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