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