| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1 | package claudetool |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | // setupTestFile creates a temporary file with given content for testing |
| 13 | func setupTestFile(t *testing.T, content string) string { |
| 14 | t.Helper() |
| 15 | |
| 16 | // Create a temporary directory |
| 17 | tempDir, err := os.MkdirTemp("", "anthropic_edit_test_*") |
| 18 | if err != nil { |
| 19 | t.Fatalf("Failed to create temp directory: %v", err) |
| 20 | } |
| 21 | |
| 22 | // Create a test file in the temp directory |
| 23 | testFile := filepath.Join(tempDir, "test_file.txt") |
| 24 | if err := os.WriteFile(testFile, []byte(content), 0o644); err != nil { |
| 25 | os.RemoveAll(tempDir) |
| 26 | t.Fatalf("Failed to write test file: %v", err) |
| 27 | } |
| 28 | |
| 29 | // Register cleanup function |
| 30 | t.Cleanup(func() { |
| 31 | os.RemoveAll(tempDir) |
| 32 | }) |
| 33 | |
| 34 | return testFile |
| 35 | } |
| 36 | |
| 37 | // callEditTool is a helper to call the edit tool with specific parameters |
| 38 | func callEditTool(t *testing.T, input map[string]any) string { |
| 39 | t.Helper() |
| 40 | |
| 41 | // Convert input to JSON |
| 42 | inputJSON, err := json.Marshal(input) |
| 43 | if err != nil { |
| 44 | t.Fatalf("Failed to marshal input: %v", err) |
| 45 | } |
| 46 | |
| 47 | // Call the tool |
| 48 | result, err := EditRun(context.Background(), inputJSON) |
| 49 | if err != nil { |
| 50 | t.Fatalf("Tool execution failed: %v", err) |
| 51 | } |
| 52 | |
| 53 | return result |
| 54 | } |
| 55 | |
| 56 | // TestEditToolView tests the view command functionality |
| 57 | func TestEditToolView(t *testing.T) { |
| 58 | content := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" |
| 59 | testFile := setupTestFile(t, content) |
| 60 | |
| 61 | // Test the view command |
| 62 | result := callEditTool(t, map[string]any{ |
| 63 | "command": "view", |
| 64 | "path": testFile, |
| 65 | }) |
| 66 | |
| 67 | // Verify results |
| 68 | if !strings.Contains(result, "Line 1") { |
| 69 | t.Errorf("View result should contain the file content, got: %s", result) |
| 70 | } |
| 71 | |
| 72 | // Test view with range |
| 73 | result = callEditTool(t, map[string]any{ |
| 74 | "command": "view", |
| 75 | "path": testFile, |
| 76 | "view_range": []int{2, 4}, |
| 77 | }) |
| 78 | |
| 79 | // Verify range results |
| 80 | if strings.Contains(result, "Line 1") || !strings.Contains(result, "Line 2") { |
| 81 | t.Errorf("View with range should show only specified lines, got: %s", result) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | // TestEditToolStrReplace tests the str_replace command functionality |
| 86 | func TestEditToolStrReplace(t *testing.T) { |
| 87 | content := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" |
| 88 | testFile := setupTestFile(t, content) |
| 89 | |
| 90 | // Test the str_replace command |
| 91 | result := callEditTool(t, map[string]any{ |
| 92 | "command": "str_replace", |
| 93 | "path": testFile, |
| 94 | "old_str": "Line 3", |
| 95 | "new_str": "Modified Line 3", |
| 96 | }) |
| 97 | |
| 98 | // Verify the file was modified |
| 99 | modifiedContent, err := os.ReadFile(testFile) |
| 100 | if err != nil { |
| 101 | t.Fatalf("Failed to read test file: %v", err) |
| 102 | } |
| 103 | |
| 104 | if !strings.Contains(string(modifiedContent), "Modified Line 3") { |
| 105 | t.Errorf("File content should be modified, got: %s", string(modifiedContent)) |
| 106 | } |
| 107 | |
| 108 | // Verify the result contains a snippet |
| 109 | if !strings.Contains(result, "Modified Line 3") { |
| 110 | t.Errorf("Result should contain the modified content, got: %s", result) |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | // TestEditToolInsert tests the insert command functionality |
| 115 | func TestEditToolInsert(t *testing.T) { |
| 116 | content := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" |
| 117 | testFile := setupTestFile(t, content) |
| 118 | |
| 119 | // Test the insert command |
| 120 | result := callEditTool(t, map[string]any{ |
| 121 | "command": "insert", |
| 122 | "path": testFile, |
| 123 | "insert_line": 2, |
| 124 | "new_str": "Inserted Line", |
| 125 | }) |
| 126 | |
| 127 | // Verify the file was modified |
| 128 | modifiedContent, err := os.ReadFile(testFile) |
| 129 | if err != nil { |
| 130 | t.Fatalf("Failed to read test file: %v", err) |
| 131 | } |
| 132 | |
| 133 | expected := "Line 1\nLine 2\nInserted Line\nLine 3\nLine 4\nLine 5" |
| 134 | if string(modifiedContent) != expected { |
| 135 | t.Errorf("File content incorrect after insert. Expected:\n%s\nGot:\n%s", expected, string(modifiedContent)) |
| 136 | } |
| 137 | |
| 138 | // Verify the result contains a snippet |
| 139 | if !strings.Contains(result, "Inserted Line") { |
| 140 | t.Errorf("Result should contain the inserted content, got: %s", result) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // TestEditToolCreate tests the create command functionality |
| 145 | func TestEditToolCreate(t *testing.T) { |
| 146 | tempDir, err := os.MkdirTemp("", "anthropic_edit_test_create_*") |
| 147 | if err != nil { |
| 148 | t.Fatalf("Failed to create temp directory: %v", err) |
| 149 | } |
| 150 | |
| 151 | t.Cleanup(func() { |
| 152 | os.RemoveAll(tempDir) |
| 153 | }) |
| 154 | |
| 155 | newFilePath := filepath.Join(tempDir, "new_file.txt") |
| 156 | content := "This is a new file\nWith multiple lines" |
| 157 | |
| 158 | // Test the create command |
| 159 | result := callEditTool(t, map[string]any{ |
| 160 | "command": "create", |
| 161 | "path": newFilePath, |
| 162 | "file_text": content, |
| 163 | }) |
| 164 | |
| 165 | // Verify the file was created with the right content |
| 166 | createdContent, err := os.ReadFile(newFilePath) |
| 167 | if err != nil { |
| 168 | t.Fatalf("Failed to read created file: %v", err) |
| 169 | } |
| 170 | |
| 171 | if string(createdContent) != content { |
| 172 | t.Errorf("Created file content incorrect. Expected:\n%s\nGot:\n%s", content, string(createdContent)) |
| 173 | } |
| 174 | |
| 175 | // Verify the result message |
| 176 | if !strings.Contains(result, "File created successfully") { |
| 177 | t.Errorf("Result should confirm file creation, got: %s", result) |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | // TestEditToolUndoEdit tests the undo_edit command functionality |
| 182 | func TestEditToolUndoEdit(t *testing.T) { |
| 183 | originalContent := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" |
| 184 | testFile := setupTestFile(t, originalContent) |
| 185 | |
| 186 | // First modify the file |
| 187 | callEditTool(t, map[string]any{ |
| 188 | "command": "str_replace", |
| 189 | "path": testFile, |
| 190 | "old_str": "Line 3", |
| 191 | "new_str": "Modified Line 3", |
| 192 | }) |
| 193 | |
| 194 | // Then undo the edit |
| 195 | result := callEditTool(t, map[string]any{ |
| 196 | "command": "undo_edit", |
| 197 | "path": testFile, |
| 198 | }) |
| 199 | |
| 200 | // Verify the file was restored to original content |
| 201 | restoredContent, err := os.ReadFile(testFile) |
| 202 | if err != nil { |
| 203 | t.Fatalf("Failed to read test file: %v", err) |
| 204 | } |
| 205 | |
| 206 | if string(restoredContent) != originalContent { |
| 207 | t.Errorf("File content should be restored to original, got: %s", string(restoredContent)) |
| 208 | } |
| 209 | |
| 210 | // Verify the result message |
| 211 | if !strings.Contains(result, "undone successfully") { |
| 212 | t.Errorf("Result should confirm undo operation, got: %s", result) |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | // TestEditToolErrors tests various error conditions |
| 217 | func TestEditToolErrors(t *testing.T) { |
| 218 | content := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" |
| 219 | testFile := setupTestFile(t, content) |
| 220 | |
| 221 | testCases := []struct { |
| 222 | name string |
| 223 | input map[string]any |
| 224 | errMsg string |
| 225 | }{ |
| 226 | { |
| 227 | name: "Invalid command", |
| 228 | input: map[string]any{ |
| 229 | "command": "invalid_command", |
| 230 | "path": testFile, |
| 231 | }, |
| 232 | errMsg: "unrecognized command", |
| 233 | }, |
| 234 | { |
| 235 | name: "Non-existent file", |
| 236 | input: map[string]any{ |
| 237 | "command": "view", |
| 238 | "path": "/non/existent/file.txt", |
| 239 | }, |
| 240 | errMsg: "does not exist", |
| 241 | }, |
| 242 | { |
| 243 | name: "Missing required parameter", |
| 244 | input: map[string]any{ |
| 245 | "command": "str_replace", |
| 246 | "path": testFile, |
| 247 | // Missing old_str |
| 248 | }, |
| 249 | errMsg: "parameter old_str is required", |
| 250 | }, |
| 251 | { |
| 252 | name: "Multiple occurrences in str_replace", |
| 253 | input: map[string]any{ |
| 254 | "command": "str_replace", |
| 255 | "path": testFile, |
| 256 | "old_str": "Line", // Appears multiple times |
| 257 | "new_str": "Modified Line", |
| 258 | }, |
| 259 | errMsg: "Multiple occurrences", |
| 260 | }, |
| 261 | { |
| 262 | name: "Invalid view range", |
| 263 | input: map[string]any{ |
| 264 | "command": "view", |
| 265 | "path": testFile, |
| 266 | "view_range": []int{10, 20}, // Out of range |
| 267 | }, |
| 268 | errMsg: "invalid view_range", |
| 269 | }, |
| 270 | } |
| 271 | |
| 272 | for _, tc := range testCases { |
| 273 | t.Run(tc.name, func(t *testing.T) { |
| 274 | inputJSON, err := json.Marshal(tc.input) |
| 275 | if err != nil { |
| 276 | t.Fatalf("Failed to marshal input: %v", err) |
| 277 | } |
| 278 | |
| 279 | _, err = EditRun(context.Background(), inputJSON) |
| 280 | if err == nil { |
| 281 | t.Fatalf("Expected error but got none") |
| 282 | } |
| 283 | |
| 284 | if !strings.Contains(err.Error(), tc.errMsg) { |
| 285 | t.Errorf("Error message does not contain expected text. Expected to contain: %q, Got: %q", tc.errMsg, err.Error()) |
| 286 | } |
| 287 | }) |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | // TestHandleStrReplaceEdgeCases tests the handleStrReplace function specifically for edge cases |
| 292 | // that could cause panics like "index out of range [0] with length 0" |
| 293 | func TestHandleStrReplaceEdgeCases(t *testing.T) { |
| 294 | // The issue was with strings.Split returning an empty slice when the separator wasn't found |
| 295 | // This test directly tests the internal implementation with conditions that might cause this |
| 296 | |
| 297 | // Create a test file with empty content |
| 298 | emptyFile := setupTestFile(t, "") |
| 299 | |
| 300 | // Test with empty file content and arbitrary oldStr |
| 301 | _, err := handleStrReplace(emptyFile, "some string that doesn't exist", "new content") |
| 302 | if err == nil { |
| 303 | t.Fatal("Expected error for empty file but got none") |
| 304 | } |
| 305 | if !strings.Contains(err.Error(), "did not appear verbatim") { |
| 306 | t.Errorf("Expected error message to indicate missing string, got: %s", err.Error()) |
| 307 | } |
| 308 | |
| 309 | // Create a file with content that doesn't match oldStr |
| 310 | nonMatchingFile := setupTestFile(t, "This is some content\nthat doesn't contain the target string") |
| 311 | |
| 312 | // Test with content that doesn't contain oldStr |
| 313 | _, err = handleStrReplace(nonMatchingFile, "target string not present", "replacement") |
| 314 | if err == nil { |
| 315 | t.Fatal("Expected error for non-matching content but got none") |
| 316 | } |
| 317 | if !strings.Contains(err.Error(), "did not appear verbatim") { |
| 318 | t.Errorf("Expected error message to indicate missing string, got: %s", err.Error()) |
| 319 | } |
| 320 | |
| 321 | // Test handling of the edge case that could potentially cause the "index out of range" panic |
| 322 | // This directly verifies that the handleStrReplace function properly handles the case where |
| 323 | // strings.Split returns an empty or unexpected result |
| 324 | |
| 325 | // Verify that the protection against empty parts slice works |
| 326 | fileContent := "" |
| 327 | oldStr := "some string" |
| 328 | parts := strings.Split(fileContent, oldStr) |
| 329 | if len(parts) == 0 { |
| 330 | // This should match the protection in the code |
| 331 | parts = []string{""} |
| 332 | } |
| 333 | |
| 334 | // This should not panic with the fix in place |
| 335 | _ = strings.Count(parts[0], "\n") // This line would have panicked without the fix |
| 336 | } |
| 337 | |
| 338 | // TestViewRangeWithStrReplace tests that the view_range parameter works correctly |
| 339 | // with the str_replace command (tests the full workflow) |
| 340 | func TestViewRangeWithStrReplace(t *testing.T) { |
| 341 | // Create test file with multiple lines |
| 342 | content := "Line 1: First line\nLine 2: Second line\nLine 3: Third line\nLine 4: Fourth line\nLine 5: Fifth line" |
| 343 | testFile := setupTestFile(t, content) |
| 344 | |
| 345 | // First view a subset of the file using view_range |
| 346 | viewResult := callEditTool(t, map[string]any{ |
| 347 | "command": "view", |
| 348 | "path": testFile, |
| 349 | "view_range": []int{2, 4}, // Only lines 2-4 |
| 350 | }) |
| 351 | |
| 352 | // Verify that we only see the specified lines |
| 353 | if strings.Contains(viewResult, "Line 1:") || strings.Contains(viewResult, "Line 5:") { |
| 354 | t.Errorf("View with range should only show lines 2-4, got: %s", viewResult) |
| 355 | } |
| 356 | if !strings.Contains(viewResult, "Line 2:") || !strings.Contains(viewResult, "Line 4:") { |
| 357 | t.Errorf("View with range should show lines 2-4, got: %s", viewResult) |
| 358 | } |
| 359 | |
| 360 | // Now perform a str_replace on one of the lines we viewed |
| 361 | replaceResult := callEditTool(t, map[string]any{ |
| 362 | "command": "str_replace", |
| 363 | "path": testFile, |
| 364 | "old_str": "Line 3: Third line", |
| 365 | "new_str": "Line 3: MODIFIED Third line", |
| 366 | }) |
| 367 | |
| 368 | // Check that the replacement was successful |
| 369 | if !strings.Contains(replaceResult, "Line 3: MODIFIED Third line") { |
| 370 | t.Errorf("Replace result should contain the modified line, got: %s", replaceResult) |
| 371 | } |
| 372 | |
| 373 | // Verify the file content was updated correctly |
| 374 | modifiedContent, err := os.ReadFile(testFile) |
| 375 | if err != nil { |
| 376 | t.Fatalf("Failed to read test file after modification: %v", err) |
| 377 | } |
| 378 | |
| 379 | expectedContent := "Line 1: First line\nLine 2: Second line\nLine 3: MODIFIED Third line\nLine 4: Fourth line\nLine 5: Fifth line" |
| 380 | if string(modifiedContent) != expectedContent { |
| 381 | t.Errorf("File content after replacement is incorrect.\nExpected:\n%s\nGot:\n%s", |
| 382 | expectedContent, string(modifiedContent)) |
| 383 | } |
| 384 | |
| 385 | // View the modified file with a different view_range |
| 386 | finalViewResult := callEditTool(t, map[string]any{ |
| 387 | "command": "view", |
| 388 | "path": testFile, |
| 389 | "view_range": []int{3, 3}, // Only the modified line |
| 390 | }) |
| 391 | |
| 392 | // Verify we can see only the modified line |
| 393 | if !strings.Contains(finalViewResult, "Line 3: MODIFIED Third line") { |
| 394 | t.Errorf("Final view should show the modified line, got: %s", finalViewResult) |
| 395 | } |
| 396 | if strings.Contains(finalViewResult, "Line 2:") || strings.Contains(finalViewResult, "Line 4:") { |
| 397 | t.Errorf("Final view should only show line 3, got: %s", finalViewResult) |
| 398 | } |
| 399 | } |