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