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
253	p := m.permissions.Request(permission.CreatePermissionRequest{
254		SessionID:   sessionID,
255		Path:        params.FilePath,
256		ToolCallID:  call.ID,
257		ToolName:    MultiEditToolName,
258		Action:      "write",
259		Description: fmt.Sprintf("Create file %s with %d edits", params.FilePath, len(params.Edits)),
260		Params: MultiEditPermissionsParams{
261			FilePath:   params.FilePath,
262			OldContent: "",
263			NewContent: currentContent,
264		},
265	})
266	if !p {
267		return ToolResponse{}, permission.ErrorPermissionDenied
268	}
269
270	// Write the file
271	err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
272	if err != nil {
273		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
274	}
275
276	// Update file history
277	_, err = m.files.Create(ctx, sessionID, params.FilePath, "")
278	if err != nil {
279		return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
280	}
281
282	_, err = m.files.CreateVersion(ctx, sessionID, params.FilePath, currentContent)
283	if err != nil {
284		slog.Debug("Error creating file history version", "error", err)
285	}
286
287	recordFileWrite(params.FilePath)
288	recordFileRead(params.FilePath)
289
290	return WithResponseMetadata(
291		NewTextResponse(fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)),
292		MultiEditResponseMetadata{
293			OldContent:   "",
294			NewContent:   currentContent,
295			Additions:    additions,
296			Removals:     removals,
297			EditsApplied: len(params.Edits),
298		},
299	), nil
300}
301
302func (m *multiEditTool) processMultiEditExistingFile(ctx context.Context, params MultiEditParams, call ToolCall) (ToolResponse, error) {
303	// Validate file exists and is readable
304	fileInfo, err := os.Stat(params.FilePath)
305	if err != nil {
306		if os.IsNotExist(err) {
307			return NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
308		}
309		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
310	}
311
312	if fileInfo.IsDir() {
313		return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
314	}
315
316	// Check if file was read before editing
317	if getLastReadTime(params.FilePath).IsZero() {
318		return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
319	}
320
321	// Check if file was modified since last read
322	modTime := fileInfo.ModTime()
323	lastRead := getLastReadTime(params.FilePath)
324	if modTime.After(lastRead) {
325		return NewTextErrorResponse(
326			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
327				params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
328			)), nil
329	}
330
331	// Read current file content
332	content, err := os.ReadFile(params.FilePath)
333	if err != nil {
334		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
335	}
336
337	oldContent := string(content)
338	currentContent := oldContent
339
340	// Apply all edits sequentially
341	for i, edit := range params.Edits {
342		newContent, err := m.applyEditToContent(currentContent, edit)
343		if err != nil {
344			return NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
345		}
346		currentContent = newContent
347	}
348
349	// Check if content actually changed
350	if oldContent == currentContent {
351		return NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
352	}
353
354	// Get session and message IDs
355	sessionID, messageID := GetContextValues(ctx)
356	if sessionID == "" || messageID == "" {
357		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for editing file")
358	}
359
360	// Generate diff and check permissions
361	_, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, m.workingDir))
362
363	p := m.permissions.Request(permission.CreatePermissionRequest{
364		SessionID:   sessionID,
365		Path:        params.FilePath,
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	// Write the updated content
381	err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
382	if err != nil {
383		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
384	}
385
386	// Update file history
387	file, err := m.files.GetByPathAndSession(ctx, params.FilePath, sessionID)
388	if err != nil {
389		_, err = m.files.Create(ctx, sessionID, params.FilePath, oldContent)
390		if err != nil {
391			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
392		}
393	}
394	if file.Content != oldContent {
395		// User manually changed the content, store an intermediate version
396		_, err = m.files.CreateVersion(ctx, sessionID, params.FilePath, oldContent)
397		if err != nil {
398			slog.Debug("Error creating file history version", "error", err)
399		}
400	}
401
402	// Store the new version
403	_, err = m.files.CreateVersion(ctx, sessionID, params.FilePath, currentContent)
404	if err != nil {
405		slog.Debug("Error creating file history version", "error", err)
406	}
407
408	recordFileWrite(params.FilePath)
409	recordFileRead(params.FilePath)
410
411	return WithResponseMetadata(
412		NewTextResponse(fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)),
413		MultiEditResponseMetadata{
414			OldContent:   oldContent,
415			NewContent:   currentContent,
416			Additions:    additions,
417			Removals:     removals,
418			EditsApplied: len(params.Edits),
419		},
420	), nil
421}
422
423func (m *multiEditTool) applyEditToContent(content string, edit MultiEditOperation) (string, error) {
424	if edit.OldString == "" && edit.NewString == "" {
425		return content, nil
426	}
427
428	if edit.OldString == "" {
429		return "", fmt.Errorf("old_string cannot be empty for content replacement")
430	}
431
432	var newContent string
433	var replacementCount int
434
435	if edit.ReplaceAll {
436		newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
437		replacementCount = strings.Count(content, edit.OldString)
438		if replacementCount == 0 {
439			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
440		}
441	} else {
442		index := strings.Index(content, edit.OldString)
443		if index == -1 {
444			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
445		}
446
447		lastIndex := strings.LastIndex(content, edit.OldString)
448		if index != lastIndex {
449			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")
450		}
451
452		newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
453		replacementCount = 1
454	}
455
456	return newContent, nil
457}