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