1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "time"
11
12 "github.com/kujtimiihoxha/termai/internal/config"
13 "github.com/kujtimiihoxha/termai/internal/git"
14 "github.com/kujtimiihoxha/termai/internal/lsp"
15 "github.com/kujtimiihoxha/termai/internal/permission"
16)
17
18type EditParams struct {
19 FilePath string `json:"file_path"`
20 OldString string `json:"old_string"`
21 NewString string `json:"new_string"`
22}
23
24type EditPermissionsParams struct {
25 FilePath string `json:"file_path"`
26 Diff string `json:"diff"`
27}
28
29type EditResponseMetadata struct {
30 Additions int `json:"additions"`
31 Removals int `json:"removals"`
32}
33
34type editTool struct {
35 lspClients map[string]*lsp.Client
36 permissions permission.Service
37}
38
39const (
40 EditToolName = "edit"
41 editDescription = `Edits files by replacing text, creating new files, or deleting content. For moving or renaming files, use the Bash tool with the 'mv' command instead. For larger file edits, use the FileWrite tool to overwrite files.
42
43Before using this tool:
44
451. Use the FileRead tool to understand the file's contents and context
46
472. Verify the directory path is correct (only applicable when creating new files):
48 - Use the LS tool to verify the parent directory exists and is the correct location
49
50To make a file edit, provide the following:
511. file_path: The absolute path to the file to modify (must be absolute, not relative)
522. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
533. new_string: The edited text to replace the old_string
54
55Special cases:
56- To create a new file: provide file_path and new_string, leave old_string empty
57- To delete content: provide file_path and old_string, leave new_string empty
58
59The tool will replace ONE occurrence of old_string with new_string in the specified file.
60
61CRITICAL REQUIREMENTS FOR USING THIS TOOL:
62
631. UNIQUENESS: The old_string MUST uniquely identify the specific instance you want to change. This means:
64 - Include AT LEAST 3-5 lines of context BEFORE the change point
65 - Include AT LEAST 3-5 lines of context AFTER the change point
66 - Include all whitespace, indentation, and surrounding code exactly as it appears in the file
67
682. SINGLE INSTANCE: This tool can only change ONE instance at a time. If you need to change multiple instances:
69 - Make separate calls to this tool for each instance
70 - Each call must uniquely identify its specific instance using extensive context
71
723. VERIFICATION: Before using this tool:
73 - Check how many instances of the target text exist in the file
74 - If multiple instances exist, gather enough context to uniquely identify each one
75 - Plan separate tool calls for each instance
76
77WARNING: If you do not follow these requirements:
78 - The tool will fail if old_string matches multiple locations
79 - The tool will fail if old_string doesn't match exactly (including whitespace)
80 - You may change the wrong instance if you don't include enough context
81
82When making edits:
83 - Ensure the edit results in idiomatic, correct code
84 - Do not leave the code in a broken state
85 - Always use absolute file paths (starting with /)
86
87Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.`
88)
89
90func NewEditTool(lspClients map[string]*lsp.Client, permissions permission.Service) BaseTool {
91 return &editTool{
92 lspClients: lspClients,
93 permissions: permissions,
94 }
95}
96
97func (e *editTool) Info() ToolInfo {
98 return ToolInfo{
99 Name: EditToolName,
100 Description: editDescription,
101 Parameters: map[string]any{
102 "file_path": map[string]any{
103 "type": "string",
104 "description": "The absolute path to the file to modify",
105 },
106 "old_string": map[string]any{
107 "type": "string",
108 "description": "The text to replace",
109 },
110 "new_string": map[string]any{
111 "type": "string",
112 "description": "The text to replace it with",
113 },
114 },
115 Required: []string{"file_path", "old_string", "new_string"},
116 }
117}
118
119func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
120 var params EditParams
121 if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
122 return NewTextErrorResponse("invalid parameters"), nil
123 }
124
125 if params.FilePath == "" {
126 return NewTextErrorResponse("file_path is required"), nil
127 }
128
129 if !filepath.IsAbs(params.FilePath) {
130 wd := config.WorkingDirectory()
131 params.FilePath = filepath.Join(wd, params.FilePath)
132 }
133
134 if params.OldString == "" {
135 result, err := e.createNewFile(ctx, params.FilePath, params.NewString)
136 if err != nil {
137 return NewTextErrorResponse(fmt.Sprintf("error creating file: %s", err)), nil
138 }
139 return WithResponseMetadata(NewTextResponse(result.text), EditResponseMetadata{
140 Additions: result.additions,
141 Removals: result.removals,
142 }), nil
143 }
144
145 if params.NewString == "" {
146 result, err := e.deleteContent(ctx, params.FilePath, params.OldString)
147 if err != nil {
148 return NewTextErrorResponse(fmt.Sprintf("error deleting content: %s", err)), nil
149 }
150 return WithResponseMetadata(NewTextResponse(result.text), EditResponseMetadata{
151 Additions: result.additions,
152 Removals: result.removals,
153 }), nil
154 }
155
156 result, err := e.replaceContent(ctx, params.FilePath, params.OldString, params.NewString)
157 if err != nil {
158 return NewTextErrorResponse(fmt.Sprintf("error replacing content: %s", err)), nil
159 }
160
161 waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
162 text := fmt.Sprintf("<result>\n%s\n</result>\n", result.text)
163 text += appendDiagnostics(params.FilePath, e.lspClients)
164 return WithResponseMetadata(NewTextResponse(text), EditResponseMetadata{
165 Additions: result.additions,
166 Removals: result.removals,
167 }), nil
168}
169
170type editResponse struct {
171 text string
172 additions int
173 removals int
174}
175
176func (e *editTool) createNewFile(ctx context.Context, filePath, content string) (editResponse, error) {
177 er := editResponse{}
178 fileInfo, err := os.Stat(filePath)
179 if err == nil {
180 if fileInfo.IsDir() {
181 return er, fmt.Errorf("path is a directory, not a file: %s", filePath)
182 }
183 return er, fmt.Errorf("file already exists: %s. Use the Replace tool to overwrite an existing file", filePath)
184 } else if !os.IsNotExist(err) {
185 return er, fmt.Errorf("failed to access file: %w", err)
186 }
187
188 dir := filepath.Dir(filePath)
189 if err = os.MkdirAll(dir, 0o755); err != nil {
190 return er, fmt.Errorf("failed to create parent directories: %w", err)
191 }
192
193 sessionID, messageID := GetContextValues(ctx)
194 if sessionID == "" || messageID == "" {
195 return er, fmt.Errorf("session ID and message ID are required for creating a new file")
196 }
197
198 diff, stats, err := git.GenerateGitDiffWithStats(
199 removeWorkingDirectoryPrefix(filePath),
200 "",
201 content,
202 )
203 if err != nil {
204 return er, fmt.Errorf("failed to get file diff: %w", err)
205 }
206 p := e.permissions.Request(
207 permission.CreatePermissionRequest{
208 Path: filepath.Dir(filePath),
209 ToolName: EditToolName,
210 Action: "create",
211 Description: fmt.Sprintf("Create file %s", filePath),
212 Params: EditPermissionsParams{
213 FilePath: filePath,
214 Diff: diff,
215 },
216 },
217 )
218 if !p {
219 return er, fmt.Errorf("permission denied")
220 }
221
222 err = os.WriteFile(filePath, []byte(content), 0o644)
223 if err != nil {
224 return er, fmt.Errorf("failed to write file: %w", err)
225 }
226
227 recordFileWrite(filePath)
228 recordFileRead(filePath)
229
230 er.text = "File created: " + filePath
231 er.additions = stats.Additions
232 er.removals = stats.Removals
233 return er, nil
234}
235
236func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string) (editResponse, error) {
237 er := editResponse{}
238 fileInfo, err := os.Stat(filePath)
239 if err != nil {
240 if os.IsNotExist(err) {
241 return er, fmt.Errorf("file not found: %s", filePath)
242 }
243 return er, fmt.Errorf("failed to access file: %w", err)
244 }
245
246 if fileInfo.IsDir() {
247 return er, fmt.Errorf("path is a directory, not a file: %s", filePath)
248 }
249
250 if getLastReadTime(filePath).IsZero() {
251 return er, fmt.Errorf("you must read the file before editing it. Use the View tool first")
252 }
253
254 modTime := fileInfo.ModTime()
255 lastRead := getLastReadTime(filePath)
256 if modTime.After(lastRead) {
257 return er, fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
258 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
259 }
260
261 content, err := os.ReadFile(filePath)
262 if err != nil {
263 return er, fmt.Errorf("failed to read file: %w", err)
264 }
265
266 oldContent := string(content)
267
268 index := strings.Index(oldContent, oldString)
269 if index == -1 {
270 return er, fmt.Errorf("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks")
271 }
272
273 lastIndex := strings.LastIndex(oldContent, oldString)
274 if index != lastIndex {
275 return er, fmt.Errorf("old_string appears multiple times in the file. Please provide more context to ensure a unique match")
276 }
277
278 newContent := oldContent[:index] + oldContent[index+len(oldString):]
279
280 sessionID, messageID := GetContextValues(ctx)
281
282 if sessionID == "" || messageID == "" {
283 return er, fmt.Errorf("session ID and message ID are required for creating a new file")
284 }
285
286 diff, stats, err := git.GenerateGitDiffWithStats(
287 removeWorkingDirectoryPrefix(filePath),
288 oldContent,
289 newContent,
290 )
291 if err != nil {
292 return er, fmt.Errorf("failed to get file diff: %w", err)
293 }
294
295 p := e.permissions.Request(
296 permission.CreatePermissionRequest{
297 Path: filepath.Dir(filePath),
298 ToolName: EditToolName,
299 Action: "delete",
300 Description: fmt.Sprintf("Delete content from file %s", filePath),
301 Params: EditPermissionsParams{
302 FilePath: filePath,
303 Diff: diff,
304 },
305 },
306 )
307 if !p {
308 return er, fmt.Errorf("permission denied")
309 }
310
311 err = os.WriteFile(filePath, []byte(newContent), 0o644)
312 if err != nil {
313 return er, fmt.Errorf("failed to write file: %w", err)
314 }
315 recordFileWrite(filePath)
316 recordFileRead(filePath)
317
318 er.text = "Content deleted from file: " + filePath
319 er.additions = stats.Additions
320 er.removals = stats.Removals
321 return er, nil
322}
323
324func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string) (editResponse, error) {
325 er := editResponse{}
326 fileInfo, err := os.Stat(filePath)
327 if err != nil {
328 if os.IsNotExist(err) {
329 return er, fmt.Errorf("file not found: %s", filePath)
330 }
331 return er, fmt.Errorf("failed to access file: %w", err)
332 }
333
334 if fileInfo.IsDir() {
335 return er, fmt.Errorf("path is a directory, not a file: %s", filePath)
336 }
337
338 if getLastReadTime(filePath).IsZero() {
339 return er, fmt.Errorf("you must read the file before editing it. Use the View tool first")
340 }
341
342 modTime := fileInfo.ModTime()
343 lastRead := getLastReadTime(filePath)
344 if modTime.After(lastRead) {
345 return er, fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
346 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
347 }
348
349 content, err := os.ReadFile(filePath)
350 if err != nil {
351 return er, fmt.Errorf("failed to read file: %w", err)
352 }
353
354 oldContent := string(content)
355
356 index := strings.Index(oldContent, oldString)
357 if index == -1 {
358 return er, fmt.Errorf("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks")
359 }
360
361 lastIndex := strings.LastIndex(oldContent, oldString)
362 if index != lastIndex {
363 return er, fmt.Errorf("old_string appears multiple times in the file. Please provide more context to ensure a unique match")
364 }
365
366 newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
367
368 sessionID, messageID := GetContextValues(ctx)
369
370 if sessionID == "" || messageID == "" {
371 return er, fmt.Errorf("session ID and message ID are required for creating a new file")
372 }
373 diff, stats, err := git.GenerateGitDiffWithStats(
374 removeWorkingDirectoryPrefix(filePath),
375 oldContent,
376 newContent,
377 )
378 if err != nil {
379 return er, fmt.Errorf("failed to get file diff: %w", err)
380 }
381
382 p := e.permissions.Request(
383 permission.CreatePermissionRequest{
384 Path: filepath.Dir(filePath),
385 ToolName: EditToolName,
386 Action: "replace",
387 Description: fmt.Sprintf("Replace content in file %s", filePath),
388 Params: EditPermissionsParams{
389 FilePath: filePath,
390
391 Diff: diff,
392 },
393 },
394 )
395 if !p {
396 return er, fmt.Errorf("permission denied")
397 }
398
399 err = os.WriteFile(filePath, []byte(newContent), 0o644)
400 if err != nil {
401 return er, fmt.Errorf("failed to write file: %w", err)
402 }
403
404 recordFileWrite(filePath)
405 recordFileRead(filePath)
406 er.text = "Content replaced in file: " + filePath
407 er.additions = stats.Additions
408 er.removals = stats.Removals
409
410 return er, nil
411}