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