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 []byte
 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		string(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
110func createNewFile(edit editContext, filePath, content string, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
111	fileInfo, err := os.Stat(filePath)
112	if err == nil {
113		if fileInfo.IsDir() {
114			return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
115		}
116		return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
117	} else if !os.IsNotExist(err) {
118		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
119	}
120
121	dir := filepath.Dir(filePath)
122	if err = os.MkdirAll(dir, 0o755); err != nil {
123		return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
124	}
125
126	sessionID := GetSessionFromContext(edit.ctx)
127	if sessionID == "" {
128		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
129	}
130
131	_, additions, removals := diff.GenerateDiff(
132		"",
133		content,
134		strings.TrimPrefix(filePath, edit.workingDir),
135	)
136	p, err := edit.permissions.Request(edit.ctx,
137		permission.CreatePermissionRequest{
138			SessionID:   sessionID,
139			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
140			ToolCallID:  call.ID,
141			ToolName:    EditToolName,
142			Action:      "write",
143			Description: fmt.Sprintf("Create file %s", filePath),
144			Params: EditPermissionsParams{
145				FilePath:   filePath,
146				OldContent: "",
147				NewContent: content,
148			},
149		},
150	)
151	if err != nil {
152		return fantasy.ToolResponse{}, err
153	}
154	if !p {
155		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
156	}
157
158	err = os.WriteFile(filePath, []byte(content), 0o644)
159	if err != nil {
160		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
161	}
162
163	// File can't be in the history so we create a new file history
164	_, err = edit.files.Create(edit.ctx, sessionID, filePath, "")
165	if err != nil {
166		// Log error but don't fail the operation
167		return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
168	}
169
170	// Add the new content to the file history
171	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, content)
172	if err != nil {
173		// Log error but don't fail the operation
174		slog.Error("Error creating file history version", "error", err)
175	}
176
177	edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
178
179	return fantasy.WithResponseMetadata(
180		fantasy.NewTextResponse("File created: "+filePath),
181		EditResponseMetadata{
182			OldContent: "",
183			NewContent: content,
184			Additions:  additions,
185			Removals:   removals,
186		},
187	), nil
188}
189
190func deleteContent(edit editContext, filePath, oldString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
191	fileInfo, err := os.Stat(filePath)
192	if err != nil {
193		if os.IsNotExist(err) {
194			return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
195		}
196		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
197	}
198
199	if fileInfo.IsDir() {
200		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
201	}
202
203	sessionID := GetSessionFromContext(edit.ctx)
204	if sessionID == "" {
205		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
206	}
207
208	lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
209	if lastRead.IsZero() {
210		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
211	}
212
213	modTime := fileInfo.ModTime().Truncate(time.Second)
214	if modTime.After(lastRead) {
215		return fantasy.NewTextErrorResponse(
216			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
217				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
218			)), nil
219	}
220
221	content, err := os.ReadFile(filePath)
222	if err != nil {
223		return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
224	}
225
226	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
227
228	var newContent string
229
230	if replaceAll {
231		newContent = strings.ReplaceAll(oldContent, oldString, "")
232		if newContent == oldContent {
233			return oldStringNotFoundErr, nil
234		}
235	} else {
236		index := strings.Index(oldContent, oldString)
237		if index == -1 {
238			return oldStringNotFoundErr, nil
239		}
240
241		lastIndex := strings.LastIndex(oldContent, oldString)
242		if index != lastIndex {
243			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
244		}
245
246		newContent = oldContent[:index] + oldContent[index+len(oldString):]
247	}
248
249	_, additions, removals := diff.GenerateDiff(
250		oldContent,
251		newContent,
252		strings.TrimPrefix(filePath, edit.workingDir),
253	)
254
255	p, err := edit.permissions.Request(edit.ctx,
256		permission.CreatePermissionRequest{
257			SessionID:   sessionID,
258			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
259			ToolCallID:  call.ID,
260			ToolName:    EditToolName,
261			Action:      "write",
262			Description: fmt.Sprintf("Delete content from file %s", filePath),
263			Params: EditPermissionsParams{
264				FilePath:   filePath,
265				OldContent: oldContent,
266				NewContent: newContent,
267			},
268		},
269	)
270	if err != nil {
271		return fantasy.ToolResponse{}, err
272	}
273	if !p {
274		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
275	}
276
277	normalizedNewContent := newContent
278	if isCrlf {
279		newContent, _ = fsext.ToWindowsLineEndings(newContent)
280	}
281
282	err = os.WriteFile(filePath, []byte(newContent), 0o644)
283	if err != nil {
284		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
285	}
286
287	// Check if file exists in history
288	file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
289	if err != nil {
290		_, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
291		if err != nil {
292			// Log error but don't fail the operation
293			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
294		}
295	}
296	if file.Content != oldContent {
297		// User manually changed the content; store an intermediate version
298		_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
299		if err != nil {
300			slog.Error("Error creating file history version", "error", err)
301		}
302	}
303	// Store the new version
304	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, normalizedNewContent)
305	if err != nil {
306		slog.Error("Error creating file history version", "error", err)
307	}
308
309	edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
310
311	return fantasy.WithResponseMetadata(
312		fantasy.NewTextResponse("Content deleted from file: "+filePath),
313		EditResponseMetadata{
314			OldContent: oldContent,
315			NewContent: normalizedNewContent,
316			Additions:  additions,
317			Removals:   removals,
318		},
319	), nil
320}
321
322func replaceContent(edit editContext, filePath, oldString, newString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
323	fileInfo, err := os.Stat(filePath)
324	if err != nil {
325		if os.IsNotExist(err) {
326			return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
327		}
328		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
329	}
330
331	if fileInfo.IsDir() {
332		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
333	}
334
335	sessionID := GetSessionFromContext(edit.ctx)
336	if sessionID == "" {
337		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for edit a file")
338	}
339
340	lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
341	if lastRead.IsZero() {
342		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
343	}
344
345	modTime := fileInfo.ModTime().Truncate(time.Second)
346	if modTime.After(lastRead) {
347		return fantasy.NewTextErrorResponse(
348			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
349				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
350			)), nil
351	}
352
353	content, err := os.ReadFile(filePath)
354	if err != nil {
355		return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
356	}
357
358	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
359
360	var newContent string
361
362	if replaceAll {
363		newContent = strings.ReplaceAll(oldContent, oldString, newString)
364	} else {
365		index := strings.Index(oldContent, oldString)
366		if index == -1 {
367			return oldStringNotFoundErr, nil
368		}
369
370		lastIndex := strings.LastIndex(oldContent, oldString)
371		if index != lastIndex {
372			return oldStringMultipleMatchesErr, nil
373		}
374
375		newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
376	}
377
378	if oldContent == newContent {
379		return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
380	}
381	_, additions, removals := diff.GenerateDiff(
382		oldContent,
383		newContent,
384		strings.TrimPrefix(filePath, edit.workingDir),
385	)
386
387	p, err := edit.permissions.Request(edit.ctx,
388		permission.CreatePermissionRequest{
389			SessionID:   sessionID,
390			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
391			ToolCallID:  call.ID,
392			ToolName:    EditToolName,
393			Action:      "write",
394			Description: fmt.Sprintf("Replace content in file %s", filePath),
395			Params: EditPermissionsParams{
396				FilePath:   filePath,
397				OldContent: oldContent,
398				NewContent: newContent,
399			},
400		},
401	)
402	if err != nil {
403		return fantasy.ToolResponse{}, err
404	}
405	if !p {
406		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
407	}
408
409	normalizedNewContent := newContent
410	if isCrlf {
411		newContent, _ = fsext.ToWindowsLineEndings(newContent)
412	}
413
414	err = os.WriteFile(filePath, []byte(newContent), 0o644)
415	if err != nil {
416		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
417	}
418
419	// Check if file exists in history
420	file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
421	if err != nil {
422		_, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
423		if err != nil {
424			// Log error but don't fail the operation
425			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
426		}
427	}
428	if file.Content != oldContent {
429		// User manually changed the content; store an intermediate version
430		_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
431		if err != nil {
432			slog.Debug("Error creating file history version", "error", err)
433		}
434	}
435	// Store the new version
436	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, normalizedNewContent)
437	if err != nil {
438		slog.Error("Error creating file history version", "error", err)
439	}
440
441	edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
442
443	return fantasy.WithResponseMetadata(
444		fantasy.NewTextResponse("Content replaced in file: "+filePath),
445		EditResponseMetadata{
446			OldContent: oldContent,
447			NewContent: normalizedNewContent,
448			Additions:  additions,
449			Removals:   removals,
450		}), nil
451}