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