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
220	if replaceAll {
221		newContent = strings.ReplaceAll(oldContent, oldString, "")
222		if newContent == oldContent {
223			return oldStringNotFoundErr, nil
224		}
225	} else {
226		index := strings.Index(oldContent, oldString)
227		if index == -1 {
228			return oldStringNotFoundErr, nil
229		}
230
231		lastIndex := strings.LastIndex(oldContent, oldString)
232		if index != lastIndex {
233			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
234		}
235
236		newContent = oldContent[:index] + oldContent[index+len(oldString):]
237	}
238
239	sessionID := GetSessionFromContext(edit.ctx)
240
241	if sessionID == "" {
242		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
243	}
244
245	_, additions, removals := diff.GenerateDiff(
246		oldContent,
247		newContent,
248		strings.TrimPrefix(filePath, edit.workingDir),
249	)
250
251	p, err := edit.permissions.Request(edit.ctx,
252		permission.CreatePermissionRequest{
253			SessionID:   sessionID,
254			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
255			ToolCallID:  call.ID,
256			ToolName:    EditToolName,
257			Action:      "write",
258			Description: fmt.Sprintf("Delete content from file %s", filePath),
259			Params: EditPermissionsParams{
260				FilePath:   filePath,
261				OldContent: oldContent,
262				NewContent: newContent,
263			},
264		},
265	)
266	if err != nil {
267		return fantasy.ToolResponse{}, err
268	}
269	if !p {
270		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
271	}
272
273	if isCrlf {
274		newContent, _ = fsext.ToWindowsLineEndings(newContent)
275	}
276
277	err = os.WriteFile(filePath, []byte(newContent), 0o644)
278	if err != nil {
279		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
280	}
281
282	// Check if file exists in history
283	file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
284	if err != nil {
285		_, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
286		if err != nil {
287			// Log error but don't fail the operation
288			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
289		}
290	}
291	if file.Content != oldContent {
292		// User manually changed the content; store an intermediate version
293		_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
294		if err != nil {
295			slog.Error("Error creating file history version", "error", err)
296		}
297	}
298	// Store the new version
299	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
300	if err != nil {
301		slog.Error("Error creating file history version", "error", err)
302	}
303
304	filetracker.RecordWrite(filePath)
305	filetracker.RecordRead(filePath)
306
307	return fantasy.WithResponseMetadata(
308		fantasy.NewTextResponse("Content deleted from file: "+filePath),
309		EditResponseMetadata{
310			OldContent: oldContent,
311			NewContent: newContent,
312			Additions:  additions,
313			Removals:   removals,
314		},
315	), nil
316}
317
318func replaceContent(edit editContext, filePath, oldString, newString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
319	fileInfo, err := os.Stat(filePath)
320	if err != nil {
321		if os.IsNotExist(err) {
322			return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
323		}
324		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
325	}
326
327	if fileInfo.IsDir() {
328		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
329	}
330
331	if filetracker.LastReadTime(filePath).IsZero() {
332		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
333	}
334
335	modTime := fileInfo.ModTime()
336	lastRead := filetracker.LastReadTime(filePath)
337	if modTime.After(lastRead) {
338		return fantasy.NewTextErrorResponse(
339			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
340				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
341			)), nil
342	}
343
344	content, err := os.ReadFile(filePath)
345	if err != nil {
346		return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
347	}
348
349	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
350
351	var newContent string
352
353	if replaceAll {
354		newContent = strings.ReplaceAll(oldContent, oldString, newString)
355	} else {
356		index := strings.Index(oldContent, oldString)
357		if index == -1 {
358			return oldStringNotFoundErr, nil
359		}
360
361		lastIndex := strings.LastIndex(oldContent, oldString)
362		if index != lastIndex {
363			return oldStringMultipleMatchesErr, nil
364		}
365
366		newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
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, err := edit.permissions.Request(edit.ctx,
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 err != nil {
399		return fantasy.ToolResponse{}, err
400	}
401	if !p {
402		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
403	}
404
405	if isCrlf {
406		newContent, _ = fsext.ToWindowsLineEndings(newContent)
407	}
408
409	err = os.WriteFile(filePath, []byte(newContent), 0o644)
410	if err != nil {
411		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
412	}
413
414	// Check if file exists in history
415	file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
416	if err != nil {
417		_, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
418		if err != nil {
419			// Log error but don't fail the operation
420			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
421		}
422	}
423	if file.Content != oldContent {
424		// User manually changed the content; store an intermediate version
425		_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
426		if err != nil {
427			slog.Debug("Error creating file history version", "error", err)
428		}
429	}
430	// Store the new version
431	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
432	if err != nil {
433		slog.Error("Error creating file history version", "error", err)
434	}
435
436	filetracker.RecordWrite(filePath)
437	filetracker.RecordRead(filePath)
438
439	return fantasy.WithResponseMetadata(
440		fantasy.NewTextResponse("Content replaced in file: "+filePath),
441		EditResponseMetadata{
442			OldContent: oldContent,
443			NewContent: newContent,
444			Additions:  additions,
445			Removals:   removals,
446		}), nil
447}