edit.go

  1package tools
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"os"
  8	"path/filepath"
  9	"strings"
 10	"time"
 11
 12	"github.com/kujtimiihoxha/termai/internal/config"
 13	"github.com/kujtimiihoxha/termai/internal/permission"
 14	"github.com/sergi/go-diff/diffmatchpatch"
 15)
 16
 17type editTool struct{}
 18
 19const (
 20	EditToolName = "edit"
 21)
 22
 23type EditParams struct {
 24	FilePath  string `json:"file_path"`
 25	OldString string `json:"old_string"`
 26	NewString string `json:"new_string"`
 27}
 28
 29type EditPermissionsParams struct {
 30	FilePath  string `json:"file_path"`
 31	OldString string `json:"old_string"`
 32	NewString string `json:"new_string"`
 33	Diff      string `json:"diff"`
 34}
 35
 36func (e *editTool) Info() ToolInfo {
 37	return ToolInfo{
 38		Name:        EditToolName,
 39		Description: editDescription(),
 40		Parameters: map[string]any{
 41			"file_path": map[string]any{
 42				"type":        "string",
 43				"description": "The absolute path to the file to modify",
 44			},
 45			"old_string": map[string]any{
 46				"type":        "string",
 47				"description": "The text to replace",
 48			},
 49			"new_string": map[string]any{
 50				"type":        "string",
 51				"description": "The text to replace it with",
 52			},
 53		},
 54		Required: []string{"file_path", "old_string", "new_string"},
 55	}
 56}
 57
 58// Run implements Tool.
 59func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
 60	var params EditParams
 61	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
 62		return NewTextErrorResponse("invalid parameters"), nil
 63	}
 64
 65	if params.FilePath == "" {
 66		return NewTextErrorResponse("file_path is required"), nil
 67	}
 68
 69	if !filepath.IsAbs(params.FilePath) {
 70		wd := config.WorkingDirectory()
 71		params.FilePath = filepath.Join(wd, params.FilePath)
 72	}
 73
 74	if params.OldString == "" {
 75		result, err := createNewFile(params.FilePath, params.NewString)
 76		if err != nil {
 77			return NewTextErrorResponse(fmt.Sprintf("error creating file: %s", err)), nil
 78		}
 79		return NewTextErrorResponse(result), nil
 80	}
 81
 82	if params.NewString == "" {
 83		result, err := deleteContent(params.FilePath, params.OldString)
 84		if err != nil {
 85			return NewTextErrorResponse(fmt.Sprintf("error deleting content: %s", err)), nil
 86		}
 87		return NewTextErrorResponse(result), nil
 88	}
 89
 90	result, err := replaceContent(params.FilePath, params.OldString, params.NewString)
 91	if err != nil {
 92		return NewTextErrorResponse(fmt.Sprintf("error replacing content: %s", err)), nil
 93	}
 94	return NewTextResponse(result), nil
 95}
 96
 97func createNewFile(filePath, content string) (string, error) {
 98	fileInfo, err := os.Stat(filePath)
 99	if err == nil {
100		if fileInfo.IsDir() {
101			return "", fmt.Errorf("path is a directory, not a file: %s", filePath)
102		}
103		return "", fmt.Errorf("file already exists: %s. Use the Replace tool to overwrite an existing file", filePath)
104	} else if !os.IsNotExist(err) {
105		return "", fmt.Errorf("failed to access file: %w", err)
106	}
107
108	dir := filepath.Dir(filePath)
109	if err = os.MkdirAll(dir, 0o755); err != nil {
110		return "", fmt.Errorf("failed to create parent directories: %w", err)
111	}
112
113	p := permission.Default.Request(
114		permission.CreatePermissionRequest{
115			Path:        filepath.Dir(filePath),
116			ToolName:    EditToolName,
117			Action:      "create",
118			Description: fmt.Sprintf("Create file %s", filePath),
119			Params: EditPermissionsParams{
120				FilePath:  filePath,
121				OldString: "",
122				NewString: content,
123				Diff:      GenerateDiff("", content),
124			},
125		},
126	)
127	if !p {
128		return "", fmt.Errorf("permission denied")
129	}
130
131	err = os.WriteFile(filePath, []byte(content), 0o644)
132	if err != nil {
133		return "", fmt.Errorf("failed to write file: %w", err)
134	}
135
136	recordFileWrite(filePath)
137	recordFileRead(filePath)
138
139	return "File created: " + filePath, nil
140}
141
142func deleteContent(filePath, oldString string) (string, error) {
143	fileInfo, err := os.Stat(filePath)
144	if err != nil {
145		if os.IsNotExist(err) {
146			return "", fmt.Errorf("file not found: %s", filePath)
147		}
148		return "", fmt.Errorf("failed to access file: %w", err)
149	}
150
151	if fileInfo.IsDir() {
152		return "", fmt.Errorf("path is a directory, not a file: %s", filePath)
153	}
154
155	if getLastReadTime(filePath).IsZero() {
156		return "", fmt.Errorf("you must read the file before editing it. Use the View tool first")
157	}
158
159	modTime := fileInfo.ModTime()
160	lastRead := getLastReadTime(filePath)
161	if modTime.After(lastRead) {
162		return "", fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
163			filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
164	}
165
166	content, err := os.ReadFile(filePath)
167	if err != nil {
168		return "", fmt.Errorf("failed to read file: %w", err)
169	}
170
171	oldContent := string(content)
172
173	index := strings.Index(oldContent, oldString)
174	if index == -1 {
175		return "", fmt.Errorf("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks")
176	}
177
178	lastIndex := strings.LastIndex(oldContent, oldString)
179	if index != lastIndex {
180		return "", fmt.Errorf("old_string appears multiple times in the file. Please provide more context to ensure a unique match")
181	}
182
183	newContent := oldContent[:index] + oldContent[index+len(oldString):]
184
185	p := permission.Default.Request(
186		permission.CreatePermissionRequest{
187			Path:        filepath.Dir(filePath),
188			ToolName:    EditToolName,
189			Action:      "delete",
190			Description: fmt.Sprintf("Delete content from file %s", filePath),
191			Params: EditPermissionsParams{
192				FilePath:  filePath,
193				OldString: oldString,
194				NewString: "",
195				Diff:      GenerateDiff(oldContent, newContent),
196			},
197		},
198	)
199	if !p {
200		return "", fmt.Errorf("permission denied")
201	}
202
203	err = os.WriteFile(filePath, []byte(newContent), 0o644)
204	if err != nil {
205		return "", fmt.Errorf("failed to write file: %w", err)
206	}
207
208	recordFileWrite(filePath)
209	recordFileRead(filePath)
210
211	return "Content deleted from file: " + filePath, nil
212}
213
214func replaceContent(filePath, oldString, newString string) (string, error) {
215	fileInfo, err := os.Stat(filePath)
216	if err != nil {
217		if os.IsNotExist(err) {
218			return "", fmt.Errorf("file not found: %s", filePath)
219		}
220		return "", fmt.Errorf("failed to access file: %w", err)
221	}
222
223	if fileInfo.IsDir() {
224		return "", fmt.Errorf("path is a directory, not a file: %s", filePath)
225	}
226
227	if getLastReadTime(filePath).IsZero() {
228		return "", fmt.Errorf("you must read the file before editing it. Use the View tool first")
229	}
230
231	modTime := fileInfo.ModTime()
232	lastRead := getLastReadTime(filePath)
233	if modTime.After(lastRead) {
234		return "", fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
235			filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
236	}
237
238	content, err := os.ReadFile(filePath)
239	if err != nil {
240		return "", fmt.Errorf("failed to read file: %w", err)
241	}
242
243	oldContent := string(content)
244
245	index := strings.Index(oldContent, oldString)
246	if index == -1 {
247		return "", fmt.Errorf("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks")
248	}
249
250	lastIndex := strings.LastIndex(oldContent, oldString)
251	if index != lastIndex {
252		return "", fmt.Errorf("old_string appears multiple times in the file. Please provide more context to ensure a unique match")
253	}
254
255	newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
256	diff := GenerateDiff(oldString, newContent)
257
258	p := permission.Default.Request(
259		permission.CreatePermissionRequest{
260			Path:        filepath.Dir(filePath),
261			ToolName:    EditToolName,
262			Action:      "replace",
263			Description: fmt.Sprintf("Replace content in file %s", filePath),
264			Params: EditPermissionsParams{
265				FilePath:  filePath,
266				OldString: oldString,
267				NewString: newString,
268				Diff:      diff,
269			},
270		},
271	)
272	if !p {
273		return "", fmt.Errorf("permission denied")
274	}
275
276	err = os.WriteFile(filePath, []byte(newContent), 0o644)
277	if err != nil {
278		return "", fmt.Errorf("failed to write file: %w", err)
279	}
280
281	recordFileWrite(filePath)
282	recordFileRead(filePath)
283
284	return "Content replaced in file: " + filePath, nil
285}
286
287func GenerateDiff(oldContent, newContent string) string {
288	dmp := diffmatchpatch.New()
289	fileAdmp, fileBdmp, dmpStrings := dmp.DiffLinesToChars(oldContent, newContent)
290	diffs := dmp.DiffMain(fileAdmp, fileBdmp, false)
291	diffs = dmp.DiffCharsToLines(diffs, dmpStrings)
292	diffs = dmp.DiffCleanupSemantic(diffs)
293	buff := strings.Builder{}
294	for _, diff := range diffs {
295		text := diff.Text
296
297		switch diff.Type {
298		case diffmatchpatch.DiffInsert:
299			for _, line := range strings.Split(text, "\n") {
300				_, _ = buff.WriteString("+ " + line + "\n")
301			}
302		case diffmatchpatch.DiffDelete:
303			for _, line := range strings.Split(text, "\n") {
304				_, _ = buff.WriteString("- " + line + "\n")
305			}
306		case diffmatchpatch.DiffEqual:
307			if len(text) > 40 {
308				_, _ = buff.WriteString("  " + text[:20] + "..." + text[len(text)-20:] + "\n")
309			} else {
310				for _, line := range strings.Split(text, "\n") {
311					_, _ = buff.WriteString("  " + line + "\n")
312				}
313			}
314		}
315	}
316	return buff.String()
317}
318
319func editDescription() string {
320	return `Edits files by replacing text, creating new files, or deleting content. For moving or renaming files, use the Bash tool with the 'mv' command instead. For larger file edits, use the FileWrite tool to overwrite files.
321
322Before using this tool:
323
3241. Use the FileRead tool to understand the file's contents and context
325
3262. Verify the directory path is correct (only applicable when creating new files):
327   - Use the LS tool to verify the parent directory exists and is the correct location
328
329To make a file edit, provide the following:
3301. file_path: The absolute path to the file to modify (must be absolute, not relative)
3312. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
3323. new_string: The edited text to replace the old_string
333
334Special cases:
335- To create a new file: provide file_path and new_string, leave old_string empty
336- To delete content: provide file_path and old_string, leave new_string empty
337
338The tool will replace ONE occurrence of old_string with new_string in the specified file.
339
340CRITICAL REQUIREMENTS FOR USING THIS TOOL:
341
3421. UNIQUENESS: The old_string MUST uniquely identify the specific instance you want to change. This means:
343   - Include AT LEAST 3-5 lines of context BEFORE the change point
344   - Include AT LEAST 3-5 lines of context AFTER the change point
345   - Include all whitespace, indentation, and surrounding code exactly as it appears in the file
346
3472. SINGLE INSTANCE: This tool can only change ONE instance at a time. If you need to change multiple instances:
348   - Make separate calls to this tool for each instance
349   - Each call must uniquely identify its specific instance using extensive context
350
3513. VERIFICATION: Before using this tool:
352   - Check how many instances of the target text exist in the file
353   - If multiple instances exist, gather enough context to uniquely identify each one
354   - Plan separate tool calls for each instance
355
356WARNING: If you do not follow these requirements:
357   - The tool will fail if old_string matches multiple locations
358   - The tool will fail if old_string doesn't match exactly (including whitespace)
359   - You may change the wrong instance if you don't include enough context
360
361When making edits:
362   - Ensure the edit results in idiomatic, correct code
363   - Do not leave the code in a broken state
364   - Always use absolute file paths (starting with /)
365
366Remember: 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.`
367}
368
369func NewEditTool() BaseTool {
370	return &editTool{}
371}