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