edit.go

  1package tools
  2
  3import (
  4	"context"
  5	_ "embed"
  6	"fmt"
  7	"log/slog"
  8	"os"
  9	"path/filepath"
 10	"strings"
 11	"time"
 12
 13	"charm.land/fantasy"
 14	"github.com/charmbracelet/crush/internal/diff"
 15	"github.com/charmbracelet/crush/internal/filepathext"
 16	"github.com/charmbracelet/crush/internal/filetracker"
 17	"github.com/charmbracelet/crush/internal/fsext"
 18	"github.com/charmbracelet/crush/internal/history"
 19
 20	"github.com/charmbracelet/crush/internal/lsp"
 21	"github.com/charmbracelet/crush/internal/permission"
 22)
 23
 24type EditParams struct {
 25	FilePath   string `json:"file_path" description:"The absolute path to the file to modify"`
 26	OldString  string `json:"old_string" description:"The text to replace"`
 27	NewString  string `json:"new_string" description:"The text to replace it with"`
 28	ReplaceAll bool   `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)"`
 29}
 30
 31type EditPermissionsParams struct {
 32	FilePath   string `json:"file_path"`
 33	OldContent string `json:"old_content,omitempty"`
 34	NewContent string `json:"new_content,omitempty"`
 35}
 36
 37type EditResponseMetadata struct {
 38	Additions  int    `json:"additions"`
 39	Removals   int    `json:"removals"`
 40	OldContent string `json:"old_content,omitempty"`
 41	NewContent string `json:"new_content,omitempty"`
 42}
 43
 44const EditToolName = "edit"
 45
 46var (
 47	oldStringNotFoundErr        = fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks.")
 48	oldStringMultipleMatchesErr = fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true")
 49)
 50
 51//go:embed edit.md
 52var editDescription string
 53
 54type editContext struct {
 55	ctx         context.Context
 56	permissions permission.Service
 57	files       history.Service
 58	filetracker filetracker.Service
 59	workingDir  string
 60}
 61
 62func NewEditTool(
 63	lspManager *lsp.Manager,
 64	permissions permission.Service,
 65	files history.Service,
 66	filetracker filetracker.Service,
 67	workingDir string,
 68) fantasy.AgentTool {
 69	return fantasy.NewAgentTool(
 70		EditToolName,
 71		editDescription,
 72		func(ctx context.Context, params EditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 73			if params.FilePath == "" {
 74				return fantasy.NewTextErrorResponse("file_path is required"), nil
 75			}
 76
 77			params.FilePath = filepathext.SmartJoin(workingDir, params.FilePath)
 78
 79			var response fantasy.ToolResponse
 80			var err error
 81
 82			editCtx := editContext{ctx, permissions, files, filetracker, workingDir}
 83
 84			if params.OldString == "" {
 85				response, err = createNewFile(editCtx, params.FilePath, params.NewString, call)
 86			} else if params.NewString == "" {
 87				response, err = deleteContent(editCtx, params.FilePath, params.OldString, params.ReplaceAll, call)
 88			} else {
 89				response, err = replaceContent(editCtx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
 90			}
 91
 92			if err != nil {
 93				return response, err
 94			}
 95			if response.IsError {
 96				// Return early if there was an error during content replacement
 97				// This prevents unnecessary LSP diagnostics processing
 98				return response, nil
 99			}
100
101			notifyLSPs(ctx, lspManager, params.FilePath)
102
103			text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
104			text += getDiagnostics(params.FilePath, lspManager)
105			response.Content = text
106			return response, nil
107		},
108	)
109}
110
111func createNewFile(edit editContext, filePath, content string, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
112	fileInfo, err := os.Stat(filePath)
113	if err == nil {
114		if fileInfo.IsDir() {
115			return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
116		}
117		return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
118	} else if !os.IsNotExist(err) {
119		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
120	}
121
122	dir := filepath.Dir(filePath)
123	if err = os.MkdirAll(dir, 0o755); err != nil {
124		return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
125	}
126
127	sessionID := GetSessionFromContext(edit.ctx)
128	if sessionID == "" {
129		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
130	}
131
132	_, additions, removals := diff.GenerateDiff(
133		"",
134		content,
135		strings.TrimPrefix(filePath, edit.workingDir),
136	)
137	p, err := edit.permissions.Request(
138		edit.ctx,
139		permission.CreatePermissionRequest{
140			SessionID:   sessionID,
141			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
142			ToolCallID:  call.ID,
143			ToolName:    EditToolName,
144			Action:      "write",
145			Description: fmt.Sprintf("Create file %s", filePath),
146			Params: EditPermissionsParams{
147				FilePath:   filePath,
148				OldContent: "",
149				NewContent: content,
150			},
151		},
152	)
153	if err != nil {
154		return fantasy.ToolResponse{}, err
155	}
156	if !p {
157		resp := NewPermissionDeniedResponse()
158		resp = fantasy.WithResponseMetadata(resp, EditResponseMetadata{
159			OldContent: "",
160			NewContent: content,
161			Additions:  additions,
162			Removals:   removals,
163		})
164		return resp, nil
165	}
166
167	err = os.WriteFile(filePath, []byte(content), 0o644)
168	if err != nil {
169		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
170	}
171
172	// File can't be in the history so we create a new file history
173	_, err = edit.files.Create(edit.ctx, sessionID, filePath, "")
174	if err != nil {
175		// Log error but don't fail the operation
176		return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
177	}
178
179	// Add the new content to the file history
180	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, content)
181	if err != nil {
182		// Log error but don't fail the operation
183		slog.Error("Error creating file history version", "error", err)
184	}
185
186	edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
187
188	return fantasy.WithResponseMetadata(
189		fantasy.NewTextResponse("File created: "+filePath),
190		EditResponseMetadata{
191			OldContent: "",
192			NewContent: content,
193			Additions:  additions,
194			Removals:   removals,
195		},
196	), nil
197}
198
199func deleteContent(edit editContext, filePath, oldString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
200	fileInfo, err := os.Stat(filePath)
201	if err != nil {
202		if os.IsNotExist(err) {
203			return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
204		}
205		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
206	}
207
208	if fileInfo.IsDir() {
209		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
210	}
211
212	sessionID := GetSessionFromContext(edit.ctx)
213	if sessionID == "" {
214		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
215	}
216
217	lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
218	if lastRead.IsZero() {
219		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
220	}
221
222	modTime := fileInfo.ModTime().Truncate(time.Second)
223	if modTime.After(lastRead) {
224		return fantasy.NewTextErrorResponse(
225			fmt.Sprintf(
226				"file %s has been modified since it was last read (mod time: %s, last read: %s)",
227				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
228			),
229		), nil
230	}
231
232	content, err := os.ReadFile(filePath)
233	if err != nil {
234		return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
235	}
236
237	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
238
239	var newContent string
240
241	if replaceAll {
242		newContent = strings.ReplaceAll(oldContent, oldString, "")
243		if newContent == oldContent {
244			return oldStringNotFoundErr, nil
245		}
246	} else {
247		index := strings.Index(oldContent, oldString)
248		if index == -1 {
249			return oldStringNotFoundErr, nil
250		}
251
252		lastIndex := strings.LastIndex(oldContent, oldString)
253		if index != lastIndex {
254			return fantasy.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
255		}
256
257		newContent = oldContent[:index] + oldContent[index+len(oldString):]
258	}
259
260	_, additions, removals := diff.GenerateDiff(
261		oldContent,
262		newContent,
263		strings.TrimPrefix(filePath, edit.workingDir),
264	)
265
266	p, err := edit.permissions.Request(
267		edit.ctx,
268		permission.CreatePermissionRequest{
269			SessionID:   sessionID,
270			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
271			ToolCallID:  call.ID,
272			ToolName:    EditToolName,
273			Action:      "write",
274			Description: fmt.Sprintf("Delete content from file %s", filePath),
275			Params: EditPermissionsParams{
276				FilePath:   filePath,
277				OldContent: oldContent,
278				NewContent: newContent,
279			},
280		},
281	)
282	if err != nil {
283		return fantasy.ToolResponse{}, err
284	}
285	if !p {
286		resp := NewPermissionDeniedResponse()
287		resp = fantasy.WithResponseMetadata(resp, EditResponseMetadata{
288			OldContent: oldContent,
289			NewContent: newContent,
290			Additions:  additions,
291			Removals:   removals,
292		})
293		return resp, nil
294	}
295
296	if isCrlf {
297		newContent, _ = fsext.ToWindowsLineEndings(newContent)
298	}
299
300	err = os.WriteFile(filePath, []byte(newContent), 0o644)
301	if err != nil {
302		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
303	}
304
305	// Check if file exists in history
306	file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
307	if err != nil {
308		_, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
309		if err != nil {
310			// Log error but don't fail the operation
311			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
312		}
313	}
314	if file.Content != oldContent {
315		// User manually changed the content; store an intermediate version
316		_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
317		if err != nil {
318			slog.Error("Error creating file history version", "error", err)
319		}
320	}
321	// Store the new version
322	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
323	if err != nil {
324		slog.Error("Error creating file history version", "error", err)
325	}
326
327	edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
328
329	return fantasy.WithResponseMetadata(
330		fantasy.NewTextResponse("Content deleted from file: "+filePath),
331		EditResponseMetadata{
332			OldContent: oldContent,
333			NewContent: newContent,
334			Additions:  additions,
335			Removals:   removals,
336		},
337	), nil
338}
339
340func replaceContent(edit editContext, filePath, oldString, newString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
341	fileInfo, err := os.Stat(filePath)
342	if err != nil {
343		if os.IsNotExist(err) {
344			return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
345		}
346		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
347	}
348
349	if fileInfo.IsDir() {
350		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
351	}
352
353	sessionID := GetSessionFromContext(edit.ctx)
354	if sessionID == "" {
355		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for edit a file")
356	}
357
358	lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
359	if lastRead.IsZero() {
360		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
361	}
362
363	modTime := fileInfo.ModTime().Truncate(time.Second)
364	if modTime.After(lastRead) {
365		return fantasy.NewTextErrorResponse(
366			fmt.Sprintf(
367				"file %s has been modified since it was last read (mod time: %s, last read: %s)",
368				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
369			),
370		), nil
371	}
372
373	content, err := os.ReadFile(filePath)
374	if err != nil {
375		return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
376	}
377
378	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
379
380	var newContent string
381
382	if replaceAll {
383		newContent = strings.ReplaceAll(oldContent, oldString, newString)
384	} else {
385		index := strings.Index(oldContent, oldString)
386		if index == -1 {
387			return oldStringNotFoundErr, nil
388		}
389
390		lastIndex := strings.LastIndex(oldContent, oldString)
391		if index != lastIndex {
392			return oldStringMultipleMatchesErr, nil
393		}
394
395		newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
396	}
397
398	if oldContent == newContent {
399		return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
400	}
401	_, additions, removals := diff.GenerateDiff(
402		oldContent,
403		newContent,
404		strings.TrimPrefix(filePath, edit.workingDir),
405	)
406
407	p, err := edit.permissions.Request(
408		edit.ctx,
409		permission.CreatePermissionRequest{
410			SessionID:   sessionID,
411			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
412			ToolCallID:  call.ID,
413			ToolName:    EditToolName,
414			Action:      "write",
415			Description: fmt.Sprintf("Replace content in file %s", filePath),
416			Params: EditPermissionsParams{
417				FilePath:   filePath,
418				OldContent: oldContent,
419				NewContent: newContent,
420			},
421		},
422	)
423	if err != nil {
424		return fantasy.ToolResponse{}, err
425	}
426	if !p {
427		resp := NewPermissionDeniedResponse()
428		resp = fantasy.WithResponseMetadata(resp, EditResponseMetadata{
429			OldContent: oldContent,
430			NewContent: newContent,
431			Additions:  additions,
432			Removals:   removals,
433		})
434		return resp, nil
435	}
436
437	if isCrlf {
438		newContent, _ = fsext.ToWindowsLineEndings(newContent)
439	}
440
441	err = os.WriteFile(filePath, []byte(newContent), 0o644)
442	if err != nil {
443		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
444	}
445
446	// Check if file exists in history
447	file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
448	if err != nil {
449		_, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
450		if err != nil {
451			// Log error but don't fail the operation
452			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
453		}
454	}
455	if file.Content != oldContent {
456		// User manually changed the content; store an intermediate version
457		_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
458		if err != nil {
459			slog.Debug("Error creating file history version", "error", err)
460		}
461	}
462	// Store the new version
463	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
464	if err != nil {
465		slog.Error("Error creating file history version", "error", err)
466	}
467
468	edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
469
470	return fantasy.WithResponseMetadata(
471		fantasy.NewTextResponse("Content replaced in file: "+filePath),
472		EditResponseMetadata{
473			OldContent: oldContent,
474			NewContent: newContent,
475			Additions:  additions,
476			Removals:   removals,
477		},
478	), nil
479}