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