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