1package tools
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"log/slog"
  8	"os"
  9	"path/filepath"
 10	"strings"
 11	"time"
 12
 13	"github.com/charmbracelet/crush/internal/diff"
 14	"github.com/charmbracelet/crush/internal/fsext"
 15	"github.com/charmbracelet/crush/internal/history"
 16	"github.com/charmbracelet/crush/internal/lsp"
 17	"github.com/charmbracelet/crush/internal/permission"
 18)
 19
 20type MultiEditOperation struct {
 21	OldString  string `json:"old_string"`
 22	NewString  string `json:"new_string"`
 23	ReplaceAll bool   `json:"replace_all,omitempty"`
 24}
 25
 26type MultiEditParams struct {
 27	FilePath string               `json:"file_path"`
 28	Edits    []MultiEditOperation `json:"edits"`
 29}
 30
 31type MultiEditPermissionsParams struct {
 32	FilePath   string `json:"file_path"`
 33	OldContent string `json:"old_content,omitempty"`
 34	NewContent string `json:"new_content,omitempty"`
 35}
 36
 37type MultiEditResponseMetadata 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	EditsApplied int    `json:"edits_applied"`
 43}
 44
 45type multiEditTool struct {
 46	lspClients  map[string]*lsp.Client
 47	permissions permission.Service
 48	files       history.Service
 49	workingDir  string
 50}
 51
 52const (
 53	MultiEditToolName    = "multiedit"
 54	multiEditDescription = `This is a tool for making multiple edits to a single file in one operation. It is built on top of the Edit tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the Edit tool when you need to make multiple edits to the same file.
 55
 56Before using this tool:
 57
 581. Use the Read tool to understand the file's contents and context
 59
 602. Verify the directory path is correct
 61
 62To make multiple file edits, provide the following:
 631. file_path: The absolute path to the file to modify (must be absolute, not relative)
 642. edits: An array of edit operations to perform, where each edit contains:
 65   - old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
 66   - new_string: The edited text to replace the old_string
 67   - replace_all: Replace all occurrences of old_string. This parameter is optional and defaults to false.
 68
 69IMPORTANT:
 70- All edits are applied in sequence, in the order they are provided
 71- Each edit operates on the result of the previous edit
 72- All edits must be valid for the operation to succeed - if any edit fails, none will be applied
 73- This tool is ideal when you need to make several changes to different parts of the same file
 74
 75CRITICAL REQUIREMENTS:
 761. All edits follow the same requirements as the single Edit tool
 772. The edits are atomic - either all succeed or none are applied
 783. Plan your edits carefully to avoid conflicts between sequential operations
 79
 80WARNING:
 81- The tool will fail if edits.old_string doesn't match the file contents exactly (including whitespace)
 82- The tool will fail if edits.old_string and edits.new_string are the same
 83- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find
 84
 85When making edits:
 86- Ensure all edits result in idiomatic, correct code
 87- Do not leave the code in a broken state
 88- Always use absolute file paths (starting with /)
 89- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
 90- Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
 91
 92If you want to create a new file, use:
 93- A new file path, including dir name if needed
 94- First edit: empty old_string and the new file's contents as new_string
 95- Subsequent edits: normal edit operations on the created content`
 96)
 97
 98func NewMultiEditTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service, workingDir string) BaseTool {
 99	return &multiEditTool{
100		lspClients:  lspClients,
101		permissions: permissions,
102		files:       files,
103		workingDir:  workingDir,
104	}
105}
106
107func (m *multiEditTool) Name() string {
108	return MultiEditToolName
109}
110
111func (m *multiEditTool) Info() ToolInfo {
112	return ToolInfo{
113		Name:        MultiEditToolName,
114		Description: multiEditDescription,
115		Parameters: map[string]any{
116			"file_path": map[string]any{
117				"type":        "string",
118				"description": "The absolute path to the file to modify",
119			},
120			"edits": map[string]any{
121				"type": "array",
122				"items": map[string]any{
123					"type": "object",
124					"properties": map[string]any{
125						"old_string": map[string]any{
126							"type":        "string",
127							"description": "The text to replace",
128						},
129						"new_string": map[string]any{
130							"type":        "string",
131							"description": "The text to replace it with",
132						},
133						"replace_all": map[string]any{
134							"type":        "boolean",
135							"default":     false,
136							"description": "Replace all occurrences of old_string (default false).",
137						},
138					},
139					"required":             []string{"old_string", "new_string"},
140					"additionalProperties": false,
141				},
142				"minItems":    1,
143				"description": "Array of edit operations to perform sequentially on the file",
144			},
145		},
146		Required: []string{"file_path", "edits"},
147	}
148}
149
150func (m *multiEditTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
151	var params MultiEditParams
152	if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
153		return NewTextErrorResponse("invalid parameters"), nil
154	}
155
156	if params.FilePath == "" {
157		return NewTextErrorResponse("file_path is required"), nil
158	}
159
160	if len(params.Edits) == 0 {
161		return NewTextErrorResponse("at least one edit operation is required"), nil
162	}
163
164	if !filepath.IsAbs(params.FilePath) {
165		params.FilePath = filepath.Join(m.workingDir, params.FilePath)
166	}
167
168	// Validate all edits before applying any
169	if err := m.validateEdits(params.Edits); err != nil {
170		return NewTextErrorResponse(err.Error()), nil
171	}
172
173	var response ToolResponse
174	var err error
175
176	// Handle file creation case (first edit has empty old_string)
177	if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
178		response, err = m.processMultiEditWithCreation(ctx, params, call)
179	} else {
180		response, err = m.processMultiEditExistingFile(ctx, params, call)
181	}
182
183	if err != nil {
184		return response, err
185	}
186
187	if response.IsError {
188		return response, nil
189	}
190
191	// Wait for LSP diagnostics and add them to the response
192	waitForLspDiagnostics(ctx, params.FilePath, m.lspClients)
193	text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
194	text += getDiagnostics(params.FilePath, m.lspClients)
195	response.Content = text
196	return response, nil
197}
198
199func (m *multiEditTool) validateEdits(edits []MultiEditOperation) error {
200	for i, edit := range edits {
201		if edit.OldString == edit.NewString {
202			return fmt.Errorf("edit %d: old_string and new_string are identical", i+1)
203		}
204		// Only the first edit can have empty old_string (for file creation)
205		if i > 0 && edit.OldString == "" {
206			return fmt.Errorf("edit %d: only the first edit can have empty old_string (for file creation)", i+1)
207		}
208	}
209	return nil
210}
211
212func (m *multiEditTool) processMultiEditWithCreation(ctx context.Context, params MultiEditParams, call ToolCall) (ToolResponse, error) {
213	// First edit creates the file
214	firstEdit := params.Edits[0]
215	if firstEdit.OldString != "" {
216		return NewTextErrorResponse("first edit must have empty old_string for file creation"), nil
217	}
218
219	// Check if file already exists
220	if _, err := os.Stat(params.FilePath); err == nil {
221		return NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil
222	} else if !os.IsNotExist(err) {
223		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
224	}
225
226	// Create parent directories
227	dir := filepath.Dir(params.FilePath)
228	if err := os.MkdirAll(dir, 0o755); err != nil {
229		return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
230	}
231
232	// Start with the content from the first edit
233	currentContent := firstEdit.NewString
234
235	// Apply remaining edits to the content
236	for i := 1; i < len(params.Edits); i++ {
237		edit := params.Edits[i]
238		newContent, err := m.applyEditToContent(currentContent, edit)
239		if err != nil {
240			return NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
241		}
242		currentContent = newContent
243	}
244
245	// Get session and message IDs
246	sessionID, messageID := GetContextValues(ctx)
247	if sessionID == "" || messageID == "" {
248		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
249	}
250
251	// Check permissions
252	_, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, m.workingDir))
253
254	p := m.permissions.Request(permission.CreatePermissionRequest{
255		SessionID:   sessionID,
256		Path:        fsext.PathOrPrefix(params.FilePath, m.workingDir),
257		ToolCallID:  call.ID,
258		ToolName:    MultiEditToolName,
259		Action:      "write",
260		Description: fmt.Sprintf("Create file %s with %d edits", params.FilePath, len(params.Edits)),
261		Params: MultiEditPermissionsParams{
262			FilePath:   params.FilePath,
263			OldContent: "",
264			NewContent: currentContent,
265		},
266	})
267	if !p {
268		return ToolResponse{}, permission.ErrorPermissionDenied
269	}
270
271	// Write the file
272	err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
273	if err != nil {
274		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
275	}
276
277	// Update file history
278	_, err = m.files.Create(ctx, sessionID, params.FilePath, "")
279	if err != nil {
280		return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
281	}
282
283	_, err = m.files.CreateVersion(ctx, sessionID, params.FilePath, currentContent)
284	if err != nil {
285		slog.Debug("Error creating file history version", "error", err)
286	}
287
288	recordFileWrite(params.FilePath)
289	recordFileRead(params.FilePath)
290
291	return WithResponseMetadata(
292		NewTextResponse(fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)),
293		MultiEditResponseMetadata{
294			OldContent:   "",
295			NewContent:   currentContent,
296			Additions:    additions,
297			Removals:     removals,
298			EditsApplied: len(params.Edits),
299		},
300	), nil
301}
302
303func (m *multiEditTool) processMultiEditExistingFile(ctx context.Context, params MultiEditParams, call ToolCall) (ToolResponse, error) {
304	// Validate file exists and is readable
305	fileInfo, err := os.Stat(params.FilePath)
306	if err != nil {
307		if os.IsNotExist(err) {
308			return NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
309		}
310		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
311	}
312
313	if fileInfo.IsDir() {
314		return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
315	}
316
317	// Check if file was read before editing
318	if getLastReadTime(params.FilePath).IsZero() {
319		return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
320	}
321
322	// Check if file was modified since last read
323	modTime := fileInfo.ModTime()
324	lastRead := getLastReadTime(params.FilePath)
325	if modTime.After(lastRead) {
326		return NewTextErrorResponse(
327			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
328				params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
329			)), nil
330	}
331
332	// Read current file content
333	content, err := os.ReadFile(params.FilePath)
334	if err != nil {
335		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
336	}
337
338	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
339	currentContent := oldContent
340
341	// Apply all edits sequentially
342	for i, edit := range params.Edits {
343		newContent, err := m.applyEditToContent(currentContent, edit)
344		if err != nil {
345			return NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
346		}
347		currentContent = newContent
348	}
349
350	// Check if content actually changed
351	if oldContent == currentContent {
352		return NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
353	}
354
355	// Get session and message IDs
356	sessionID, messageID := GetContextValues(ctx)
357	if sessionID == "" || messageID == "" {
358		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for editing file")
359	}
360
361	// Generate diff and check permissions
362	_, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, m.workingDir))
363	p := m.permissions.Request(permission.CreatePermissionRequest{
364		SessionID:   sessionID,
365		Path:        fsext.PathOrPrefix(params.FilePath, m.workingDir),
366		ToolCallID:  call.ID,
367		ToolName:    MultiEditToolName,
368		Action:      "write",
369		Description: fmt.Sprintf("Apply %d edits to file %s", len(params.Edits), params.FilePath),
370		Params: MultiEditPermissionsParams{
371			FilePath:   params.FilePath,
372			OldContent: oldContent,
373			NewContent: currentContent,
374		},
375	})
376	if !p {
377		return ToolResponse{}, permission.ErrorPermissionDenied
378	}
379
380	if isCrlf {
381		currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
382	}
383
384	// Write the updated content
385	err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
386	if err != nil {
387		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
388	}
389
390	// Update file history
391	file, err := m.files.GetByPathAndSession(ctx, params.FilePath, sessionID)
392	if err != nil {
393		_, err = m.files.Create(ctx, sessionID, params.FilePath, oldContent)
394		if err != nil {
395			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
396		}
397	}
398	if file.Content != oldContent {
399		// User manually changed the content, store an intermediate version
400		_, err = m.files.CreateVersion(ctx, sessionID, params.FilePath, oldContent)
401		if err != nil {
402			slog.Debug("Error creating file history version", "error", err)
403		}
404	}
405
406	// Store the new version
407	_, err = m.files.CreateVersion(ctx, sessionID, params.FilePath, currentContent)
408	if err != nil {
409		slog.Debug("Error creating file history version", "error", err)
410	}
411
412	recordFileWrite(params.FilePath)
413	recordFileRead(params.FilePath)
414
415	return WithResponseMetadata(
416		NewTextResponse(fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)),
417		MultiEditResponseMetadata{
418			OldContent:   oldContent,
419			NewContent:   currentContent,
420			Additions:    additions,
421			Removals:     removals,
422			EditsApplied: len(params.Edits),
423		},
424	), nil
425}
426
427func (m *multiEditTool) applyEditToContent(content string, edit MultiEditOperation) (string, error) {
428	if edit.OldString == "" && edit.NewString == "" {
429		return content, nil
430	}
431
432	if edit.OldString == "" {
433		return "", fmt.Errorf("old_string cannot be empty for content replacement")
434	}
435
436	var newContent string
437	var replacementCount int
438
439	if edit.ReplaceAll {
440		newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
441		replacementCount = strings.Count(content, edit.OldString)
442		if replacementCount == 0 {
443			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
444		}
445	} else {
446		index := strings.Index(content, edit.OldString)
447		if index == -1 {
448			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
449		}
450
451		lastIndex := strings.LastIndex(content, edit.OldString)
452		if index != lastIndex {
453			return "", fmt.Errorf("old_string appears multiple times in the content. Please provide more context to ensure a unique match, or set replace_all to true")
454		}
455
456		newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
457		replacementCount = 1
458	}
459
460	return newContent, nil
461}