@@ -0,0 +1,509 @@
1+package tools
2+
3+import (
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+
16+func 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+
30+func 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+
463+func 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+