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