edit_test.go

  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