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