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/csync"
 15	"github.com/charmbracelet/crush/internal/diff"
 16	"github.com/charmbracelet/crush/internal/filepathext"
 17	"github.com/charmbracelet/crush/internal/filetracker"
 18	"github.com/charmbracelet/crush/internal/fsext"
 19	"github.com/charmbracelet/crush/internal/history"
 20
 21	"github.com/charmbracelet/crush/internal/lsp"
 22	"github.com/charmbracelet/crush/internal/permission"
 23)
 24
 25type EditParams struct {
 26	FilePath   string `json:"file_path" description:"The absolute path to the file to modify"`
 27	OldString  string `json:"old_string" description:"The text to replace"`
 28	NewString  string `json:"new_string" description:"The text to replace it with"`
 29	ReplaceAll bool   `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)"`
 30}
 31
 32type EditPermissionsParams struct {
 33	FilePath   string `json:"file_path"`
 34	OldContent string `json:"old_content,omitempty"`
 35	NewContent string `json:"new_content,omitempty"`
 36}
 37
 38type EditResponseMetadata struct {
 39	Additions  int    `json:"additions"`
 40	Removals   int    `json:"removals"`
 41	OldContent string `json:"old_content,omitempty"`
 42	NewContent string `json:"new_content,omitempty"`
 43}
 44
 45const EditToolName = "edit"
 46
 47var (
 48	oldStringNotFoundErr        = fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks.")
 49	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")
 50)
 51
 52//go:embed edit.md
 53var editDescription []byte
 54
 55type editContext struct {
 56	ctx         context.Context
 57	permissions permission.Service
 58	files       history.Service
 59	filetracker filetracker.Service
 60	workingDir  string
 61}
 62
 63func NewEditTool(
 64	lspClients *csync.Map[string, *lsp.Client],
 65	permissions permission.Service,
 66	files history.Service,
 67	filetracker filetracker.Service,
 68	workingDir string,
 69) fantasy.AgentTool {
 70	return fantasy.NewAgentTool(
 71		EditToolName,
 72		string(editDescription),
 73		func(ctx context.Context, params EditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 74			if params.FilePath == "" {
 75				return fantasy.NewTextErrorResponse("file_path is required"), nil
 76			}
 77
 78			params.FilePath = filepathext.SmartJoin(workingDir, params.FilePath)
 79
 80			var response fantasy.ToolResponse
 81			var err error
 82
 83			editCtx := editContext{ctx, permissions, files, filetracker, workingDir}
 84
 85			if params.OldString == "" {
 86				response, err = createNewFile(editCtx, params.FilePath, params.NewString, call)
 87			} else if params.NewString == "" {
 88				response, err = deleteContent(editCtx, params.FilePath, params.OldString, params.ReplaceAll, call)
 89			} else {
 90				response, err = replaceContent(editCtx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
 91			}
 92
 93			if err != nil {
 94				return response, err
 95			}
 96			if response.IsError {
 97				// Return early if there was an error during content replacement
 98				// This prevents unnecessary LSP diagnostics processing
 99				return response, nil
100			}
101
102			notifyLSPs(ctx, lspClients, params.FilePath)
103
104			text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
105			text += getDiagnostics(params.FilePath, lspClients)
106			response.Content = text
107			return response, nil
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(edit.ctx,
138		permission.CreatePermissionRequest{
139			SessionID:   sessionID,
140			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
141			ToolCallID:  call.ID,
142			ToolName:    EditToolName,
143			Action:      "write",
144			Description: fmt.Sprintf("Create file %s", filePath),
145			Params: EditPermissionsParams{
146				FilePath:   filePath,
147				OldContent: "",
148				NewContent: content,
149			},
150		},
151	)
152	if err != nil {
153		return fantasy.ToolResponse{}, err
154	}
155	if !p {
156		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
157	}
158
159	err = os.WriteFile(filePath, []byte(content), 0o644)
160	if err != nil {
161		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
162	}
163
164	// File can't be in the history so we create a new file history
165	_, err = edit.files.Create(edit.ctx, sessionID, filePath, "")
166	if err != nil {
167		// Log error but don't fail the operation
168		return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
169	}
170
171	// Add the new content to the file history
172	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, content)
173	if err != nil {
174		// Log error but don't fail the operation
175		slog.Error("Error creating file history version", "error", err)
176	}
177
178	edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
179
180	return fantasy.WithResponseMetadata(
181		fantasy.NewTextResponse("File created: "+filePath),
182		EditResponseMetadata{
183			OldContent: "",
184			NewContent: content,
185			Additions:  additions,
186			Removals:   removals,
187		},
188	), nil
189}
190
191func deleteContent(edit editContext, filePath, oldString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
192	fileInfo, err := os.Stat(filePath)
193	if err != nil {
194		if os.IsNotExist(err) {
195			return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
196		}
197		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
198	}
199
200	if fileInfo.IsDir() {
201		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
202	}
203
204	sessionID := GetSessionFromContext(edit.ctx)
205	if sessionID == "" {
206		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
207	}
208
209	lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
210	if lastRead.IsZero() {
211		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
212	}
213
214	modTime := fileInfo.ModTime().Truncate(time.Second)
215	if modTime.After(lastRead) {
216		return fantasy.NewTextErrorResponse(
217			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
218				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
219			)), nil
220	}
221
222	content, err := os.ReadFile(filePath)
223	if err != nil {
224		return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
225	}
226
227	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
228
229	var newContent string
230
231	if replaceAll {
232		newContent = strings.ReplaceAll(oldContent, oldString, "")
233		if newContent == oldContent {
234			return oldStringNotFoundErr, nil
235		}
236	} else {
237		index := strings.Index(oldContent, oldString)
238		if index == -1 {
239			return oldStringNotFoundErr, nil
240		}
241
242		lastIndex := strings.LastIndex(oldContent, oldString)
243		if index != lastIndex {
244			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
245		}
246
247		newContent = oldContent[:index] + oldContent[index+len(oldString):]
248	}
249
250	_, additions, removals := diff.GenerateDiff(
251		oldContent,
252		newContent,
253		strings.TrimPrefix(filePath, edit.workingDir),
254	)
255
256	p, err := edit.permissions.Request(edit.ctx,
257		permission.CreatePermissionRequest{
258			SessionID:   sessionID,
259			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
260			ToolCallID:  call.ID,
261			ToolName:    EditToolName,
262			Action:      "write",
263			Description: fmt.Sprintf("Delete content from file %s", filePath),
264			Params: EditPermissionsParams{
265				FilePath:   filePath,
266				OldContent: oldContent,
267				NewContent: newContent,
268			},
269		},
270	)
271	if err != nil {
272		return fantasy.ToolResponse{}, err
273	}
274	if !p {
275		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
276	}
277
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, newContent)
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: newContent,
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	if isCrlf {
410		newContent, _ = fsext.ToWindowsLineEndings(newContent)
411	}
412
413	err = os.WriteFile(filePath, []byte(newContent), 0o644)
414	if err != nil {
415		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
416	}
417
418	// Check if file exists in history
419	file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
420	if err != nil {
421		_, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
422		if err != nil {
423			// Log error but don't fail the operation
424			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
425		}
426	}
427	if file.Content != oldContent {
428		// User manually changed the content; store an intermediate version
429		_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
430		if err != nil {
431			slog.Debug("Error creating file history version", "error", err)
432		}
433	}
434	// Store the new version
435	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
436	if err != nil {
437		slog.Error("Error creating file history version", "error", err)
438	}
439
440	edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
441
442	return fantasy.WithResponseMetadata(
443		fantasy.NewTextResponse("Content replaced in file: "+filePath),
444		EditResponseMetadata{
445			OldContent: oldContent,
446			NewContent: newContent,
447			Additions:  additions,
448			Removals:   removals,
449		}), nil
450}