using System.ComponentModel;
using System.Text.RegularExpressions;
using AnchorCli.Hashline;
namespace AnchorCli.Tools;
///
/// Mutating file tools exposed to the LLM as AIFunctions.
/// Every operation validates Hashline anchors (line:hash format) before touching the file.
/// On success, returns "OK fp:{fingerprint}" so the model can verify the mutation.
/// On failure, returns a descriptive ERROR string — no exceptions reach the LLM.
///
internal static partial class EditTools
{
public static Action Log { get; set; } = Console.WriteLine;
///
/// Matches accidental hashline prefixes the LLM may include in new content.
/// Format: "lineNumber:hexHash|" e.g. "5:a3|"
///
private static readonly Regex HashlinePrefix =
MyRegex();
///
/// Strips accidental hashline prefixes from lines the LLM sends as new content.
/// Logs a warning when stripping occurs.
///
private static string[] SanitizeNewLines(string[] newLines)
{
bool anyStripped = false;
var result = new string[newLines.Length];
for (int i = 0; i < newLines.Length; i++)
{
var match = HashlinePrefix.Match(newLines[i]);
if (match.Success)
{
result[i] = newLines[i][match.Length..];
anyStripped = true;
}
else
{
result[i] = newLines[i];
}
}
if (anyStripped)
{
Log(" ⚠ Stripped hashline prefixes from new content (LLM included anchors in edit content)");
}
return result;
}
[Description("Replace a range of lines. Both line number and hash in the line:hash anchor must match.")]
public static string ReplaceLines(
[Description("Path to the file.")] string path,
[Description("First line's line:hash anchor (e.g. '5:a3').")] string startAnchor,
[Description("Last line's line:hash anchor. Use same as startAnchor for a single line.")] string endAnchor,
[Description("Raw source code to insert. Do NOT include 'lineNumber:hash|' prefixes.")] string[] newLines)
{
newLines = SanitizeNewLines(newLines);
path = FileTools.ResolvePath(path);
Log($"REPLACE_LINES: {path}");
Log($" Range: {startAnchor} -> {endAnchor}");
Log($" Replacing {endAnchor.Split(':')[0]}-{startAnchor.Split(':')[0]} lines with {newLines.Length} new lines");
if (!File.Exists(path))
return $"ERROR: File not found: {path}\n Check the correct path and try again.";
try
{
string[] lines = File.ReadAllLines(path);
if (!HashlineValidator.TryResolveRange(startAnchor, endAnchor, lines,
out int startIdx, out int endIdx, out string error))
return $"ERROR: Anchor validation failed\n{error}";
var result = new List(lines.Length - (endIdx - startIdx + 1) + newLines.Length);
result.AddRange(lines[..startIdx]);
result.AddRange(newLines);
result.AddRange(lines[(endIdx + 1)..]);
File.WriteAllLines(path, result);
return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}";
}
catch (Exception ex)
{
return $"ERROR modifying '{path}': {ex.Message}.\nThis is a bug. Tell the user about it.";
}
}
[Description("Delete a range of lines.")]
public static string DeleteRange(
[Description("Path to the file.")] string path,
[Description("First line's line:hash anchor (e.g. '4:7c').")] string startAnchor,
[Description("Last line's line:hash anchor (e.g. '6:19').")] string endAnchor)
{
path = FileTools.ResolvePath(path);
Log($"Deleting lines in file: {path}");
if (!File.Exists(path))
return $"ERROR: File not found: {path}";
try
{
string[] lines = File.ReadAllLines(path);
if (!HashlineValidator.TryResolveRange(startAnchor, endAnchor, lines,
out int startIdx, out int endIdx, out string error))
return $"ERROR: {error}";
var result = new List(lines.Length - (endIdx - startIdx + 1));
result.AddRange(lines[..startIdx]);
result.AddRange(lines[(endIdx + 1)..]);
File.WriteAllLines(path, result);
return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}";
}
catch (Exception ex)
{
return $"ERROR modifying '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
}
}
[Description("Delete a file or directory. Use mode='file' to delete a file, mode='dir' to delete a directory.")]
public static string Delete(
[Description("Path to the file or directory to delete.")] string path,
[Description("Type of deletion: 'file' or 'dir'. Defaults to 'file'.")] string mode = "file")
{
path = FileTools.ResolvePath(path);
string targetType = mode.Equals("dir", StringComparison.CurrentCultureIgnoreCase) ? "directory" : "file";
Log($"Deleting {targetType}: {path}");
if (mode.Equals("dir", StringComparison.CurrentCultureIgnoreCase))
{
if (!Directory.Exists(path))
return $"ERROR: Directory not found: {path}";
try
{
Directory.Delete(path, true);
return $"OK: Directory deleted: '{path}'";
}
catch (Exception ex)
{
return $"ERROR deleting directory '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
}
}
else
{
if (!File.Exists(path))
return $"ERROR: File not found: {path}";
try
{
File.Delete(path);
return $"OK (deleted)";
}
catch (Exception ex)
{
return $"ERROR deleting '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
}
}
}
[Description("Move or copy a file to a new location.")]
public static string MoveFile(
[Description("Current path to the file.")] string sourcePath,
[Description("New path for the file.")] string destinationPath,
[Description("If true, copy the file instead of moving it. Defaults to false.")] bool copy = false)
{
sourcePath = FileTools.ResolvePath(sourcePath);
destinationPath = FileTools.ResolvePath(destinationPath);
string action = copy ? "Copying" : "Moving";
Log($"{action} file: {sourcePath} -> {destinationPath}");
if (!File.Exists(sourcePath))
return $"ERROR: Source file not found: {sourcePath}";
if (File.Exists(destinationPath))
return $"ERROR: Destination file already exists: {destinationPath}";
try
{
string? dir = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
if (copy)
File.Copy(sourcePath, destinationPath);
else
File.Move(sourcePath, destinationPath);
return copy ? $"OK (copied to {destinationPath})" : $"OK (moved to {destinationPath})";
}
catch (Exception ex)
{
return $"ERROR {action.ToLower()} file: {ex.Message}\nThis is a bug. Tell the user about it.";
}
}
[Description("Write to a file with different modes: create, append, or insert.")]
public static string WriteToFile(
[Description("Path to the file.")] string path,
[Description("Content to write.")] string[] content,
[Description("Write mode: 'create' (error if exists), 'append' (creates if missing), 'insert' (requires anchor)")] string mode = "create",
[Description("line:hash anchor to insert after (required for mode='insert', e.g. '3:0e').")] string? anchor = null)
{
content = SanitizeNewLines(content);
path = FileTools.ResolvePath(path);
Log($"WRITE_TO_FILE: {path}");
Log($" Mode: {mode}");
Log($" Writing {content.Length} lines");
try
{
string? dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
switch (mode.ToLower())
{
case "create":
if (File.Exists(path))
return $"ERROR: File already exists: {path}";
if (content.Length > 0)
File.WriteAllLines(path, content);
else
File.WriteAllText(path, "");
return $"OK fp:{HashlineEncoder.FileFingerprint(content)}";
case "append":
if (!File.Exists(path))
{
File.WriteAllText(path, "");
Log($" (created new file)");
}
using (var writer = new System.IO.StreamWriter(path, true))
{
foreach (var line in content)
{
writer.WriteLine(line);
}
}
string[] appendedLines = File.ReadAllLines(path);
return $"OK fp:{HashlineEncoder.FileFingerprint([.. appendedLines])}";
case "insert":
if (!File.Exists(path))
return $"ERROR: File not found: {path}";
if (string.IsNullOrEmpty(anchor))
return "ERROR: mode='insert' requires an anchor parameter";
string[] lines = File.ReadAllLines(path);
if (!HashlineValidator.TryResolve(anchor, lines, out int idx, out string error))
return $"ERROR: {error}";
var result = new List(lines.Length + content.Length);
result.AddRange(lines[..(idx + 1)]);
result.AddRange(content);
result.AddRange(lines[(idx + 1)..]);
File.WriteAllLines(path, result);
return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}";
default:
return $"ERROR: Unknown mode '{mode}'. Valid modes: create, append, insert";
}
}
catch (Exception ex)
{
return $"ERROR writing to '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
}
}
[GeneratedRegex(@"^\d+:[0-9a-fA-F]{2}\|", RegexOptions.Compiled)]
private static partial Regex MyRegex();
}