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 var response ToolResponse
135 var err error
136
137 if params.OldString == "" {
138 response, err = e.createNewFile(ctx, params.FilePath, params.NewString)
139 if err != nil {
140 return response, nil
141 }
142 }
143
144 if params.NewString == "" {
145 response, err = e.deleteContent(ctx, params.FilePath, params.OldString)
146 if err != nil {
147 return response, nil
148 }
149 }
150
151 response, err = e.replaceContent(ctx, params.FilePath, params.OldString, params.NewString)
152 if err != nil {
153 return response, nil
154 }
155
156 waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
157 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
158 text += getDiagnostics(params.FilePath, e.lspClients)
159 response.Content = text
160 return response, nil
161}
162
163func (e *editTool) createNewFile(ctx context.Context, filePath, content string) (ToolResponse, error) {
164 fileInfo, err := os.Stat(filePath)
165 if err == nil {
166 if fileInfo.IsDir() {
167 return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
168 }
169 return NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
170 } else if !os.IsNotExist(err) {
171 return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
172 }
173
174 dir := filepath.Dir(filePath)
175 if err = os.MkdirAll(dir, 0o755); err != nil {
176 return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
177 }
178
179 sessionID, messageID := GetContextValues(ctx)
180 if sessionID == "" || messageID == "" {
181 return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
182 }
183
184 diff, stats, err := git.GenerateGitDiffWithStats(
185 removeWorkingDirectoryPrefix(filePath),
186 "",
187 content,
188 )
189 if err != nil {
190 return ToolResponse{}, fmt.Errorf("failed to get file diff: %w", err)
191 }
192 p := e.permissions.Request(
193 permission.CreatePermissionRequest{
194 Path: filepath.Dir(filePath),
195 ToolName: EditToolName,
196 Action: "create",
197 Description: fmt.Sprintf("Create file %s", filePath),
198 Params: EditPermissionsParams{
199 FilePath: filePath,
200 Diff: diff,
201 },
202 },
203 )
204 if !p {
205 return ToolResponse{}, permission.ErrorPermissionDenied
206 }
207
208 err = os.WriteFile(filePath, []byte(content), 0o644)
209 if err != nil {
210 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
211 }
212
213 recordFileWrite(filePath)
214 recordFileRead(filePath)
215
216 return WithResponseMetadata(
217 NewTextResponse("File created: "+filePath),
218 EditResponseMetadata{
219 Additions: stats.Additions,
220 Removals: stats.Removals,
221 },
222 ), nil
223}
224
225func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string) (ToolResponse, error) {
226 fileInfo, err := os.Stat(filePath)
227 if err != nil {
228 if os.IsNotExist(err) {
229 return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
230 }
231 return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
232 }
233
234 if fileInfo.IsDir() {
235 return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
236 }
237
238 if getLastReadTime(filePath).IsZero() {
239 return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
240 }
241
242 modTime := fileInfo.ModTime()
243 lastRead := getLastReadTime(filePath)
244 if modTime.After(lastRead) {
245 return NewTextErrorResponse(
246 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
247 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
248 )), nil
249 }
250
251 content, err := os.ReadFile(filePath)
252 if err != nil {
253 return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
254 }
255
256 oldContent := string(content)
257
258 index := strings.Index(oldContent, oldString)
259 if index == -1 {
260 return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
261 }
262
263 lastIndex := strings.LastIndex(oldContent, oldString)
264 if index != lastIndex {
265 return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil
266 }
267
268 newContent := oldContent[:index] + oldContent[index+len(oldString):]
269
270 sessionID, messageID := GetContextValues(ctx)
271
272 if sessionID == "" || messageID == "" {
273 return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
274 }
275
276 diff, stats, err := git.GenerateGitDiffWithStats(
277 removeWorkingDirectoryPrefix(filePath),
278 oldContent,
279 newContent,
280 )
281 if err != nil {
282 return ToolResponse{}, fmt.Errorf("failed to get file diff: %w", err)
283 }
284
285 p := e.permissions.Request(
286 permission.CreatePermissionRequest{
287 Path: filepath.Dir(filePath),
288 ToolName: EditToolName,
289 Action: "delete",
290 Description: fmt.Sprintf("Delete content from file %s", filePath),
291 Params: EditPermissionsParams{
292 FilePath: filePath,
293 Diff: diff,
294 },
295 },
296 )
297 if !p {
298 return ToolResponse{}, permission.ErrorPermissionDenied
299 }
300
301 err = os.WriteFile(filePath, []byte(newContent), 0o644)
302 if err != nil {
303 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
304 }
305 recordFileWrite(filePath)
306 recordFileRead(filePath)
307
308 return WithResponseMetadata(
309 NewTextResponse("Content deleted from file: "+filePath),
310 EditResponseMetadata{
311 Additions: stats.Additions,
312 Removals: stats.Removals,
313 },
314 ), nil
315}
316
317func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string) (ToolResponse, error) {
318 fileInfo, err := os.Stat(filePath)
319 if err != nil {
320 if os.IsNotExist(err) {
321 return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
322 }
323 return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
324 }
325
326 if fileInfo.IsDir() {
327 return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
328 }
329
330 if getLastReadTime(filePath).IsZero() {
331 return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
332 }
333
334 modTime := fileInfo.ModTime()
335 lastRead := getLastReadTime(filePath)
336 if modTime.After(lastRead) {
337 return NewTextErrorResponse(
338 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
339 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
340 )), nil
341 }
342
343 content, err := os.ReadFile(filePath)
344 if err != nil {
345 return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
346 }
347
348 oldContent := string(content)
349
350 index := strings.Index(oldContent, oldString)
351 if index == -1 {
352 return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
353 }
354
355 lastIndex := strings.LastIndex(oldContent, oldString)
356 if index != lastIndex {
357 return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil
358 }
359
360 newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
361
362 sessionID, messageID := GetContextValues(ctx)
363
364 if sessionID == "" || messageID == "" {
365 return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
366 }
367 diff, stats, err := git.GenerateGitDiffWithStats(
368 removeWorkingDirectoryPrefix(filePath),
369 oldContent,
370 newContent,
371 )
372 if err != nil {
373 return ToolResponse{}, fmt.Errorf("failed to get file diff: %w", err)
374 }
375
376 p := e.permissions.Request(
377 permission.CreatePermissionRequest{
378 Path: filepath.Dir(filePath),
379 ToolName: EditToolName,
380 Action: "replace",
381 Description: fmt.Sprintf("Replace content in file %s", filePath),
382 Params: EditPermissionsParams{
383 FilePath: filePath,
384
385 Diff: diff,
386 },
387 },
388 )
389 if !p {
390 return ToolResponse{}, permission.ErrorPermissionDenied
391 }
392
393 err = os.WriteFile(filePath, []byte(newContent), 0o644)
394 if err != nil {
395 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
396 }
397
398 recordFileWrite(filePath)
399 recordFileRead(filePath)
400
401 return WithResponseMetadata(
402 NewTextResponse("Content replaced in file: "+filePath),
403 EditResponseMetadata{
404 Additions: stats.Additions,
405 Removals: stats.Removals,
406 }), nil
407}