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