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	rootDir := e.workingDir
219	permissionPath := filepath.Dir(filePath)
220	if strings.HasPrefix(filePath, rootDir) {
221		permissionPath = rootDir
222	}
223	p := e.permissions.Request(
224		permission.CreatePermissionRequest{
225			SessionID:   sessionID,
226			Path:        permissionPath,
227			ToolCallID:  call.ID,
228			ToolName:    EditToolName,
229			Action:      "write",
230			Description: fmt.Sprintf("Create file %s", filePath),
231			Params: EditPermissionsParams{
232				FilePath:   filePath,
233				OldContent: "",
234				NewContent: content,
235			},
236		},
237	)
238	if !p {
239		return ToolResponse{}, permission.ErrorPermissionDenied
240	}
241
242	err = os.WriteFile(filePath, []byte(content), 0o644)
243	if err != nil {
244		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
245	}
246
247	// File can't be in the history so we create a new file history
248	_, err = e.files.Create(ctx, sessionID, filePath, "")
249	if err != nil {
250		// Log error but don't fail the operation
251		return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
252	}
253
254	// Add the new content to the file history
255	_, err = e.files.CreateVersion(ctx, sessionID, filePath, content)
256	if err != nil {
257		// Log error but don't fail the operation
258		slog.Debug("Error creating file history version", "error", err)
259	}
260
261	recordFileWrite(filePath)
262	recordFileRead(filePath)
263
264	return WithResponseMetadata(
265		NewTextResponse("File created: "+filePath),
266		EditResponseMetadata{
267			OldContent: "",
268			NewContent: content,
269			Additions:  additions,
270			Removals:   removals,
271		},
272	), nil
273}
274
275func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string, replaceAll bool, call ToolCall) (ToolResponse, error) {
276	fileInfo, err := os.Stat(filePath)
277	if err != nil {
278		if os.IsNotExist(err) {
279			return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
280		}
281		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
282	}
283
284	if fileInfo.IsDir() {
285		return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
286	}
287
288	if getLastReadTime(filePath).IsZero() {
289		return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
290	}
291
292	modTime := fileInfo.ModTime()
293	lastRead := getLastReadTime(filePath)
294	if modTime.After(lastRead) {
295		return NewTextErrorResponse(
296			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
297				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
298			)), nil
299	}
300
301	content, err := os.ReadFile(filePath)
302	if err != nil {
303		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
304	}
305
306	oldContent := string(content)
307
308	var newContent string
309	var deletionCount int
310
311	if replaceAll {
312		newContent = strings.ReplaceAll(oldContent, oldString, "")
313		deletionCount = strings.Count(oldContent, oldString)
314		if deletionCount == 0 {
315			return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
316		}
317	} else {
318		index := strings.Index(oldContent, oldString)
319		if index == -1 {
320			return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
321		}
322
323		lastIndex := strings.LastIndex(oldContent, oldString)
324		if index != lastIndex {
325			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
326		}
327
328		newContent = oldContent[:index] + oldContent[index+len(oldString):]
329		deletionCount = 1
330	}
331
332	sessionID, messageID := GetContextValues(ctx)
333
334	if sessionID == "" || messageID == "" {
335		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
336	}
337
338	_, additions, removals := diff.GenerateDiff(
339		oldContent,
340		newContent,
341		strings.TrimPrefix(filePath, e.workingDir),
342	)
343
344	rootDir := e.workingDir
345	permissionPath := filepath.Dir(filePath)
346	if strings.HasPrefix(filePath, rootDir) {
347		permissionPath = rootDir
348	}
349	p := e.permissions.Request(
350		permission.CreatePermissionRequest{
351			SessionID:   sessionID,
352			Path:        permissionPath,
353			ToolCallID:  call.ID,
354			ToolName:    EditToolName,
355			Action:      "write",
356			Description: fmt.Sprintf("Delete content from file %s", filePath),
357			Params: EditPermissionsParams{
358				FilePath:   filePath,
359				OldContent: oldContent,
360				NewContent: newContent,
361			},
362		},
363	)
364	if !p {
365		return ToolResponse{}, permission.ErrorPermissionDenied
366	}
367
368	err = os.WriteFile(filePath, []byte(newContent), 0o644)
369	if err != nil {
370		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
371	}
372
373	// Check if file exists in history
374	file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
375	if err != nil {
376		_, err = e.files.Create(ctx, sessionID, filePath, oldContent)
377		if err != nil {
378			// Log error but don't fail the operation
379			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
380		}
381	}
382	if file.Content != oldContent {
383		// User Manually changed the content store an intermediate version
384		_, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
385		if err != nil {
386			slog.Debug("Error creating file history version", "error", err)
387		}
388	}
389	// Store the new version
390	_, err = e.files.CreateVersion(ctx, sessionID, filePath, "")
391	if err != nil {
392		slog.Debug("Error creating file history version", "error", err)
393	}
394
395	recordFileWrite(filePath)
396	recordFileRead(filePath)
397
398	return WithResponseMetadata(
399		NewTextResponse("Content deleted from file: "+filePath),
400		EditResponseMetadata{
401			OldContent: oldContent,
402			NewContent: newContent,
403			Additions:  additions,
404			Removals:   removals,
405		},
406	), nil
407}
408
409func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string, replaceAll bool, call ToolCall) (ToolResponse, error) {
410	fileInfo, err := os.Stat(filePath)
411	if err != nil {
412		if os.IsNotExist(err) {
413			return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
414		}
415		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
416	}
417
418	if fileInfo.IsDir() {
419		return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
420	}
421
422	if getLastReadTime(filePath).IsZero() {
423		return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
424	}
425
426	modTime := fileInfo.ModTime()
427	lastRead := getLastReadTime(filePath)
428	if modTime.After(lastRead) {
429		return NewTextErrorResponse(
430			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
431				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
432			)), nil
433	}
434
435	content, err := os.ReadFile(filePath)
436	if err != nil {
437		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
438	}
439
440	oldContent := string(content)
441
442	var newContent string
443	var replacementCount int
444
445	if replaceAll {
446		newContent = strings.ReplaceAll(oldContent, oldString, newString)
447		replacementCount = strings.Count(oldContent, oldString)
448		if replacementCount == 0 {
449			return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
450		}
451	} else {
452		index := strings.Index(oldContent, oldString)
453		if index == -1 {
454			return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
455		}
456
457		lastIndex := strings.LastIndex(oldContent, oldString)
458		if index != lastIndex {
459			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
460		}
461
462		newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
463		replacementCount = 1
464	}
465
466	if oldContent == newContent {
467		return NewTextErrorResponse("new content is the same as old content. No changes made."), nil
468	}
469	sessionID, messageID := GetContextValues(ctx)
470
471	if sessionID == "" || messageID == "" {
472		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
473	}
474	_, additions, removals := diff.GenerateDiff(
475		oldContent,
476		newContent,
477		strings.TrimPrefix(filePath, e.workingDir),
478	)
479	rootDir := e.workingDir
480	permissionPath := filepath.Dir(filePath)
481	if strings.HasPrefix(filePath, rootDir) {
482		permissionPath = rootDir
483	}
484	p := e.permissions.Request(
485		permission.CreatePermissionRequest{
486			SessionID:   sessionID,
487			Path:        permissionPath,
488			ToolCallID:  call.ID,
489			ToolName:    EditToolName,
490			Action:      "write",
491			Description: fmt.Sprintf("Replace content in file %s", filePath),
492			Params: EditPermissionsParams{
493				FilePath:   filePath,
494				OldContent: oldContent,
495				NewContent: newContent,
496			},
497		},
498	)
499	if !p {
500		return ToolResponse{}, permission.ErrorPermissionDenied
501	}
502
503	err = os.WriteFile(filePath, []byte(newContent), 0o644)
504	if err != nil {
505		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
506	}
507
508	// Check if file exists in history
509	file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
510	if err != nil {
511		_, err = e.files.Create(ctx, sessionID, filePath, oldContent)
512		if err != nil {
513			// Log error but don't fail the operation
514			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
515		}
516	}
517	if file.Content != oldContent {
518		// User Manually changed the content store an intermediate version
519		_, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
520		if err != nil {
521			slog.Debug("Error creating file history version", "error", err)
522		}
523	}
524	// Store the new version
525	_, err = e.files.CreateVersion(ctx, sessionID, filePath, newContent)
526	if err != nil {
527		slog.Debug("Error creating file history version", "error", err)
528	}
529
530	recordFileWrite(filePath)
531	recordFileRead(filePath)
532
533	return WithResponseMetadata(
534		NewTextResponse("Content replaced in file: "+filePath),
535		EditResponseMetadata{
536			OldContent: oldContent,
537			NewContent: newContent,
538			Additions:  additions,
539			Removals:   removals,
540		}), nil
541}