using System.IO; using System.Text; using System.Text.Json; namespace AxCopilot.Services.Agent; /// 파일의 특정 부분을 수정하는 도구 (old_string → new_string 패턴). 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 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}"); } } /// 변경 전/후 diff를 생성합니다 (unified diff 스타일). 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; } }