edit_test.go 14 KB

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