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