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