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}";
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) + 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}";
}
}
[Description("Insert lines after the specified line:hash anchor.")]
public static string InsertAfter(
[Description("Path to the file.")] string path,
[Description("line:hash anchor to insert after (e.g. '3:0e').")] string anchor,
[Description("Raw source code to insert. Do NOT include 'lineNumber:hash|' prefixes.")] string[] newLines)
{
newLines = SanitizeNewLines(newLines);
path = FileTools.ResolvePath(path);
Log($"INSERT_AFTER: {path}");
Log($" Anchor: {anchor}");
Log($" Inserting {newLines.Length} lines after line {anchor.Split(':')[0]}");
if (!File.Exists(path))
return $"ERROR: File not found: {path}";
try
{
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 + newLines.Length);
result.AddRange(lines[..(idx + 1)]);
result.AddRange(newLines);
result.AddRange(lines[(idx + 1)..]);
File.WriteAllLines(path, result);
return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}";
}
catch (Exception ex)
{
return $"ERROR modifying '{path}': {ex.Message}";
}
}
[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}";
}
}
[Description("Create a new file (parents auto-created). Max initial lines: 200. Alternatively, append lines later.")]
public static string CreateFile(
[Description("Path to the new file to create.")] string path,
[Description("Optional initial raw source code. Do NOT include 'lineNumber:hash|' prefixes.")] string[]? initialLines = null)
{
path = FileTools.ResolvePath(path);
Log($"Creating file: {path}");
if (File.Exists(path))
return $"ERROR: File already exists: {path}";
try
{
if (initialLines is not null)
initialLines = SanitizeNewLines(initialLines);
string? dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
if (initialLines is not null && initialLines.Length > 0)
File.WriteAllLines(path, initialLines);
else
File.WriteAllText(path, "");
return $"OK fp:{HashlineEncoder.FileFingerprint(initialLines ?? [])}";
}
catch (Exception ex)
{
return $"ERROR creating '{path}': {ex.Message}";
}
}
[Description("Delete a file permanently.")]
public static string DeleteFile(
[Description("Path to the file to delete.")] string path)
{
path = FileTools.ResolvePath(path);
Log($"Deleting file: {path}");
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}";
}
}
[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}";
}
}
[Description("Append lines to EOF (auto-creating the file if missing).")]
public static string AppendToFile(
[Description("Path to the file to append to.")] string path,
[Description("Raw source code to append. Do NOT include 'lineNumber:hash|' prefixes.")] string[] lines)
{
lines = SanitizeNewLines(lines);
path = FileTools.ResolvePath(path);
Log($"Appending to file: {path}");
Log($" Appending {lines.Length} lines");
try
{
string? dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
if (!File.Exists(path))
{
File.WriteAllText(path, "");
Log($" (created new file)");
}
using (var writer = new System.IO.StreamWriter(path, true))
{
foreach (var line in lines)
{
writer.WriteLine(line);
}
}
string[] allLines = File.ReadAllLines(path);
return $"OK fp:{HashlineEncoder.FileFingerprint([.. allLines])}";
}
catch (Exception ex)
{
return $"ERROR appending to '{path}': {ex.Message}";
}
}
[GeneratedRegex(@"^\d+:[0-9a-fA-F]{2}\|", RegexOptions.Compiled)]
private static partial Regex MyRegex();
}