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	"github.com/charmbracelet/crush/internal/lsp"
 19	"github.com/charmbracelet/crush/internal/permission"
 20)
 21
 22type MultiEditOperation struct {
 23	OldString  string `json:"old_string" description:"The text to replace"`
 24	NewString  string `json:"new_string" description:"The text to replace it with"`
 25	ReplaceAll bool   `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)."`
 26}
 27
 28type MultiEditParams struct {
 29	FilePath string               `json:"file_path" description:"The absolute path to the file to modify"`
 30	Edits    []MultiEditOperation `json:"edits" description:"Array of edit operations to perform sequentially on the file"`
 31}
 32
 33type MultiEditPermissionsParams struct {
 34	FilePath   string `json:"file_path"`
 35	OldContent string `json:"old_content,omitempty"`
 36	NewContent string `json:"new_content,omitempty"`
 37}
 38
 39type MultiEditResponseMetadata struct {
 40	Additions    int    `json:"additions"`
 41	Removals     int    `json:"removals"`
 42	OldContent   string `json:"old_content,omitempty"`
 43	NewContent   string `json:"new_content,omitempty"`
 44	EditsApplied int    `json:"edits_applied"`
 45}
 46
 47const MultiEditToolName = "multiedit"
 48
 49//go:embed multiedit.md
 50var multieditDescription []byte
 51
 52func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
 53	return fantasy.NewAgentTool(
 54		MultiEditToolName,
 55		string(multieditDescription),
 56		func(ctx context.Context, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 57			if params.FilePath == "" {
 58				return fantasy.NewTextErrorResponse("file_path is required"), nil
 59			}
 60
 61			if len(params.Edits) == 0 {
 62				return fantasy.NewTextErrorResponse("at least one edit operation is required"), nil
 63			}
 64
 65			if !filepath.IsAbs(params.FilePath) {
 66				params.FilePath = filepath.Join(workingDir, params.FilePath)
 67			}
 68
 69			// Validate all edits before applying any
 70			if err := validateEdits(params.Edits); err != nil {
 71				return fantasy.NewTextErrorResponse(err.Error()), nil
 72			}
 73
 74			var response fantasy.ToolResponse
 75			var err error
 76
 77			editCtx := editContext{ctx, permissions, files, workingDir}
 78			// Handle file creation case (first edit has empty old_string)
 79			if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
 80				response, err = processMultiEditWithCreation(editCtx, params, call)
 81			} else {
 82				response, err = processMultiEditExistingFile(editCtx, params, call)
 83			}
 84
 85			if err != nil {
 86				return response, err
 87			}
 88
 89			if response.IsError {
 90				return response, nil
 91			}
 92
 93			// Notify LSP clients about the change
 94			notifyLSPs(ctx, lspClients, params.FilePath)
 95
 96			// Wait for LSP diagnostics and add them to the response
 97			text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
 98			text += getDiagnostics(params.FilePath, lspClients)
 99			response.Content = text
100			return response, nil
101		})
102}
103
104func validateEdits(edits []MultiEditOperation) error {
105	for i, edit := range edits {
106		if edit.OldString == edit.NewString {
107			return fmt.Errorf("edit %d: old_string and new_string are identical", i+1)
108		}
109		// Only the first edit can have empty old_string (for file creation)
110		if i > 0 && edit.OldString == "" {
111			return fmt.Errorf("edit %d: only the first edit can have empty old_string (for file creation)", i+1)
112		}
113	}
114	return nil
115}
116
117func processMultiEditWithCreation(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
118	// First edit creates the file
119	firstEdit := params.Edits[0]
120	if firstEdit.OldString != "" {
121		return fantasy.NewTextErrorResponse("first edit must have empty old_string for file creation"), nil
122	}
123
124	// Check if file already exists
125	if _, err := os.Stat(params.FilePath); err == nil {
126		return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil
127	} else if !os.IsNotExist(err) {
128		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
129	}
130
131	// Create parent directories
132	dir := filepath.Dir(params.FilePath)
133	if err := os.MkdirAll(dir, 0o755); err != nil {
134		return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
135	}
136
137	// Start with the content from the first edit
138	currentContent := firstEdit.NewString
139
140	// Apply remaining edits to the content
141	for i := 1; i < len(params.Edits); i++ {
142		edit := params.Edits[i]
143		newContent, err := applyEditToContent(currentContent, edit)
144		if err != nil {
145			return fantasy.NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
146		}
147		currentContent = newContent
148	}
149
150	// Get session and message IDs
151	sessionID := GetSessionFromContext(edit.ctx)
152	if sessionID == "" {
153		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
154	}
155
156	// Check permissions
157	_, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
158
159	p := edit.permissions.Request(permission.CreatePermissionRequest{
160		SessionID:   sessionID,
161		Path:        fsext.PathOrPrefix(params.FilePath, edit.workingDir),
162		ToolCallID:  call.ID,
163		ToolName:    MultiEditToolName,
164		Action:      "write",
165		Description: fmt.Sprintf("Create file %s with %d edits", params.FilePath, len(params.Edits)),
166		Params: MultiEditPermissionsParams{
167			FilePath:   params.FilePath,
168			OldContent: "",
169			NewContent: currentContent,
170		},
171	})
172	if !p {
173		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
174	}
175
176	// Write the file
177	err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
178	if err != nil {
179		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
180	}
181
182	// Update file history
183	_, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, "")
184	if err != nil {
185		return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
186	}
187
188	_, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
189	if err != nil {
190		slog.Debug("Error creating file history version", "error", err)
191	}
192
193	recordFileWrite(params.FilePath)
194	recordFileRead(params.FilePath)
195
196	return fantasy.WithResponseMetadata(
197		fantasy.NewTextResponse(fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)),
198		MultiEditResponseMetadata{
199			OldContent:   "",
200			NewContent:   currentContent,
201			Additions:    additions,
202			Removals:     removals,
203			EditsApplied: len(params.Edits),
204		},
205	), nil
206}
207
208func processMultiEditExistingFile(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
209	// Validate file exists and is readable
210	fileInfo, err := os.Stat(params.FilePath)
211	if err != nil {
212		if os.IsNotExist(err) {
213			return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
214		}
215		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
216	}
217
218	if fileInfo.IsDir() {
219		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
220	}
221
222	// Check if file was read before editing
223	if getLastReadTime(params.FilePath).IsZero() {
224		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
225	}
226
227	// Check if file was modified since last read
228	modTime := fileInfo.ModTime()
229	lastRead := getLastReadTime(params.FilePath)
230	if modTime.After(lastRead) {
231		return fantasy.NewTextErrorResponse(
232			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
233				params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
234			)), nil
235	}
236
237	// Read current file content
238	content, err := os.ReadFile(params.FilePath)
239	if err != nil {
240		return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
241	}
242
243	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
244	currentContent := oldContent
245
246	// Apply all edits sequentially
247	for i, edit := range params.Edits {
248		newContent, err := applyEditToContent(currentContent, edit)
249		if err != nil {
250			return fantasy.NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
251		}
252		currentContent = newContent
253	}
254
255	// Check if content actually changed
256	if oldContent == currentContent {
257		return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
258	}
259
260	// Get session and message IDs
261	sessionID := GetSessionFromContext(edit.ctx)
262	if sessionID == "" {
263		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
264	}
265
266	// Generate diff and check permissions
267	_, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
268	p := edit.permissions.Request(permission.CreatePermissionRequest{
269		SessionID:   sessionID,
270		Path:        fsext.PathOrPrefix(params.FilePath, edit.workingDir),
271		ToolCallID:  call.ID,
272		ToolName:    MultiEditToolName,
273		Action:      "write",
274		Description: fmt.Sprintf("Apply %d edits to file %s", len(params.Edits), params.FilePath),
275		Params: MultiEditPermissionsParams{
276			FilePath:   params.FilePath,
277			OldContent: oldContent,
278			NewContent: currentContent,
279		},
280	})
281	if !p {
282		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
283	}
284
285	if isCrlf {
286		currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
287	}
288
289	// Write the updated content
290	err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
291	if err != nil {
292		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
293	}
294
295	// Update file history
296	file, err := edit.files.GetByPathAndSession(edit.ctx, params.FilePath, sessionID)
297	if err != nil {
298		_, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, oldContent)
299		if err != nil {
300			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
301		}
302	}
303	if file.Content != oldContent {
304		// User manually changed the content, store an intermediate version
305		_, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, oldContent)
306		if err != nil {
307			slog.Debug("Error creating file history version", "error", err)
308		}
309	}
310
311	// Store the new version
312	_, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
313	if err != nil {
314		slog.Debug("Error creating file history version", "error", err)
315	}
316
317	recordFileWrite(params.FilePath)
318	recordFileRead(params.FilePath)
319
320	return fantasy.WithResponseMetadata(
321		fantasy.NewTextResponse(fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)),
322		MultiEditResponseMetadata{
323			OldContent:   oldContent,
324			NewContent:   currentContent,
325			Additions:    additions,
326			Removals:     removals,
327			EditsApplied: len(params.Edits),
328		},
329	), nil
330}
331
332func applyEditToContent(content string, edit MultiEditOperation) (string, error) {
333	if edit.OldString == "" && edit.NewString == "" {
334		return content, nil
335	}
336
337	if edit.OldString == "" {
338		return "", fmt.Errorf("old_string cannot be empty for content replacement")
339	}
340
341	var newContent string
342	var replacementCount int
343
344	if edit.ReplaceAll {
345		newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
346		replacementCount = strings.Count(content, edit.OldString)
347		if replacementCount == 0 {
348			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
349		}
350	} else {
351		index := strings.Index(content, edit.OldString)
352		if index == -1 {
353			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
354		}
355
356		lastIndex := strings.LastIndex(content, edit.OldString)
357		if index != lastIndex {
358			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")
359		}
360
361		newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
362		replacementCount = 1
363	}
364
365	return newContent, nil
366}