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				if err != nil {
 75					return response, err
 76				}
 77			}
 78
 79			if params.NewString == "" {
 80				response, err = deleteContent(editCtx, params.FilePath, params.OldString, params.ReplaceAll, call)
 81				if err != nil {
 82					return response, err
 83				}
 84			}
 85
 86			response, err = replaceContent(editCtx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
 87			if err != nil {
 88				return response, err
 89			}
 90			if response.IsError {
 91				// Return early if there was an error during content replacement
 92				// This prevents unnecessary LSP diagnostics processing
 93				return response, nil
 94			}
 95
 96			notifyLSPs(ctx, lspClients, params.FilePath)
 97
 98			text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
 99			text += getDiagnostics(params.FilePath, lspClients)
100			response.Content = text
101			return response, nil
102		})
103}
104
105func createNewFile(edit editContext, filePath, content string, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
106	fileInfo, err := os.Stat(filePath)
107	if err == nil {
108		if fileInfo.IsDir() {
109			return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
110		}
111		return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
112	} else if !os.IsNotExist(err) {
113		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
114	}
115
116	dir := filepath.Dir(filePath)
117	if err = os.MkdirAll(dir, 0o755); err != nil {
118		return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
119	}
120
121	sessionID := GetSessionFromContext(edit.ctx)
122	if sessionID == "" {
123		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
124	}
125
126	_, additions, removals := diff.GenerateDiff(
127		"",
128		content,
129		strings.TrimPrefix(filePath, edit.workingDir),
130	)
131	p := edit.permissions.Request(
132		permission.CreatePermissionRequest{
133			SessionID:   sessionID,
134			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
135			ToolCallID:  call.ID,
136			ToolName:    EditToolName,
137			Action:      "write",
138			Description: fmt.Sprintf("Create file %s", filePath),
139			Params: EditPermissionsParams{
140				FilePath:   filePath,
141				OldContent: "",
142				NewContent: content,
143			},
144		},
145	)
146	if !p {
147		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
148	}
149
150	err = os.WriteFile(filePath, []byte(content), 0o644)
151	if err != nil {
152		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
153	}
154
155	// File can't be in the history so we create a new file history
156	_, err = edit.files.Create(edit.ctx, sessionID, filePath, "")
157	if err != nil {
158		// Log error but don't fail the operation
159		return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
160	}
161
162	// Add the new content to the file history
163	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, content)
164	if err != nil {
165		// Log error but don't fail the operation
166		slog.Debug("Error creating file history version", "error", err)
167	}
168
169	recordFileWrite(filePath)
170	recordFileRead(filePath)
171
172	return fantasy.WithResponseMetadata(
173		fantasy.NewTextResponse("File created: "+filePath),
174		EditResponseMetadata{
175			OldContent: "",
176			NewContent: content,
177			Additions:  additions,
178			Removals:   removals,
179		},
180	), nil
181}
182
183func deleteContent(edit editContext, filePath, oldString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
184	fileInfo, err := os.Stat(filePath)
185	if err != nil {
186		if os.IsNotExist(err) {
187			return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
188		}
189		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
190	}
191
192	if fileInfo.IsDir() {
193		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
194	}
195
196	if getLastReadTime(filePath).IsZero() {
197		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
198	}
199
200	modTime := fileInfo.ModTime()
201	lastRead := getLastReadTime(filePath)
202	if modTime.After(lastRead) {
203		return fantasy.NewTextErrorResponse(
204			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
205				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
206			)), nil
207	}
208
209	content, err := os.ReadFile(filePath)
210	if err != nil {
211		return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
212	}
213
214	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
215
216	var newContent string
217	var deletionCount int
218
219	if replaceAll {
220		newContent = strings.ReplaceAll(oldContent, oldString, "")
221		deletionCount = strings.Count(oldContent, oldString)
222		if deletionCount == 0 {
223			return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
224		}
225	} else {
226		index := strings.Index(oldContent, oldString)
227		if index == -1 {
228			return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), 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		deletionCount = 1
238	}
239
240	sessionID := GetSessionFromContext(edit.ctx)
241
242	if sessionID == "" {
243		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
244	}
245
246	_, additions, removals := diff.GenerateDiff(
247		oldContent,
248		newContent,
249		strings.TrimPrefix(filePath, edit.workingDir),
250	)
251
252	p := edit.permissions.Request(
253		permission.CreatePermissionRequest{
254			SessionID:   sessionID,
255			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
256			ToolCallID:  call.ID,
257			ToolName:    EditToolName,
258			Action:      "write",
259			Description: fmt.Sprintf("Delete content from file %s", filePath),
260			Params: EditPermissionsParams{
261				FilePath:   filePath,
262				OldContent: oldContent,
263				NewContent: newContent,
264			},
265		},
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.Debug("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.Debug("Error creating file history version", "error", err)
300	}
301
302	recordFileWrite(filePath)
303	recordFileRead(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 getLastReadTime(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 := getLastReadTime(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 := edit.permissions.Request(
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 !p {
403		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
404	}
405
406	if isCrlf {
407		newContent, _ = fsext.ToWindowsLineEndings(newContent)
408	}
409
410	err = os.WriteFile(filePath, []byte(newContent), 0o644)
411	if err != nil {
412		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
413	}
414
415	// Check if file exists in history
416	file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
417	if err != nil {
418		_, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
419		if err != nil {
420			// Log error but don't fail the operation
421			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
422		}
423	}
424	if file.Content != oldContent {
425		// User Manually changed the content store an intermediate version
426		_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
427		if err != nil {
428			slog.Debug("Error creating file history version", "error", err)
429		}
430	}
431	// Store the new version
432	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
433	if err != nil {
434		slog.Debug("Error creating file history version", "error", err)
435	}
436
437	recordFileWrite(filePath)
438	recordFileRead(filePath)
439
440	return fantasy.WithResponseMetadata(
441		fantasy.NewTextResponse("Content replaced in file: "+filePath),
442		EditResponseMetadata{
443			OldContent: oldContent,
444			NewContent: newContent,
445			Additions:  additions,
446			Removals:   removals,
447		}), nil
448}