edit.go

  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), &params); 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}