1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "testing"
9 "time"
10
11 "github.com/kujtimiihoxha/termai/internal/lsp"
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14)
15
16func TestEditTool_Info(t *testing.T) {
17 tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
18 info := tool.Info()
19
20 assert.Equal(t, EditToolName, info.Name)
21 assert.NotEmpty(t, info.Description)
22 assert.Contains(t, info.Parameters, "file_path")
23 assert.Contains(t, info.Parameters, "old_string")
24 assert.Contains(t, info.Parameters, "new_string")
25 assert.Contains(t, info.Required, "file_path")
26 assert.Contains(t, info.Required, "old_string")
27 assert.Contains(t, info.Required, "new_string")
28}
29
30func TestEditTool_Run(t *testing.T) {
31 // Create a temporary directory for testing
32 tempDir, err := os.MkdirTemp("", "edit_tool_test")
33 require.NoError(t, err)
34 defer os.RemoveAll(tempDir)
35
36 t.Run("creates a new file successfully", func(t *testing.T) {
37 tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
38
39 filePath := filepath.Join(tempDir, "new_file.txt")
40 content := "This is a test content"
41
42 params := EditParams{
43 FilePath: filePath,
44 OldString: "",
45 NewString: content,
46 }
47
48 paramsJSON, err := json.Marshal(params)
49 require.NoError(t, err)
50
51 call := ToolCall{
52 Name: EditToolName,
53 Input: string(paramsJSON),
54 }
55
56 response, err := tool.Run(context.Background(), call)
57 require.NoError(t, err)
58 assert.Contains(t, response.Content, "File created")
59
60 // Verify file was created with correct content
61 fileContent, err := os.ReadFile(filePath)
62 require.NoError(t, err)
63 assert.Equal(t, content, string(fileContent))
64 })
65
66 t.Run("creates file with nested directories", func(t *testing.T) {
67 tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
68
69 filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
70 content := "Content in nested directory"
71
72 params := EditParams{
73 FilePath: filePath,
74 OldString: "",
75 NewString: content,
76 }
77
78 paramsJSON, err := json.Marshal(params)
79 require.NoError(t, err)
80
81 call := ToolCall{
82 Name: EditToolName,
83 Input: string(paramsJSON),
84 }
85
86 response, err := tool.Run(context.Background(), call)
87 require.NoError(t, err)
88 assert.Contains(t, response.Content, "File created")
89
90 // Verify file was created with correct content
91 fileContent, err := os.ReadFile(filePath)
92 require.NoError(t, err)
93 assert.Equal(t, content, string(fileContent))
94 })
95
96 t.Run("fails to create file that already exists", func(t *testing.T) {
97 tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
98
99 // Create a file first
100 filePath := filepath.Join(tempDir, "existing_file.txt")
101 initialContent := "Initial content"
102 err := os.WriteFile(filePath, []byte(initialContent), 0o644)
103 require.NoError(t, err)
104
105 // Try to create the same file
106 params := EditParams{
107 FilePath: filePath,
108 OldString: "",
109 NewString: "New content",
110 }
111
112 paramsJSON, err := json.Marshal(params)
113 require.NoError(t, err)
114
115 call := ToolCall{
116 Name: EditToolName,
117 Input: string(paramsJSON),
118 }
119
120 response, err := tool.Run(context.Background(), call)
121 require.NoError(t, err)
122 assert.Contains(t, response.Content, "file already exists")
123 })
124
125 t.Run("fails to create file when path is a directory", func(t *testing.T) {
126 tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
127
128 // Create a directory
129 dirPath := filepath.Join(tempDir, "test_dir")
130 err := os.Mkdir(dirPath, 0o755)
131 require.NoError(t, err)
132
133 // Try to create a file with the same path as the directory
134 params := EditParams{
135 FilePath: dirPath,
136 OldString: "",
137 NewString: "Some content",
138 }
139
140 paramsJSON, err := json.Marshal(params)
141 require.NoError(t, err)
142
143 call := ToolCall{
144 Name: EditToolName,
145 Input: string(paramsJSON),
146 }
147
148 response, err := tool.Run(context.Background(), call)
149 require.NoError(t, err)
150 assert.Contains(t, response.Content, "path is a directory")
151 })
152
153 t.Run("replaces content successfully", func(t *testing.T) {
154 tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
155
156 // Create a file first
157 filePath := filepath.Join(tempDir, "replace_content.txt")
158 initialContent := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
159 err := os.WriteFile(filePath, []byte(initialContent), 0o644)
160 require.NoError(t, err)
161
162 // Record the file read to avoid modification time check failure
163 recordFileRead(filePath)
164
165 // Replace content
166 oldString := "Line 2\nLine 3"
167 newString := "Line 2 modified\nLine 3 modified"
168 params := EditParams{
169 FilePath: filePath,
170 OldString: oldString,
171 NewString: newString,
172 }
173
174 paramsJSON, err := json.Marshal(params)
175 require.NoError(t, err)
176
177 call := ToolCall{
178 Name: EditToolName,
179 Input: string(paramsJSON),
180 }
181
182 response, err := tool.Run(context.Background(), call)
183 require.NoError(t, err)
184 assert.Contains(t, response.Content, "Content replaced")
185
186 // Verify file was updated with correct content
187 expectedContent := "Line 1\nLine 2 modified\nLine 3 modified\nLine 4\nLine 5"
188 fileContent, err := os.ReadFile(filePath)
189 require.NoError(t, err)
190 assert.Equal(t, expectedContent, string(fileContent))
191 })
192
193 t.Run("deletes content successfully", func(t *testing.T) {
194 tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
195
196 // Create a file first
197 filePath := filepath.Join(tempDir, "delete_content.txt")
198 initialContent := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
199 err := os.WriteFile(filePath, []byte(initialContent), 0o644)
200 require.NoError(t, err)
201
202 // Record the file read to avoid modification time check failure
203 recordFileRead(filePath)
204
205 // Delete content
206 oldString := "Line 2\nLine 3\n"
207 params := EditParams{
208 FilePath: filePath,
209 OldString: oldString,
210 NewString: "",
211 }
212
213 paramsJSON, err := json.Marshal(params)
214 require.NoError(t, err)
215
216 call := ToolCall{
217 Name: EditToolName,
218 Input: string(paramsJSON),
219 }
220
221 response, err := tool.Run(context.Background(), call)
222 require.NoError(t, err)
223 assert.Contains(t, response.Content, "Content deleted")
224
225 // Verify file was updated with correct content
226 expectedContent := "Line 1\nLine 4\nLine 5"
227 fileContent, err := os.ReadFile(filePath)
228 require.NoError(t, err)
229 assert.Equal(t, expectedContent, string(fileContent))
230 })
231
232 t.Run("handles invalid parameters", func(t *testing.T) {
233 tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
234
235 call := ToolCall{
236 Name: EditToolName,
237 Input: "invalid json",
238 }
239
240 response, err := tool.Run(context.Background(), call)
241 require.NoError(t, err)
242 assert.Contains(t, response.Content, "invalid parameters")
243 })
244
245 t.Run("handles missing file_path", func(t *testing.T) {
246 tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
247
248 params := EditParams{
249 FilePath: "",
250 OldString: "old",
251 NewString: "new",
252 }
253
254 paramsJSON, err := json.Marshal(params)
255 require.NoError(t, err)
256
257 call := ToolCall{
258 Name: EditToolName,
259 Input: string(paramsJSON),
260 }
261
262 response, err := tool.Run(context.Background(), call)
263 require.NoError(t, err)
264 assert.Contains(t, response.Content, "file_path is required")
265 })
266
267 t.Run("handles file not found", func(t *testing.T) {
268 tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
269
270 filePath := filepath.Join(tempDir, "non_existent_file.txt")
271 params := EditParams{
272 FilePath: filePath,
273 OldString: "old content",
274 NewString: "new content",
275 }
276
277 paramsJSON, err := json.Marshal(params)
278 require.NoError(t, err)
279
280 call := ToolCall{
281 Name: EditToolName,
282 Input: string(paramsJSON),
283 }
284
285 response, err := tool.Run(context.Background(), call)
286 require.NoError(t, err)
287 assert.Contains(t, response.Content, "file not found")
288 })
289
290 t.Run("handles old_string not found in file", func(t *testing.T) {
291 tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
292
293 // Create a file first
294 filePath := filepath.Join(tempDir, "content_not_found.txt")
295 initialContent := "Line 1\nLine 2\nLine 3"
296 err := os.WriteFile(filePath, []byte(initialContent), 0o644)
297 require.NoError(t, err)
298
299 // Record the file read to avoid modification time check failure
300 recordFileRead(filePath)
301
302 // Try to replace content that doesn't exist
303 params := EditParams{
304 FilePath: filePath,
305 OldString: "This content does not exist",
306 NewString: "new content",
307 }
308
309 paramsJSON, err := json.Marshal(params)
310 require.NoError(t, err)
311
312 call := ToolCall{
313 Name: EditToolName,
314 Input: string(paramsJSON),
315 }
316
317 response, err := tool.Run(context.Background(), call)
318 require.NoError(t, err)
319 assert.Contains(t, response.Content, "old_string not found in file")
320 })
321
322 t.Run("handles multiple occurrences of old_string", func(t *testing.T) {
323 tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
324
325 // Create a file with duplicate content
326 filePath := filepath.Join(tempDir, "duplicate_content.txt")
327 initialContent := "Line 1\nDuplicate\nLine 3\nDuplicate\nLine 5"
328 err := os.WriteFile(filePath, []byte(initialContent), 0o644)
329 require.NoError(t, err)
330
331 // Record the file read to avoid modification time check failure
332 recordFileRead(filePath)
333
334 // Try to replace content that appears multiple times
335 params := EditParams{
336 FilePath: filePath,
337 OldString: "Duplicate",
338 NewString: "Replaced",
339 }
340
341 paramsJSON, err := json.Marshal(params)
342 require.NoError(t, err)
343
344 call := ToolCall{
345 Name: EditToolName,
346 Input: string(paramsJSON),
347 }
348
349 response, err := tool.Run(context.Background(), call)
350 require.NoError(t, err)
351 assert.Contains(t, response.Content, "appears multiple times")
352 })
353
354 t.Run("handles file modified since last read", func(t *testing.T) {
355 tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
356
357 // Create a file
358 filePath := filepath.Join(tempDir, "modified_file.txt")
359 initialContent := "Initial content"
360 err := os.WriteFile(filePath, []byte(initialContent), 0o644)
361 require.NoError(t, err)
362
363 // Record an old read time
364 fileRecordMutex.Lock()
365 fileRecords[filePath] = fileRecord{
366 path: filePath,
367 readTime: time.Now().Add(-1 * time.Hour),
368 }
369 fileRecordMutex.Unlock()
370
371 // Try to update the file
372 params := EditParams{
373 FilePath: filePath,
374 OldString: "Initial",
375 NewString: "Updated",
376 }
377
378 paramsJSON, err := json.Marshal(params)
379 require.NoError(t, err)
380
381 call := ToolCall{
382 Name: EditToolName,
383 Input: string(paramsJSON),
384 }
385
386 response, err := tool.Run(context.Background(), call)
387 require.NoError(t, err)
388 assert.Contains(t, response.Content, "has been modified since it was last read")
389
390 // Verify file was not modified
391 fileContent, err := os.ReadFile(filePath)
392 require.NoError(t, err)
393 assert.Equal(t, initialContent, string(fileContent))
394 })
395
396 t.Run("handles file not read before editing", func(t *testing.T) {
397 tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true))
398
399 // Create a file
400 filePath := filepath.Join(tempDir, "not_read_file.txt")
401 initialContent := "Initial content"
402 err := os.WriteFile(filePath, []byte(initialContent), 0o644)
403 require.NoError(t, err)
404
405 // Try to update the file without reading it first
406 params := EditParams{
407 FilePath: filePath,
408 OldString: "Initial",
409 NewString: "Updated",
410 }
411
412 paramsJSON, err := json.Marshal(params)
413 require.NoError(t, err)
414
415 call := ToolCall{
416 Name: EditToolName,
417 Input: string(paramsJSON),
418 }
419
420 response, err := tool.Run(context.Background(), call)
421 require.NoError(t, err)
422 assert.Contains(t, response.Content, "you must read the file before editing it")
423 })
424
425 t.Run("handles permission denied", func(t *testing.T) {
426 tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(false))
427
428 // Create a file
429 filePath := filepath.Join(tempDir, "permission_denied.txt")
430 initialContent := "Initial content"
431 err := os.WriteFile(filePath, []byte(initialContent), 0o644)
432 require.NoError(t, err)
433
434 // Record the file read to avoid modification time check failure
435 recordFileRead(filePath)
436
437 // Try to update the file
438 params := EditParams{
439 FilePath: filePath,
440 OldString: "Initial",
441 NewString: "Updated",
442 }
443
444 paramsJSON, err := json.Marshal(params)
445 require.NoError(t, err)
446
447 call := ToolCall{
448 Name: EditToolName,
449 Input: string(paramsJSON),
450 }
451
452 response, err := tool.Run(context.Background(), call)
453 require.NoError(t, err)
454 assert.Contains(t, response.Content, "permission denied")
455
456 // Verify file was not modified
457 fileContent, err := os.ReadFile(filePath)
458 require.NoError(t, err)
459 assert.Equal(t, initialContent, string(fileContent))
460 })
461}
462
463func TestGenerateDiff(t *testing.T) {
464 testCases := []struct {
465 name string
466 oldContent string
467 newContent string
468 expectedDiff string
469 }{
470 {
471 name: "add content",
472 oldContent: "Line 1\nLine 2\n",
473 newContent: "Line 1\nLine 2\nLine 3\n",
474 expectedDiff: "Changes:\n Line 1\n Line 2\n+ Line 3\n",
475 },
476 {
477 name: "remove content",
478 oldContent: "Line 1\nLine 2\nLine 3\n",
479 newContent: "Line 1\nLine 3\n",
480 expectedDiff: "Changes:\n Line 1\n- Line 2\n Line 3\n",
481 },
482 {
483 name: "replace content",
484 oldContent: "Line 1\nLine 2\nLine 3\n",
485 newContent: "Line 1\nModified Line\nLine 3\n",
486 expectedDiff: "Changes:\n Line 1\n- Line 2\n+ Modified Line\n Line 3\n",
487 },
488 {
489 name: "empty to content",
490 oldContent: "",
491 newContent: "Line 1\nLine 2\n",
492 expectedDiff: "Changes:\n+ Line 1\n+ Line 2\n",
493 },
494 {
495 name: "content to empty",
496 oldContent: "Line 1\nLine 2\n",
497 newContent: "",
498 expectedDiff: "Changes:\n- Line 1\n- Line 2\n",
499 },
500 }
501
502 for _, tc := range testCases {
503 t.Run(tc.name, func(t *testing.T) {
504 diff := GenerateDiff(tc.oldContent, tc.newContent)
505 assert.Contains(t, diff, tc.expectedDiff)
506 })
507 }
508}
509