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