1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "strings"
11 "time"
12
13 "github.com/cloudwego/eino/components/tool"
14 "github.com/cloudwego/eino/schema"
15 "github.com/kujtimiihoxha/termai/internal/permission"
16 "github.com/sergi/go-diff/diffmatchpatch"
17)
18
19type editTool struct {
20 workingDir string
21}
22
23const (
24 EditToolName = "edit"
25)
26
27type EditParams struct {
28 FilePath string `json:"file_path"`
29 OldString string `json:"old_string"`
30 NewString string `json:"new_string"`
31}
32
33func (b *editTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
34 return &schema.ToolInfo{
35 Name: EditToolName,
36 Desc: `This is a tool for editing files. For moving or renaming files, you should generally use the Bash tool with the 'mv' command instead. For larger edits, use the Write tool to overwrite files. F.
37
38Before using this tool:
39
401. Use the View tool to understand the file's contents and context
41
422. Verify the directory path is correct (only applicable when creating new files):
43 - Use the LS tool to verify the parent directory exists and is the correct location
44
45To make a file edit, provide the following:
461. file_path: The absolute path to the file to modify (must be absolute, not relative)
472. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
483. new_string: The edited text to replace the old_string
49
50The tool will replace ONE occurrence of old_string with new_string in the specified file.
51
52CRITICAL REQUIREMENTS FOR USING THIS TOOL:
53
541. UNIQUENESS: The old_string MUST uniquely identify the specific instance you want to change. This means:
55 - Include AT LEAST 3-5 lines of context BEFORE the change point
56 - Include AT LEAST 3-5 lines of context AFTER the change point
57 - Include all whitespace, indentation, and surrounding code exactly as it appears in the file
58
592. SINGLE INSTANCE: This tool can only change ONE instance at a time. If you need to change multiple instances:
60 - Make separate calls to this tool for each instance
61 - Each call must uniquely identify its specific instance using extensive context
62
633. VERIFICATION: Before using this tool:
64 - Check how many instances of the target text exist in the file
65 - If multiple instances exist, gather enough context to uniquely identify each one
66 - Plan separate tool calls for each instance
67
68WARNING: If you do not follow these requirements:
69 - The tool will fail if old_string matches multiple locations
70 - The tool will fail if old_string doesn't match exactly (including whitespace)
71 - You may change the wrong instance if you don't include enough context
72
73When making edits:
74 - Ensure the edit results in idiomatic, correct code
75 - Do not leave the code in a broken state
76 - Always use absolute file paths (starting with /)
77
78If you want to create a new file, use:
79 - A new file path, including dir name if needed
80 - An empty old_string
81 - The new file's contents as new_string
82
83Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.`,
84 ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
85 "file_path": {
86 Type: "string",
87 Desc: "The absolute path to the file to modify",
88 Required: true,
89 },
90 "old_string": {
91 Type: "string",
92 Desc: "The text to replace",
93 Required: true,
94 },
95 "new_string": {
96 Type: "string",
97 Desc: "The text to replace it with",
98 Required: true,
99 },
100 }),
101 }, nil
102}
103
104func (b *editTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
105 var params EditParams
106 if err := json.Unmarshal([]byte(args), ¶ms); err != nil {
107 return "", err
108 }
109
110 if params.FilePath == "" {
111 return "", errors.New("file_path is required")
112 }
113
114 if !filepath.IsAbs(params.FilePath) {
115 return "", fmt.Errorf("file path must be absolute, got: %s", params.FilePath)
116 }
117
118 if params.OldString == "" {
119 return createNewFile(params.FilePath, params.NewString)
120 }
121
122 if params.NewString == "" {
123 return deleteContent(params.FilePath, params.OldString)
124 }
125
126 return replaceContent(params.FilePath, params.OldString, params.NewString)
127}
128
129func createNewFile(filePath, content string) (string, error) {
130 fileInfo, err := os.Stat(filePath)
131 if err == nil {
132 if fileInfo.IsDir() {
133 return "", fmt.Errorf("path is a directory, not a file: %s", filePath)
134 }
135 return "", fmt.Errorf("file already exists: %s. Use the Replace tool to overwrite an existing file", filePath)
136 } else if !os.IsNotExist(err) {
137 return "", fmt.Errorf("failed to access file: %w", err)
138 }
139
140 dir := filepath.Dir(filePath)
141 if err = os.MkdirAll(dir, 0o755); err != nil {
142 return "", fmt.Errorf("failed to create parent directories: %w", err)
143 }
144
145 p := permission.Default.Request(
146 permission.CreatePermissionRequest{
147 Path: filepath.Dir(filePath),
148 ToolName: EditToolName,
149 Action: "create",
150 Description: fmt.Sprintf("Create file %s", filePath),
151 Params: map[string]interface{}{
152 "file_path": filePath,
153 "content": content,
154 },
155 },
156 )
157 if !p {
158 return "", fmt.Errorf("permission denied")
159 }
160
161 err = os.WriteFile(filePath, []byte(content), 0o644)
162 if err != nil {
163 return "", fmt.Errorf("failed to write file: %w", err)
164 }
165
166 recordFileWrite(filePath)
167 recordFileRead(filePath)
168
169 // result := FileEditResult{
170 // FilePath: filePath,
171 // Created: true,
172 // Updated: false,
173 // Deleted: false,
174 // Diff: generateDiff("", content),
175 // }
176 //
177 // resultJSON, err := json.Marshal(result)
178 // if err != nil {
179 // return "", fmt.Errorf("failed to serialize result: %w", err)
180 // }
181 //
182 return "File created: " + filePath, nil
183}
184
185func deleteContent(filePath, oldString string) (string, error) {
186 fileInfo, err := os.Stat(filePath)
187 if err != nil {
188 if os.IsNotExist(err) {
189 return "", fmt.Errorf("file not found: %s", filePath)
190 }
191 return "", fmt.Errorf("failed to access file: %w", err)
192 }
193
194 if fileInfo.IsDir() {
195 return "", fmt.Errorf("path is a directory, not a file: %s", filePath)
196 }
197
198 if getLastReadTime(filePath).IsZero() {
199 return "", fmt.Errorf("you must read the file before editing it. Use the View tool first")
200 }
201
202 modTime := fileInfo.ModTime()
203 lastRead := getLastReadTime(filePath)
204 if modTime.After(lastRead) {
205 return "", fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
206 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
207 }
208
209 content, err := os.ReadFile(filePath)
210 if err != nil {
211 return "", fmt.Errorf("failed to read file: %w", err)
212 }
213
214 oldContent := string(content)
215
216 index := strings.Index(oldContent, oldString)
217 if index == -1 {
218 return "", fmt.Errorf("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks")
219 }
220
221 lastIndex := strings.LastIndex(oldContent, oldString)
222 if index != lastIndex {
223 return "", fmt.Errorf("old_string appears multiple times in the file. Please provide more context to ensure a unique match")
224 }
225
226 newContent := oldContent[:index] + oldContent[index+len(oldString):]
227
228 p := permission.Default.Request(
229 permission.CreatePermissionRequest{
230 Path: filepath.Dir(filePath),
231 ToolName: EditToolName,
232 Action: "delete",
233 Description: fmt.Sprintf("Delete content from file %s", filePath),
234 Params: map[string]interface{}{
235 "file_path": filePath,
236 "content": content,
237 },
238 },
239 )
240 if !p {
241 return "", fmt.Errorf("permission denied")
242 }
243
244 err = os.WriteFile(filePath, []byte(newContent), 0o644)
245 if err != nil {
246 return "", fmt.Errorf("failed to write file: %w", err)
247 }
248
249 recordFileWrite(filePath)
250
251 // result := FileEditResult{
252 // FilePath: filePath,
253 // Created: false,
254 // Updated: true,
255 // Deleted: true,
256 // Diff: generateDiff(oldContent, newContent),
257 // SnippetBefore: getContextSnippet(oldContent, index, len(oldString)),
258 // SnippetAfter: getContextSnippet(newContent, index, 0),
259 // }
260 //
261 // resultJSON, err := json.Marshal(result)
262 // if err != nil {
263 // return "", fmt.Errorf("failed to serialize result: %w", err)
264 // }
265
266 return "Content deleted from file: " + filePath, nil
267}
268
269func replaceContent(filePath, oldString, newString string) (string, error) {
270 fileInfo, err := os.Stat(filePath)
271 if err != nil {
272 if os.IsNotExist(err) {
273 return fmt.Sprintf("file not found: %s", filePath), nil
274 }
275 return fmt.Sprintf("failed to access file: %s", err), nil
276 }
277
278 if fileInfo.IsDir() {
279 return fmt.Sprintf("path is a directory, not a file: %s", filePath), nil
280 }
281
282 if getLastReadTime(filePath).IsZero() {
283 return "you must read the file before editing it. Use the View tool first", nil
284 }
285
286 modTime := fileInfo.ModTime()
287 lastRead := getLastReadTime(filePath)
288 if modTime.After(lastRead) {
289 return fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
290 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339)), nil
291 }
292
293 content, err := os.ReadFile(filePath)
294 if err != nil {
295 return fmt.Sprintf("failed to read file: %s", err), nil
296 }
297
298 oldContent := string(content)
299
300 index := strings.Index(oldContent, oldString)
301 if index == -1 {
302 return "old_string not found in file. Make sure it matches exactly, including whitespace and line breaks", nil
303 }
304
305 lastIndex := strings.LastIndex(oldContent, oldString)
306 if index != lastIndex {
307 return "old_string appears multiple times in the file. Please provide more context to ensure a unique match", nil
308 }
309
310 newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
311
312 p := permission.Default.Request(
313 permission.CreatePermissionRequest{
314 Path: filepath.Dir(filePath),
315 ToolName: EditToolName,
316 Action: "replace",
317 Description: fmt.Sprintf("Replace content in file %s", filePath),
318 Params: map[string]interface{}{
319 "file_path": filePath,
320 "old_string": oldString,
321 "new_string": newString,
322 },
323 },
324 )
325 if !p {
326 return "", fmt.Errorf("permission denied")
327 }
328
329 err = os.WriteFile(filePath, []byte(newContent), 0o644)
330 if err != nil {
331 return fmt.Sprintf("failed to write file: %s", err), nil
332 }
333
334 recordFileWrite(filePath)
335
336 // result := FileEditResult{
337 // FilePath: filePath,
338 // Created: false,
339 // Updated: true,
340 // Deleted: false,
341 // Diff: generateDiff(oldContent, newContent),
342 // SnippetBefore: getContextSnippet(oldContent, index, len(oldString)),
343 // SnippetAfter: getContextSnippet(newContent, index, len(newString)),
344 // }
345 //
346 // resultJSON, err := json.Marshal(result)
347 // if err != nil {
348 // return "", fmt.Errorf("failed to serialize result: %w", err)
349 // }
350
351 return "Content replaced in file: " + filePath, nil
352}
353
354func getContextSnippet(content string, position, length int) string {
355 contextLines := 3
356
357 lines := strings.Split(content, "\n")
358 lineIndex := 0
359 currentPos := 0
360
361 for i, line := range lines {
362 if currentPos <= position && position < currentPos+len(line)+1 {
363 lineIndex = i
364 break
365 }
366 currentPos += len(line) + 1 // +1 for the newline
367 }
368
369 startLine := max(0, lineIndex-contextLines)
370 endLine := min(len(lines), lineIndex+contextLines+1)
371
372 var snippetBuilder strings.Builder
373 for i := startLine; i < endLine; i++ {
374 if i == lineIndex {
375 snippetBuilder.WriteString(fmt.Sprintf("> %s\n", lines[i]))
376 } else {
377 snippetBuilder.WriteString(fmt.Sprintf(" %s\n", lines[i]))
378 }
379 }
380
381 return snippetBuilder.String()
382}
383
384func generateDiff(oldContent, newContent string) string {
385 dmp := diffmatchpatch.New()
386
387 diffs := dmp.DiffMain(oldContent, newContent, false)
388
389 patches := dmp.PatchMake(oldContent, diffs)
390 patchText := dmp.PatchToText(patches)
391
392 if patchText == "" && (oldContent != newContent) {
393 var result strings.Builder
394
395 result.WriteString("@@ Diff @@\n")
396 for _, diff := range diffs {
397 switch diff.Type {
398 case diffmatchpatch.DiffInsert:
399 result.WriteString("+ " + diff.Text + "\n")
400 case diffmatchpatch.DiffDelete:
401 result.WriteString("- " + diff.Text + "\n")
402 case diffmatchpatch.DiffEqual:
403 if len(diff.Text) > 40 {
404 result.WriteString(" " + diff.Text[:20] + "..." + diff.Text[len(diff.Text)-20:] + "\n")
405 } else {
406 result.WriteString(" " + diff.Text + "\n")
407 }
408 }
409 }
410 return result.String()
411 }
412
413 return patchText
414}
415
416func NewEditTool(workingDir string) tool.InvokableTool {
417 return &editTool{
418 workingDir: workingDir,
419 }
420}