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	var response ToolResponse
135	var err error
136
137	if params.OldString == "" {
138		response, err = e.createNewFile(ctx, params.FilePath, params.NewString)
139		if err != nil {
140			return response, nil
141		}
142	}
143
144	if params.NewString == "" {
145		response, err = e.deleteContent(ctx, params.FilePath, params.OldString)
146		if err != nil {
147			return response, nil
148		}
149	}
150
151	response, err = e.replaceContent(ctx, params.FilePath, params.OldString, params.NewString)
152	if err != nil {
153		return response, nil
154	}
155
156	waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
157	text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
158	text += getDiagnostics(params.FilePath, e.lspClients)
159	response.Content = text
160	return response, nil
161}
162
163func (e *editTool) createNewFile(ctx context.Context, filePath, content string) (ToolResponse, error) {
164	fileInfo, err := os.Stat(filePath)
165	if err == nil {
166		if fileInfo.IsDir() {
167			return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
168		}
169		return NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
170	} else if !os.IsNotExist(err) {
171		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
172	}
173
174	dir := filepath.Dir(filePath)
175	if err = os.MkdirAll(dir, 0o755); err != nil {
176		return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
177	}
178
179	sessionID, messageID := GetContextValues(ctx)
180	if sessionID == "" || messageID == "" {
181		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
182	}
183
184	diff, stats, err := git.GenerateGitDiffWithStats(
185		removeWorkingDirectoryPrefix(filePath),
186		"",
187		content,
188	)
189	if err != nil {
190		return ToolResponse{}, fmt.Errorf("failed to get file diff: %w", err)
191	}
192	p := e.permissions.Request(
193		permission.CreatePermissionRequest{
194			Path:        filepath.Dir(filePath),
195			ToolName:    EditToolName,
196			Action:      "create",
197			Description: fmt.Sprintf("Create file %s", filePath),
198			Params: EditPermissionsParams{
199				FilePath: filePath,
200				Diff:     diff,
201			},
202		},
203	)
204	if !p {
205		return ToolResponse{}, permission.ErrorPermissionDenied
206	}
207
208	err = os.WriteFile(filePath, []byte(content), 0o644)
209	if err != nil {
210		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
211	}
212
213	recordFileWrite(filePath)
214	recordFileRead(filePath)
215
216	return WithResponseMetadata(
217		NewTextResponse("File created: "+filePath),
218		EditResponseMetadata{
219			Additions: stats.Additions,
220			Removals:  stats.Removals,
221		},
222	), nil
223}
224
225func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string) (ToolResponse, error) {
226	fileInfo, err := os.Stat(filePath)
227	if err != nil {
228		if os.IsNotExist(err) {
229			return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
230		}
231		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
232	}
233
234	if fileInfo.IsDir() {
235		return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
236	}
237
238	if getLastReadTime(filePath).IsZero() {
239		return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
240	}
241
242	modTime := fileInfo.ModTime()
243	lastRead := getLastReadTime(filePath)
244	if modTime.After(lastRead) {
245		return NewTextErrorResponse(
246			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
247				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
248			)), nil
249	}
250
251	content, err := os.ReadFile(filePath)
252	if err != nil {
253		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
254	}
255
256	oldContent := string(content)
257
258	index := strings.Index(oldContent, oldString)
259	if index == -1 {
260		return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
261	}
262
263	lastIndex := strings.LastIndex(oldContent, oldString)
264	if index != lastIndex {
265		return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil
266	}
267
268	newContent := oldContent[:index] + oldContent[index+len(oldString):]
269
270	sessionID, messageID := GetContextValues(ctx)
271
272	if sessionID == "" || messageID == "" {
273		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
274	}
275
276	diff, stats, err := git.GenerateGitDiffWithStats(
277		removeWorkingDirectoryPrefix(filePath),
278		oldContent,
279		newContent,
280	)
281	if err != nil {
282		return ToolResponse{}, fmt.Errorf("failed to get file diff: %w", err)
283	}
284
285	p := e.permissions.Request(
286		permission.CreatePermissionRequest{
287			Path:        filepath.Dir(filePath),
288			ToolName:    EditToolName,
289			Action:      "delete",
290			Description: fmt.Sprintf("Delete content from file %s", filePath),
291			Params: EditPermissionsParams{
292				FilePath: filePath,
293				Diff:     diff,
294			},
295		},
296	)
297	if !p {
298		return ToolResponse{}, permission.ErrorPermissionDenied
299	}
300
301	err = os.WriteFile(filePath, []byte(newContent), 0o644)
302	if err != nil {
303		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
304	}
305	recordFileWrite(filePath)
306	recordFileRead(filePath)
307
308	return WithResponseMetadata(
309		NewTextResponse("Content deleted from file: "+filePath),
310		EditResponseMetadata{
311			Additions: stats.Additions,
312			Removals:  stats.Removals,
313		},
314	), nil
315}
316
317func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string) (ToolResponse, error) {
318	fileInfo, err := os.Stat(filePath)
319	if err != nil {
320		if os.IsNotExist(err) {
321			return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
322		}
323		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
324	}
325
326	if fileInfo.IsDir() {
327		return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
328	}
329
330	if getLastReadTime(filePath).IsZero() {
331		return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
332	}
333
334	modTime := fileInfo.ModTime()
335	lastRead := getLastReadTime(filePath)
336	if modTime.After(lastRead) {
337		return NewTextErrorResponse(
338			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
339				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
340			)), nil
341	}
342
343	content, err := os.ReadFile(filePath)
344	if err != nil {
345		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
346	}
347
348	oldContent := string(content)
349
350	index := strings.Index(oldContent, oldString)
351	if index == -1 {
352		return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
353	}
354
355	lastIndex := strings.LastIndex(oldContent, oldString)
356	if index != lastIndex {
357		return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil
358	}
359
360	newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
361
362	sessionID, messageID := GetContextValues(ctx)
363
364	if sessionID == "" || messageID == "" {
365		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
366	}
367	diff, stats, err := git.GenerateGitDiffWithStats(
368		removeWorkingDirectoryPrefix(filePath),
369		oldContent,
370		newContent,
371	)
372	if err != nil {
373		return ToolResponse{}, fmt.Errorf("failed to get file diff: %w", err)
374	}
375
376	p := e.permissions.Request(
377		permission.CreatePermissionRequest{
378			Path:        filepath.Dir(filePath),
379			ToolName:    EditToolName,
380			Action:      "replace",
381			Description: fmt.Sprintf("Replace content in file %s", filePath),
382			Params: EditPermissionsParams{
383				FilePath: filePath,
384
385				Diff: diff,
386			},
387		},
388	)
389	if !p {
390		return ToolResponse{}, permission.ErrorPermissionDenied
391	}
392
393	err = os.WriteFile(filePath, []byte(newContent), 0o644)
394	if err != nil {
395		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
396	}
397
398	recordFileWrite(filePath)
399	recordFileRead(filePath)
400
401	return WithResponseMetadata(
402		NewTextResponse("Content replaced in file: "+filePath),
403		EditResponseMetadata{
404			Additions: stats.Additions,
405			Removals:  stats.Removals,
406		}), nil
407}