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