1package claudetool
2
3import (
4 "context"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "shelley.exe.dev/llm"
12)
13
14func TestPatchTool_BasicOperations(t *testing.T) {
15 tempDir := t.TempDir()
16 patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
17 ctx := context.Background()
18
19 // Test overwrite operation (creates new file)
20 testFile := filepath.Join(tempDir, "test.txt")
21 input := PatchInput{
22 Path: testFile,
23 Patches: []PatchRequest{{
24 Operation: "overwrite",
25 NewText: "Hello World\n",
26 }},
27 }
28
29 msg, _ := json.Marshal(input)
30 result := patch.Run(ctx, msg)
31 if result.Error != nil {
32 t.Fatalf("overwrite failed: %v", result.Error)
33 }
34
35 content, err := os.ReadFile(testFile)
36 if err != nil {
37 t.Fatalf("failed to read file: %v", err)
38 }
39 if string(content) != "Hello World\n" {
40 t.Errorf("expected 'Hello World\\n', got %q", string(content))
41 }
42
43 // Test replace operation
44 input.Patches = []PatchRequest{{
45 Operation: "replace",
46 OldText: "World",
47 NewText: "Patch",
48 }}
49
50 msg, _ = json.Marshal(input)
51 result = patch.Run(ctx, msg)
52 if result.Error != nil {
53 t.Fatalf("replace failed: %v", result.Error)
54 }
55
56 content, _ = os.ReadFile(testFile)
57 if string(content) != "Hello Patch\n" {
58 t.Errorf("expected 'Hello Patch\\n', got %q", string(content))
59 }
60
61 // Test append_eof operation
62 input.Patches = []PatchRequest{{
63 Operation: "append_eof",
64 NewText: "Appended line\n",
65 }}
66
67 msg, _ = json.Marshal(input)
68 result = patch.Run(ctx, msg)
69 if result.Error != nil {
70 t.Fatalf("append_eof failed: %v", result.Error)
71 }
72
73 content, _ = os.ReadFile(testFile)
74 expected := "Hello Patch\nAppended line\n"
75 if string(content) != expected {
76 t.Errorf("expected %q, got %q", expected, string(content))
77 }
78
79 // Test prepend_bof operation
80 input.Patches = []PatchRequest{{
81 Operation: "prepend_bof",
82 NewText: "Prepended line\n",
83 }}
84
85 msg, _ = json.Marshal(input)
86 result = patch.Run(ctx, msg)
87 if result.Error != nil {
88 t.Fatalf("prepend_bof failed: %v", result.Error)
89 }
90
91 content, _ = os.ReadFile(testFile)
92 expected = "Prepended line\nHello Patch\nAppended line\n"
93 if string(content) != expected {
94 t.Errorf("expected %q, got %q", expected, string(content))
95 }
96}
97
98func TestPatchTool_ClipboardOperations(t *testing.T) {
99 tempDir := t.TempDir()
100 patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
101 ctx := context.Background()
102
103 testFile := filepath.Join(tempDir, "clipboard.txt")
104
105 // Create initial content
106 input := PatchInput{
107 Path: testFile,
108 Patches: []PatchRequest{{
109 Operation: "overwrite",
110 NewText: "function original() {\n return 'original';\n}\n",
111 }},
112 }
113
114 msg, _ := json.Marshal(input)
115 result := patch.Run(ctx, msg)
116 if result.Error != nil {
117 t.Fatalf("initial overwrite failed: %v", result.Error)
118 }
119
120 // Test toClipboard operation
121 input.Patches = []PatchRequest{{
122 Operation: "replace",
123 OldText: "function original() {\n return 'original';\n}",
124 NewText: "function renamed() {\n return 'renamed';\n}",
125 ToClipboard: "saved_func",
126 }}
127
128 msg, _ = json.Marshal(input)
129 result = patch.Run(ctx, msg)
130 if result.Error != nil {
131 t.Fatalf("toClipboard failed: %v", result.Error)
132 }
133
134 // Test fromClipboard operation
135 input.Patches = []PatchRequest{{
136 Operation: "append_eof",
137 FromClipboard: "saved_func",
138 }}
139
140 msg, _ = json.Marshal(input)
141 result = patch.Run(ctx, msg)
142 if result.Error != nil {
143 t.Fatalf("fromClipboard failed: %v", result.Error)
144 }
145
146 content, _ := os.ReadFile(testFile)
147 if !strings.Contains(string(content), "function original()") {
148 t.Error("clipboard content not restored properly")
149 }
150}
151
152func TestPatchTool_IndentationAdjustment(t *testing.T) {
153 tempDir := t.TempDir()
154 patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
155 ctx := context.Background()
156
157 testFile := filepath.Join(tempDir, "indent.go")
158
159 // Create file with tab indentation
160 input := PatchInput{
161 Path: testFile,
162 Patches: []PatchRequest{{
163 Operation: "overwrite",
164 NewText: "package main\n\nfunc main() {\n\tif true {\n\t\t// placeholder\n\t}\n}\n",
165 }},
166 }
167
168 msg, _ := json.Marshal(input)
169 result := patch.Run(ctx, msg)
170 if result.Error != nil {
171 t.Fatalf("initial setup failed: %v", result.Error)
172 }
173
174 // Test indentation adjustment: convert spaces to tabs
175 input.Patches = []PatchRequest{{
176 Operation: "replace",
177 OldText: "// placeholder",
178 NewText: " fmt.Println(\"hello\")\n fmt.Println(\"world\")",
179 Reindent: &Reindent{
180 Strip: " ",
181 Add: "\t\t",
182 },
183 }}
184
185 msg, _ = json.Marshal(input)
186 result = patch.Run(ctx, msg)
187 if result.Error != nil {
188 t.Fatalf("indentation adjustment failed: %v", result.Error)
189 }
190
191 content, _ := os.ReadFile(testFile)
192 expected := "\t\tfmt.Println(\"hello\")\n\t\tfmt.Println(\"world\")"
193 if !strings.Contains(string(content), expected) {
194 t.Errorf("indentation not adjusted correctly, got:\n%s", string(content))
195 }
196}
197
198func TestPatchTool_FuzzyMatching(t *testing.T) {
199 tempDir := t.TempDir()
200 patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
201 ctx := context.Background()
202
203 testFile := filepath.Join(tempDir, "fuzzy.go")
204
205 // Create Go file with specific indentation
206 input := PatchInput{
207 Path: testFile,
208 Patches: []PatchRequest{{
209 Operation: "overwrite",
210 NewText: "package main\n\nfunc test() {\n\tif condition {\n\t\tfmt.Println(\"hello\")\n\t\tfmt.Println(\"world\")\n\t}\n}\n",
211 }},
212 }
213
214 msg, _ := json.Marshal(input)
215 result := patch.Run(ctx, msg)
216 if result.Error != nil {
217 t.Fatalf("initial setup failed: %v", result.Error)
218 }
219
220 // Test fuzzy matching with different whitespace
221 input.Patches = []PatchRequest{{
222 Operation: "replace",
223 OldText: "if condition {\n fmt.Println(\"hello\")\n fmt.Println(\"world\")\n }", // spaces instead of tabs
224 NewText: "if condition {\n\t\tfmt.Println(\"modified\")\n\t}",
225 }}
226
227 msg, _ = json.Marshal(input)
228 result = patch.Run(ctx, msg)
229 if result.Error != nil {
230 t.Fatalf("fuzzy matching failed: %v", result.Error)
231 }
232
233 content, _ := os.ReadFile(testFile)
234 if !strings.Contains(string(content), "modified") {
235 t.Error("fuzzy matching did not work")
236 }
237}
238
239func TestPatchTool_ErrorCases(t *testing.T) {
240 tempDir := t.TempDir()
241 patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
242 ctx := context.Background()
243
244 testFile := filepath.Join(tempDir, "error.txt")
245
246 // Test replace operation on non-existent file
247 input := PatchInput{
248 Path: testFile,
249 Patches: []PatchRequest{{
250 Operation: "replace",
251 OldText: "something",
252 NewText: "else",
253 }},
254 }
255
256 msg, _ := json.Marshal(input)
257 result := patch.Run(ctx, msg)
258 if result.Error == nil {
259 t.Error("expected error for replace on non-existent file")
260 }
261
262 // Create file with duplicate text
263 input.Patches = []PatchRequest{{
264 Operation: "overwrite",
265 NewText: "duplicate\nduplicate\n",
266 }}
267
268 msg, _ = json.Marshal(input)
269 result = patch.Run(ctx, msg)
270 if result.Error != nil {
271 t.Fatalf("failed to create test file: %v", result.Error)
272 }
273
274 // Test non-unique text
275 input.Patches = []PatchRequest{{
276 Operation: "replace",
277 OldText: "duplicate",
278 NewText: "unique",
279 }}
280
281 msg, _ = json.Marshal(input)
282 result = patch.Run(ctx, msg)
283 if result.Error == nil || !strings.Contains(result.Error.Error(), "not unique") {
284 t.Error("expected non-unique error")
285 }
286
287 // Test missing text
288 input.Patches = []PatchRequest{{
289 Operation: "replace",
290 OldText: "nonexistent",
291 NewText: "something",
292 }}
293
294 msg, _ = json.Marshal(input)
295 result = patch.Run(ctx, msg)
296 if result.Error == nil || !strings.Contains(result.Error.Error(), "not found") {
297 t.Error("expected not found error")
298 }
299
300 // Test invalid clipboard reference
301 input.Patches = []PatchRequest{{
302 Operation: "append_eof",
303 FromClipboard: "nonexistent",
304 }}
305
306 msg, _ = json.Marshal(input)
307 result = patch.Run(ctx, msg)
308 if result.Error == nil || !strings.Contains(result.Error.Error(), "clipboard") {
309 t.Error("expected clipboard error")
310 }
311
312 // Test missing patches field (simulates truncated LLM response)
313 msg = json.RawMessage(`{"path":"server/dashboard.go"}`)
314 result = patch.Run(ctx, msg)
315 if result.Error == nil {
316 t.Error("expected error for missing patches field")
317 }
318 if !strings.Contains(result.Error.Error(), "missing or empty") {
319 t.Errorf("expected 'missing or empty' in error, got: %v", result.Error)
320 }
321
322 // Test empty patches array
323 msg = json.RawMessage(`{"path":"server/dashboard.go","patches":[]}`)
324 result = patch.Run(ctx, msg)
325 if result.Error == nil {
326 t.Error("expected error for empty patches array")
327 }
328}
329
330func TestPatchTool_FlexibleInputParsing(t *testing.T) {
331 tempDir := t.TempDir()
332 patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
333 ctx := context.Background()
334
335 testFile := filepath.Join(tempDir, "flexible.txt")
336
337 // Test single patch format (PatchInputOne)
338 inputOne := PatchInputOne{
339 Path: testFile,
340 Patches: &PatchRequest{
341 Operation: "overwrite",
342 NewText: "Single patch format\n",
343 },
344 }
345
346 msg, _ := json.Marshal(inputOne)
347 result := patch.Run(ctx, msg)
348 if result.Error != nil {
349 t.Fatalf("single patch format failed: %v", result.Error)
350 }
351
352 content, _ := os.ReadFile(testFile)
353 if string(content) != "Single patch format\n" {
354 t.Error("single patch format did not work")
355 }
356
357 // Test string patch format (PatchInputOneString)
358 patchStr := `{"operation": "replace", "oldText": "Single", "newText": "Modified"}`
359 inputStr := PatchInputOneString{
360 Path: testFile,
361 Patches: patchStr,
362 }
363
364 msg, _ = json.Marshal(inputStr)
365 result = patch.Run(ctx, msg)
366 if result.Error != nil {
367 t.Fatalf("string patch format failed: %v", result.Error)
368 }
369
370 content, _ = os.ReadFile(testFile)
371 if !strings.Contains(string(content), "Modified") {
372 t.Error("string patch format did not work")
373 }
374}
375
376func TestPatchTool_AutogeneratedDetection(t *testing.T) {
377 tempDir := t.TempDir()
378 patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
379 ctx := context.Background()
380
381 testFile := filepath.Join(tempDir, "generated.go")
382
383 // Create autogenerated file
384 input := PatchInput{
385 Path: testFile,
386 Patches: []PatchRequest{{
387 Operation: "overwrite",
388 NewText: "// Code generated by tool. DO NOT EDIT.\npackage main\n\nfunc generated() {}\n",
389 }},
390 }
391
392 msg, _ := json.Marshal(input)
393 result := patch.Run(ctx, msg)
394 if result.Error != nil {
395 t.Fatalf("failed to create generated file: %v", result.Error)
396 }
397
398 // Test patching autogenerated file (should warn but work)
399 input.Patches = []PatchRequest{{
400 Operation: "replace",
401 OldText: "func generated() {}",
402 NewText: "func modified() {}",
403 }}
404
405 msg, _ = json.Marshal(input)
406 result = patch.Run(ctx, msg)
407 if result.Error != nil {
408 t.Fatalf("patching generated file failed: %v", result.Error)
409 }
410
411 if len(result.LLMContent) == 0 || !strings.Contains(result.LLMContent[0].Text, "autogenerated") {
412 t.Error("expected autogenerated warning")
413 }
414}
415
416func TestPatchTool_MultiplePatches(t *testing.T) {
417 tempDir := t.TempDir()
418 patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
419 ctx := context.Background()
420
421 testFile := filepath.Join(tempDir, "multi.go")
422 var msg []byte
423 var result llm.ToolOut
424
425 // Apply multiple patches - first create file, then modify
426 input := PatchInput{
427 Path: testFile,
428 Patches: []PatchRequest{{
429 Operation: "overwrite",
430 NewText: "package main\n\nfunc first() {\n\tprintln(\"first\")\n}\n\nfunc second() {\n\tprintln(\"second\")\n}\n",
431 }},
432 }
433
434 msg, _ = json.Marshal(input)
435 result = patch.Run(ctx, msg)
436 if result.Error != nil {
437 t.Fatalf("failed to create initial file: %v", result.Error)
438 }
439
440 // Now apply multiple patches in one call
441 input.Patches = []PatchRequest{
442 {
443 Operation: "replace",
444 OldText: "println(\"first\")",
445 NewText: "println(\"ONE\")",
446 },
447 {
448 Operation: "replace",
449 OldText: "println(\"second\")",
450 NewText: "println(\"TWO\")",
451 },
452 {
453 Operation: "append_eof",
454 NewText: "\n// Multiple patches applied\n",
455 },
456 }
457
458 msg, _ = json.Marshal(input)
459 result = patch.Run(ctx, msg)
460 if result.Error != nil {
461 t.Fatalf("multiple patches failed: %v", result.Error)
462 }
463
464 content, _ := os.ReadFile(testFile)
465 contentStr := string(content)
466 if !strings.Contains(contentStr, "ONE") || !strings.Contains(contentStr, "TWO") {
467 t.Error("multiple patches not applied correctly")
468 }
469 if !strings.Contains(contentStr, "Multiple patches applied") {
470 t.Error("append_eof in multiple patches not applied")
471 }
472}
473
474func TestPatchTool_CopyRecipe(t *testing.T) {
475 tempDir := t.TempDir()
476 patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
477 ctx := context.Background()
478
479 testFile := filepath.Join(tempDir, "copy.txt")
480
481 // Create initial content
482 input := PatchInput{
483 Path: testFile,
484 Patches: []PatchRequest{{
485 Operation: "overwrite",
486 NewText: "original text",
487 }},
488 }
489
490 msg, _ := json.Marshal(input)
491 result := patch.Run(ctx, msg)
492 if result.Error != nil {
493 t.Fatalf("failed to create file: %v", result.Error)
494 }
495
496 // Test copy recipe (toClipboard + fromClipboard with same name)
497 input.Patches = []PatchRequest{{
498 Operation: "replace",
499 OldText: "original text",
500 NewText: "replaced text",
501 ToClipboard: "copy_test",
502 FromClipboard: "copy_test",
503 }}
504
505 msg, _ = json.Marshal(input)
506 result = patch.Run(ctx, msg)
507 if result.Error != nil {
508 t.Fatalf("copy recipe failed: %v", result.Error)
509 }
510
511 content, _ := os.ReadFile(testFile)
512 // The copy recipe should preserve the original text
513 if string(content) != "original text" {
514 t.Errorf("copy recipe failed, expected 'original text', got %q", string(content))
515 }
516}
517
518func TestPatchTool_RelativePaths(t *testing.T) {
519 tempDir := t.TempDir()
520 patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
521 ctx := context.Background()
522
523 // Test relative path resolution
524 input := PatchInput{
525 Path: "relative.txt", // relative path
526 Patches: []PatchRequest{{
527 Operation: "overwrite",
528 NewText: "relative path test\n",
529 }},
530 }
531
532 msg, _ := json.Marshal(input)
533 result := patch.Run(ctx, msg)
534 if result.Error != nil {
535 t.Fatalf("relative path failed: %v", result.Error)
536 }
537
538 // Check file was created in correct location
539 expectedPath := filepath.Join(tempDir, "relative.txt")
540 content, err := os.ReadFile(expectedPath)
541 if err != nil {
542 t.Fatalf("file not created at expected path: %v", err)
543 }
544 if string(content) != "relative path test\n" {
545 t.Error("relative path file content incorrect")
546 }
547}
548
549// Benchmark basic patch operations
550func BenchmarkPatchTool_BasicOperations(b *testing.B) {
551 tempDir := b.TempDir()
552 patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
553 ctx := context.Background()
554
555 testFile := filepath.Join(tempDir, "bench.go")
556 initialContent := "package main\n\nfunc test() {\n\tfor i := 0; i < 100; i++ {\n\t\tfmt.Println(i)\n\t}\n}\n"
557
558 // Setup
559 input := PatchInput{
560 Path: testFile,
561 Patches: []PatchRequest{{
562 Operation: "overwrite",
563 NewText: initialContent,
564 }},
565 }
566 msg, _ := json.Marshal(input)
567 patch.Run(ctx, msg)
568
569 b.ResetTimer()
570 for i := 0; i < b.N; i++ {
571 // Benchmark replace operation
572 input.Patches = []PatchRequest{{
573 Operation: "replace",
574 OldText: "fmt.Println(i)",
575 NewText: "fmt.Printf(\"%d\\n\", i)",
576 }}
577
578 msg, _ := json.Marshal(input)
579 result := patch.Run(ctx, msg)
580 if result.Error != nil {
581 b.Fatalf("benchmark failed: %v", result.Error)
582 }
583
584 // Reset for next iteration
585 input.Patches = []PatchRequest{{
586 Operation: "replace",
587 OldText: "fmt.Printf(\"%d\\n\", i)",
588 NewText: "fmt.Println(i)",
589 }}
590 msg, _ = json.Marshal(input)
591 patch.Run(ctx, msg)
592 }
593}
594
595func TestPatchTool_CallbackFunction(t *testing.T) {
596 tempDir := t.TempDir()
597 callbackCalled := false
598 var capturedInput PatchInput
599 var capturedOutput llm.ToolOut
600
601 patch := &PatchTool{
602 WorkingDir: NewMutableWorkingDir(tempDir),
603 Callback: func(input PatchInput, output llm.ToolOut) llm.ToolOut {
604 callbackCalled = true
605 capturedInput = input
606 capturedOutput = output
607 // Modify the output
608 output.LLMContent = llm.TextContent("Modified by callback")
609 return output
610 },
611 }
612
613 ctx := context.Background()
614 testFile := filepath.Join(tempDir, "callback.txt")
615
616 input := PatchInput{
617 Path: testFile,
618 Patches: []PatchRequest{{
619 Operation: "overwrite",
620 NewText: "callback test",
621 }},
622 }
623
624 msg, _ := json.Marshal(input)
625 result := patch.Run(ctx, msg)
626
627 if !callbackCalled {
628 t.Error("callback was not called")
629 }
630
631 if capturedInput.Path != testFile {
632 t.Error("callback did not receive correct input")
633 }
634
635 if len(result.LLMContent) == 0 || result.LLMContent[0].Text != "Modified by callback" {
636 t.Error("callback did not modify output correctly")
637 }
638
639 if capturedOutput.Error != nil {
640 t.Errorf("callback received error: %v", capturedOutput.Error)
641 }
642}