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/diff"
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, additions, removals := diff.GenerateDiff(
186 "",
187 content,
188 filePath,
189 filePath,
190 )
191 p := e.permissions.Request(
192 permission.CreatePermissionRequest{
193 Path: filepath.Dir(filePath),
194 ToolName: EditToolName,
195 Action: "create",
196 Description: fmt.Sprintf("Create file %s", filePath),
197 Params: EditPermissionsParams{
198 FilePath: filePath,
199 Diff: diff,
200 },
201 },
202 )
203 if !p {
204 return ToolResponse{}, permission.ErrorPermissionDenied
205 }
206
207 err = os.WriteFile(filePath, []byte(content), 0o644)
208 if err != nil {
209 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
210 }
211
212 recordFileWrite(filePath)
213 recordFileRead(filePath)
214
215 return WithResponseMetadata(
216 NewTextResponse("File created: "+filePath),
217 EditResponseMetadata{
218 Diff: diff,
219 Additions: additions,
220 Removals: 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, additions, removals := diff.GenerateDiff(
277 oldContent,
278 newContent,
279 filePath,
280 filePath,
281 )
282
283 p := e.permissions.Request(
284 permission.CreatePermissionRequest{
285 Path: filepath.Dir(filePath),
286 ToolName: EditToolName,
287 Action: "delete",
288 Description: fmt.Sprintf("Delete content from file %s", filePath),
289 Params: EditPermissionsParams{
290 FilePath: filePath,
291 Diff: diff,
292 },
293 },
294 )
295 if !p {
296 return ToolResponse{}, permission.ErrorPermissionDenied
297 }
298
299 err = os.WriteFile(filePath, []byte(newContent), 0o644)
300 if err != nil {
301 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
302 }
303 recordFileWrite(filePath)
304 recordFileRead(filePath)
305
306 return WithResponseMetadata(
307 NewTextResponse("Content deleted from file: "+filePath),
308 EditResponseMetadata{
309 Diff: diff,
310 Additions: additions,
311 Removals: removals,
312 },
313 ), nil
314}
315
316func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string) (ToolResponse, error) {
317 fileInfo, err := os.Stat(filePath)
318 if err != nil {
319 if os.IsNotExist(err) {
320 return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
321 }
322 return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
323 }
324
325 if fileInfo.IsDir() {
326 return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
327 }
328
329 if getLastReadTime(filePath).IsZero() {
330 return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
331 }
332
333 modTime := fileInfo.ModTime()
334 lastRead := getLastReadTime(filePath)
335 if modTime.After(lastRead) {
336 return NewTextErrorResponse(
337 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
338 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
339 )), nil
340 }
341
342 content, err := os.ReadFile(filePath)
343 if err != nil {
344 return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
345 }
346
347 oldContent := string(content)
348
349 index := strings.Index(oldContent, oldString)
350 if index == -1 {
351 return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
352 }
353
354 lastIndex := strings.LastIndex(oldContent, oldString)
355 if index != lastIndex {
356 return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil
357 }
358
359 newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
360
361 sessionID, messageID := GetContextValues(ctx)
362
363 if sessionID == "" || messageID == "" {
364 return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
365 }
366 diff, additions, removals := diff.GenerateDiff(
367 oldContent,
368 newContent,
369 filePath,
370 filePath,
371 )
372 p := e.permissions.Request(
373 permission.CreatePermissionRequest{
374 Path: filepath.Dir(filePath),
375 ToolName: EditToolName,
376 Action: "replace",
377 Description: fmt.Sprintf("Replace content in file %s", filePath),
378 Params: EditPermissionsParams{
379 FilePath: filePath,
380
381 Diff: diff,
382 },
383 },
384 )
385 if !p {
386 return ToolResponse{}, permission.ErrorPermissionDenied
387 }
388
389 err = os.WriteFile(filePath, []byte(newContent), 0o644)
390 if err != nil {
391 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
392 }
393
394 recordFileWrite(filePath)
395 recordFileRead(filePath)
396
397 return WithResponseMetadata(
398 NewTextResponse("Content replaced in file: "+filePath),
399 EditResponseMetadata{
400 Diff: diff,
401 Additions: additions,
402 Removals: removals,
403 }), nil
404}