1package tools
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"log/slog"
  8	"os"
  9	"path/filepath"
 10	"strings"
 11	"time"
 12
 13	"github.com/charmbracelet/crush/internal/diff"
 14	"github.com/charmbracelet/crush/internal/history"
 15
 16	"github.com/charmbracelet/crush/internal/lsp"
 17	"github.com/charmbracelet/crush/internal/permission"
 18)
 19
 20type EditParams struct {
 21	FilePath   string `json:"file_path"`
 22	OldString  string `json:"old_string"`
 23	NewString  string `json:"new_string"`
 24	ReplaceAll bool   `json:"replace_all,omitempty"`
 25}
 26
 27type EditPermissionsParams struct {
 28	FilePath   string `json:"file_path"`
 29	OldContent string `json:"old_content,omitempty"`
 30	NewContent string `json:"new_content,omitempty"`
 31}
 32
 33type EditResponseMetadata struct {
 34	Additions  int    `json:"additions"`
 35	Removals   int    `json:"removals"`
 36	OldContent string `json:"old_content,omitempty"`
 37	NewContent string `json:"new_content,omitempty"`
 38}
 39
 40type editTool struct {
 41	lspClients  map[string]*lsp.Client
 42	permissions permission.Service
 43	files       history.Service
 44	workingDir  string
 45}
 46
 47const (
 48	EditToolName    = "edit"
 49	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.
 50
 51Before using this tool:
 52
 531. Use the FileRead tool to understand the file's contents and context
 54
 552. Verify the directory path is correct (only applicable when creating new files):
 56   - Use the LS tool to verify the parent directory exists and is the correct location
 57
 58To make a file edit, provide the following:
 591. file_path: The absolute path to the file to modify (must be absolute, not relative)
 602. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
 613. new_string: The edited text to replace the old_string
 624. replace_all: Replace all occurrences of old_string (default false)
 63
 64Special cases:
 65- To create a new file: provide file_path and new_string, leave old_string empty
 66- To delete content: provide file_path and old_string, leave new_string empty
 67
 68The tool will replace ONE occurrence of old_string with new_string in the specified file by default. Set replace_all to true to replace all occurrences.
 69
 70CRITICAL REQUIREMENTS FOR USING THIS TOOL:
 71
 721. UNIQUENESS: When replace_all is false (default), the old_string MUST uniquely identify the specific instance you want to change. This means:
 73   - Include AT LEAST 3-5 lines of context BEFORE the change point
 74   - Include AT LEAST 3-5 lines of context AFTER the change point
 75   - Include all whitespace, indentation, and surrounding code exactly as it appears in the file
 76
 772. SINGLE INSTANCE: When replace_all is false, this tool can only change ONE instance at a time. If you need to change multiple instances:
 78   - Set replace_all to true to replace all occurrences at once
 79   - Or make separate calls to this tool for each instance
 80   - Each call must uniquely identify its specific instance using extensive context
 81
 823. VERIFICATION: Before using this tool:
 83   - Check how many instances of the target text exist in the file
 84   - If multiple instances exist and replace_all is false, gather enough context to uniquely identify each one
 85   - Plan separate tool calls for each instance or use replace_all
 86
 87WARNING: If you do not follow these requirements:
 88   - The tool will fail if old_string matches multiple locations and replace_all is false
 89   - The tool will fail if old_string doesn't match exactly (including whitespace)
 90   - You may change the wrong instance if you don't include enough context
 91
 92When making edits:
 93   - Ensure the edit results in idiomatic, correct code
 94   - Do not leave the code in a broken state
 95   - Always use absolute file paths (starting with /)
 96
 97WINDOWS NOTES:
 98- File paths should use forward slashes (/) for cross-platform compatibility
 99- On Windows, absolute paths start with drive letters (C:/) but forward slashes work throughout
100- File permissions are handled automatically by the Go runtime
101
102Remember: 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.`
103)
104
105func NewEditTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service, workingDir string) BaseTool {
106	return &editTool{
107		lspClients:  lspClients,
108		permissions: permissions,
109		files:       files,
110		workingDir:  workingDir,
111	}
112}
113
114func (e *editTool) Name() string {
115	return EditToolName
116}
117
118func (e *editTool) Info() ToolInfo {
119	return ToolInfo{
120		Name:        EditToolName,
121		Description: editDescription,
122		Parameters: map[string]any{
123			"file_path": map[string]any{
124				"type":        "string",
125				"description": "The absolute path to the file to modify",
126			},
127			"old_string": map[string]any{
128				"type":        "string",
129				"description": "The text to replace",
130			},
131			"new_string": map[string]any{
132				"type":        "string",
133				"description": "The text to replace it with",
134			},
135			"replace_all": map[string]any{
136				"type":        "boolean",
137				"description": "Replace all occurrences of old_string (default false)",
138			},
139		},
140		Required: []string{"file_path", "old_string", "new_string"},
141	}
142}
143
144func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
145	var params EditParams
146	if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
147		return NewTextErrorResponse("invalid parameters"), nil
148	}
149
150	if params.FilePath == "" {
151		return NewTextErrorResponse("file_path is required"), nil
152	}
153
154	if !filepath.IsAbs(params.FilePath) {
155		params.FilePath = filepath.Join(e.workingDir, params.FilePath)
156	}
157
158	var response ToolResponse
159	var err error
160
161	if params.OldString == "" {
162		response, err = e.createNewFile(ctx, params.FilePath, params.NewString, call)
163		if err != nil {
164			return response, err
165		}
166	}
167
168	if params.NewString == "" {
169		response, err = e.deleteContent(ctx, params.FilePath, params.OldString, params.ReplaceAll, call)
170		if err != nil {
171			return response, err
172		}
173	}
174
175	response, err = e.replaceContent(ctx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
176	if err != nil {
177		return response, err
178	}
179	if response.IsError {
180		// Return early if there was an error during content replacement
181		// This prevents unnecessary LSP diagnostics processing
182		return response, nil
183	}
184
185	waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
186	text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
187	text += getDiagnostics(params.FilePath, e.lspClients)
188	response.Content = text
189	return response, nil
190}
191
192func (e *editTool) createNewFile(ctx context.Context, filePath, content string, call ToolCall) (ToolResponse, error) {
193	fileInfo, err := os.Stat(filePath)
194	if err == nil {
195		if fileInfo.IsDir() {
196			return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
197		}
198		return NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
199	} else if !os.IsNotExist(err) {
200		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
201	}
202
203	dir := filepath.Dir(filePath)
204	if err = os.MkdirAll(dir, 0o755); err != nil {
205		return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
206	}
207
208	sessionID, messageID := GetContextValues(ctx)
209	if sessionID == "" || messageID == "" {
210		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
211	}
212
213	_, additions, removals := diff.GenerateDiff(
214		"",
215		content,
216		strings.TrimPrefix(filePath, e.workingDir),
217	)
218	p := e.permissions.Request(
219		permission.CreatePermissionRequest{
220			SessionID:   sessionID,
221			Path:        filePath,
222			ToolCallID:  call.ID,
223			ToolName:    EditToolName,
224			Action:      "write",
225			Description: fmt.Sprintf("Create file %s", filePath),
226			Params: EditPermissionsParams{
227				FilePath:   filePath,
228				OldContent: "",
229				NewContent: content,
230			},
231		},
232	)
233	if !p {
234		return ToolResponse{}, permission.ErrorPermissionDenied
235	}
236
237	err = os.WriteFile(filePath, []byte(content), 0o644)
238	if err != nil {
239		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
240	}
241
242	// File can't be in the history so we create a new file history
243	_, err = e.files.Create(ctx, sessionID, filePath, "")
244	if err != nil {
245		// Log error but don't fail the operation
246		return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
247	}
248
249	// Add the new content to the file history
250	_, err = e.files.CreateVersion(ctx, sessionID, filePath, content)
251	if err != nil {
252		// Log error but don't fail the operation
253		slog.Debug("Error creating file history version", "error", err)
254	}
255
256	recordFileWrite(filePath)
257	recordFileRead(filePath)
258
259	return WithResponseMetadata(
260		NewTextResponse("File created: "+filePath),
261		EditResponseMetadata{
262			OldContent: "",
263			NewContent: content,
264			Additions:  additions,
265			Removals:   removals,
266		},
267	), nil
268}
269
270func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string, replaceAll bool, call ToolCall) (ToolResponse, error) {
271	fileInfo, err := os.Stat(filePath)
272	if err != nil {
273		if os.IsNotExist(err) {
274			return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
275		}
276		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
277	}
278
279	if fileInfo.IsDir() {
280		return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
281	}
282
283	if getLastReadTime(filePath).IsZero() {
284		return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
285	}
286
287	modTime := fileInfo.ModTime()
288	lastRead := getLastReadTime(filePath)
289	if modTime.After(lastRead) {
290		return NewTextErrorResponse(
291			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
292				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
293			)), nil
294	}
295
296	content, err := os.ReadFile(filePath)
297	if err != nil {
298		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
299	}
300
301	oldContent := string(content)
302
303	var newContent string
304	var deletionCount int
305
306	if replaceAll {
307		newContent = strings.ReplaceAll(oldContent, oldString, "")
308		deletionCount = strings.Count(oldContent, oldString)
309		if deletionCount == 0 {
310			return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
311		}
312	} else {
313		index := strings.Index(oldContent, oldString)
314		if index == -1 {
315			return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
316		}
317
318		lastIndex := strings.LastIndex(oldContent, oldString)
319		if index != lastIndex {
320			return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
321		}
322
323		newContent = oldContent[:index] + oldContent[index+len(oldString):]
324		deletionCount = 1
325	}
326
327	sessionID, messageID := GetContextValues(ctx)
328
329	if sessionID == "" || messageID == "" {
330		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
331	}
332
333	_, additions, removals := diff.GenerateDiff(
334		oldContent,
335		newContent,
336		strings.TrimPrefix(filePath, e.workingDir),
337	)
338
339	p := e.permissions.Request(
340		permission.CreatePermissionRequest{
341			SessionID:   sessionID,
342			Path:        filePath,
343			ToolCallID:  call.ID,
344			ToolName:    EditToolName,
345			Action:      "write",
346			Description: fmt.Sprintf("Delete content from file %s", filePath),
347			Params: EditPermissionsParams{
348				FilePath:   filePath,
349				OldContent: oldContent,
350				NewContent: newContent,
351			},
352		},
353	)
354	if !p {
355		return ToolResponse{}, permission.ErrorPermissionDenied
356	}
357
358	err = os.WriteFile(filePath, []byte(newContent), 0o644)
359	if err != nil {
360		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
361	}
362
363	// Check if file exists in history
364	file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
365	if err != nil {
366		_, err = e.files.Create(ctx, sessionID, filePath, oldContent)
367		if err != nil {
368			// Log error but don't fail the operation
369			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
370		}
371	}
372	if file.Content != oldContent {
373		// User Manually changed the content store an intermediate version
374		_, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
375		if err != nil {
376			slog.Debug("Error creating file history version", "error", err)
377		}
378	}
379	// Store the new version
380	_, err = e.files.CreateVersion(ctx, sessionID, filePath, "")
381	if err != nil {
382		slog.Debug("Error creating file history version", "error", err)
383	}
384
385	recordFileWrite(filePath)
386	recordFileRead(filePath)
387
388	return WithResponseMetadata(
389		NewTextResponse("Content deleted from file: "+filePath),
390		EditResponseMetadata{
391			OldContent: oldContent,
392			NewContent: newContent,
393			Additions:  additions,
394			Removals:   removals,
395		},
396	), nil
397}
398
399func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string, replaceAll bool, call ToolCall) (ToolResponse, error) {
400	fileInfo, err := os.Stat(filePath)
401	if err != nil {
402		if os.IsNotExist(err) {
403			return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
404		}
405		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
406	}
407
408	if fileInfo.IsDir() {
409		return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
410	}
411
412	if getLastReadTime(filePath).IsZero() {
413		return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
414	}
415
416	modTime := fileInfo.ModTime()
417	lastRead := getLastReadTime(filePath)
418	if modTime.After(lastRead) {
419		return NewTextErrorResponse(
420			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
421				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
422			)), nil
423	}
424
425	content, err := os.ReadFile(filePath)
426	if err != nil {
427		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
428	}
429
430	oldContent := string(content)
431
432	var newContent string
433	var replacementCount int
434
435	if replaceAll {
436		newContent = strings.ReplaceAll(oldContent, oldString, newString)
437		replacementCount = strings.Count(oldContent, oldString)
438		if replacementCount == 0 {
439			return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
440		}
441	} else {
442		index := strings.Index(oldContent, oldString)
443		if index == -1 {
444			return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
445		}
446
447		lastIndex := strings.LastIndex(oldContent, oldString)
448		if index != lastIndex {
449			return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
450		}
451
452		newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
453		replacementCount = 1
454	}
455
456	if oldContent == newContent {
457		return NewTextErrorResponse("new content is the same as old content. No changes made."), nil
458	}
459	sessionID, messageID := GetContextValues(ctx)
460
461	if sessionID == "" || messageID == "" {
462		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
463	}
464	_, additions, removals := diff.GenerateDiff(
465		oldContent,
466		newContent,
467		strings.TrimPrefix(filePath, e.workingDir),
468	)
469	p := e.permissions.Request(
470		permission.CreatePermissionRequest{
471			SessionID:   sessionID,
472			Path:        filePath,
473			ToolCallID:  call.ID,
474			ToolName:    EditToolName,
475			Action:      "write",
476			Description: fmt.Sprintf("Replace content in file %s", filePath),
477			Params: EditPermissionsParams{
478				FilePath:   filePath,
479				OldContent: oldContent,
480				NewContent: newContent,
481			},
482		},
483	)
484	if !p {
485		return ToolResponse{}, permission.ErrorPermissionDenied
486	}
487
488	err = os.WriteFile(filePath, []byte(newContent), 0o644)
489	if err != nil {
490		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
491	}
492
493	// Check if file exists in history
494	file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
495	if err != nil {
496		_, err = e.files.Create(ctx, sessionID, filePath, oldContent)
497		if err != nil {
498			// Log error but don't fail the operation
499			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
500		}
501	}
502	if file.Content != oldContent {
503		// User Manually changed the content store an intermediate version
504		_, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
505		if err != nil {
506			slog.Debug("Error creating file history version", "error", err)
507		}
508	}
509	// Store the new version
510	_, err = e.files.CreateVersion(ctx, sessionID, filePath, newContent)
511	if err != nil {
512		slog.Debug("Error creating file history version", "error", err)
513	}
514
515	recordFileWrite(filePath)
516	recordFileRead(filePath)
517
518	return WithResponseMetadata(
519		NewTextResponse("Content replaced in file: "+filePath),
520		EditResponseMetadata{
521			OldContent: oldContent,
522			NewContent: newContent,
523			Additions:  additions,
524			Removals:   removals,
525		}), nil
526}