Initial commit to new repository
This commit is contained in:
127
src/AxCopilot/Services/Agent/FileEditTool.cs
Normal file
127
src/AxCopilot/Services/Agent/FileEditTool.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>파일의 특정 부분을 수정하는 도구 (old_string → new_string 패턴).</summary>
|
||||
public class FileEditTool : IAgentTool
|
||||
{
|
||||
public string Name => "file_edit";
|
||||
public string Description => "Edit a file by replacing an exact string match. Set replace_all=true to replace all occurrences; otherwise old_string must be unique.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new() { Type = "string", Description = "File path to edit" },
|
||||
["old_string"] = new() { Type = "string", Description = "Exact string to find and replace" },
|
||||
["new_string"] = new() { Type = "string", Description = "Replacement string" },
|
||||
["replace_all"] = new() { Type = "boolean", Description = "Replace all occurrences (default false). If false, old_string must be unique." },
|
||||
},
|
||||
Required = ["path", "old_string", "new_string"]
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var path = args.GetProperty("path").GetString() ?? "";
|
||||
var oldStr = args.GetProperty("old_string").GetString() ?? "";
|
||||
var newStr = args.GetProperty("new_string").GetString() ?? "";
|
||||
var replaceAll = args.TryGetProperty("replace_all", out var ra) && ra.GetBoolean();
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
return ToolResult.Fail($"파일이 존재하지 않습니다: {fullPath}");
|
||||
|
||||
if (!await context.CheckWritePermissionAsync(Name, fullPath))
|
||||
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
|
||||
|
||||
try
|
||||
{
|
||||
var read = await TextFileCodec.ReadAllTextAsync(fullPath, ct);
|
||||
var content = read.Text;
|
||||
|
||||
var count = CountOccurrences(content, oldStr);
|
||||
if (count == 0)
|
||||
return ToolResult.Fail($"old_string을 파일에서 찾을 수 없습니다.");
|
||||
if (!replaceAll && count > 1)
|
||||
return ToolResult.Fail($"old_string이 {count}번 발견됩니다. replace_all=true로 전체 교체하거나, 고유한 문자열을 지정하세요.");
|
||||
|
||||
// Diff Preview: 변경 내용을 컨텍스트와 함께 표시
|
||||
var diffPreview = GenerateDiff(content, oldStr, newStr, fullPath);
|
||||
|
||||
var updated = content.Replace(oldStr, newStr);
|
||||
var writeEncoding = TextFileCodec.ResolveWriteEncoding(read.Encoding, read.HasBom);
|
||||
await TextFileCodec.WriteAllTextAsync(fullPath, updated, writeEncoding, ct);
|
||||
|
||||
var msg = replaceAll && count > 1
|
||||
? $"파일 수정 완료: {fullPath} ({count}곳 전체 교체)"
|
||||
: $"파일 수정 완료: {fullPath}";
|
||||
return ToolResult.Ok($"{msg}\n\n{diffPreview}", fullPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"파일 수정 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>변경 전/후 diff를 생성합니다 (unified diff 스타일).</summary>
|
||||
private static string GenerateDiff(string content, string oldStr, string newStr, string filePath)
|
||||
{
|
||||
var lines = content.Split('\n');
|
||||
var matchIdx = content.IndexOf(oldStr, StringComparison.Ordinal);
|
||||
if (matchIdx < 0) return "";
|
||||
|
||||
// 변경 시작 줄 번호 계산
|
||||
var startLine = content[..matchIdx].Count(c => c == '\n');
|
||||
var oldLines = oldStr.Split('\n');
|
||||
var newLines = newStr.Split('\n');
|
||||
|
||||
var sb = new StringBuilder();
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
sb.AppendLine($"--- {fileName} (before)");
|
||||
sb.AppendLine($"+++ {fileName} (after)");
|
||||
|
||||
// 컨텍스트 라인 수
|
||||
const int ctx = 2;
|
||||
var ctxStart = Math.Max(0, startLine - ctx);
|
||||
var ctxEnd = Math.Min(lines.Length - 1, startLine + oldLines.Length - 1 + ctx);
|
||||
|
||||
sb.AppendLine($"@@ -{ctxStart + 1},{ctxEnd - ctxStart + 1} @@");
|
||||
|
||||
// 앞쪽 컨텍스트
|
||||
for (int i = ctxStart; i < startLine; i++)
|
||||
sb.AppendLine($" {lines[i].TrimEnd('\r')}");
|
||||
|
||||
// 삭제 라인
|
||||
foreach (var line in oldLines)
|
||||
sb.AppendLine($"-{line.TrimEnd('\r')}");
|
||||
|
||||
// 추가 라인
|
||||
foreach (var line in newLines)
|
||||
sb.AppendLine($"+{line.TrimEnd('\r')}");
|
||||
|
||||
// 뒤쪽 컨텍스트
|
||||
var afterEnd = startLine + oldLines.Length;
|
||||
for (int i = afterEnd; i <= ctxEnd && i < lines.Length; i++)
|
||||
sb.AppendLine($" {lines[i].TrimEnd('\r')}");
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string text, string search)
|
||||
{
|
||||
if (string.IsNullOrEmpty(search)) return 0;
|
||||
int count = 0, idx = 0;
|
||||
while ((idx = text.IndexOf(search, idx, StringComparison.Ordinal)) != -1)
|
||||
{
|
||||
count++;
|
||||
idx += search.Length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user