blob: 6a8d090656ba4ae4f79b543343e8844f9075c547 [file] [log] [blame]
Josh Bleecher Snyder7f18fb62025-07-30 18:12:29 -07001package claudetool
2
3import (
4 "context"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "sketch.dev/llm"
12)
13
14func TestPatchTool_BasicOperations(t *testing.T) {
15 tempDir := t.TempDir()
16 patch := &PatchTool{Pwd: 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{Pwd: 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{Pwd: 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{Pwd: 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{Pwd: 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
313func TestPatchTool_FlexibleInputParsing(t *testing.T) {
314 tempDir := t.TempDir()
315 patch := &PatchTool{Pwd: tempDir}
316 ctx := context.Background()
317
318 testFile := filepath.Join(tempDir, "flexible.txt")
319
320 // Test single patch format (PatchInputOne)
321 inputOne := PatchInputOne{
322 Path: testFile,
Josh Bleecher Snyder994e9842025-07-30 20:26:47 -0700323 Patches: &PatchRequest{
Josh Bleecher Snyder7f18fb62025-07-30 18:12:29 -0700324 Operation: "overwrite",
325 NewText: "Single patch format\n",
326 },
327 }
328
329 msg, _ := json.Marshal(inputOne)
330 result := patch.Run(ctx, msg)
331 if result.Error != nil {
332 t.Fatalf("single patch format failed: %v", result.Error)
333 }
334
335 content, _ := os.ReadFile(testFile)
336 if string(content) != "Single patch format\n" {
337 t.Error("single patch format did not work")
338 }
339
340 // Test string patch format (PatchInputOneString)
341 patchStr := `{"operation": "replace", "oldText": "Single", "newText": "Modified"}`
342 inputStr := PatchInputOneString{
343 Path: testFile,
344 Patches: patchStr,
345 }
346
347 msg, _ = json.Marshal(inputStr)
348 result = patch.Run(ctx, msg)
349 if result.Error != nil {
350 t.Fatalf("string patch format failed: %v", result.Error)
351 }
352
353 content, _ = os.ReadFile(testFile)
354 if !strings.Contains(string(content), "Modified") {
355 t.Error("string patch format did not work")
356 }
357}
358
359func TestPatchTool_AutogeneratedDetection(t *testing.T) {
360 tempDir := t.TempDir()
361 patch := &PatchTool{Pwd: tempDir}
362 ctx := context.Background()
363
364 testFile := filepath.Join(tempDir, "generated.go")
365
366 // Create autogenerated file
367 input := PatchInput{
368 Path: testFile,
369 Patches: []PatchRequest{{
370 Operation: "overwrite",
371 NewText: "// Code generated by tool. DO NOT EDIT.\npackage main\n\nfunc generated() {}\n",
372 }},
373 }
374
375 msg, _ := json.Marshal(input)
376 result := patch.Run(ctx, msg)
377 if result.Error != nil {
378 t.Fatalf("failed to create generated file: %v", result.Error)
379 }
380
381 // Test patching autogenerated file (should warn but work)
382 input.Patches = []PatchRequest{{
383 Operation: "replace",
384 OldText: "func generated() {}",
385 NewText: "func modified() {}",
386 }}
387
388 msg, _ = json.Marshal(input)
389 result = patch.Run(ctx, msg)
390 if result.Error != nil {
391 t.Fatalf("patching generated file failed: %v", result.Error)
392 }
393
394 if len(result.LLMContent) == 0 || !strings.Contains(result.LLMContent[0].Text, "autogenerated") {
395 t.Error("expected autogenerated warning")
396 }
397}
398
399func TestPatchTool_MultiplePatches(t *testing.T) {
400 tempDir := t.TempDir()
401 patch := &PatchTool{Pwd: tempDir}
402 ctx := context.Background()
403
404 testFile := filepath.Join(tempDir, "multi.go")
405 var msg []byte
406 var result llm.ToolOut
407
408 // Apply multiple patches - first create file, then modify
409 input := PatchInput{
410 Path: testFile,
411 Patches: []PatchRequest{{
412 Operation: "overwrite",
413 NewText: "package main\n\nfunc first() {\n\tprintln(\"first\")\n}\n\nfunc second() {\n\tprintln(\"second\")\n}\n",
414 }},
415 }
416
417 msg, _ = json.Marshal(input)
418 result = patch.Run(ctx, msg)
419 if result.Error != nil {
420 t.Fatalf("failed to create initial file: %v", result.Error)
421 }
422
423 // Now apply multiple patches in one call
424 input.Patches = []PatchRequest{
425 {
426 Operation: "replace",
427 OldText: "println(\"first\")",
428 NewText: "println(\"ONE\")",
429 },
430 {
431 Operation: "replace",
432 OldText: "println(\"second\")",
433 NewText: "println(\"TWO\")",
434 },
435 {
436 Operation: "append_eof",
437 NewText: "\n// Multiple patches applied\n",
438 },
439 }
440
441 msg, _ = json.Marshal(input)
442 result = patch.Run(ctx, msg)
443 if result.Error != nil {
444 t.Fatalf("multiple patches failed: %v", result.Error)
445 }
446
447 content, _ := os.ReadFile(testFile)
448 contentStr := string(content)
449 if !strings.Contains(contentStr, "ONE") || !strings.Contains(contentStr, "TWO") {
450 t.Error("multiple patches not applied correctly")
451 }
452 if !strings.Contains(contentStr, "Multiple patches applied") {
453 t.Error("append_eof in multiple patches not applied")
454 }
455}
456
457func TestPatchTool_CopyRecipe(t *testing.T) {
458 tempDir := t.TempDir()
459 patch := &PatchTool{Pwd: tempDir}
460 ctx := context.Background()
461
462 testFile := filepath.Join(tempDir, "copy.txt")
463
464 // Create initial content
465 input := PatchInput{
466 Path: testFile,
467 Patches: []PatchRequest{{
468 Operation: "overwrite",
469 NewText: "original text",
470 }},
471 }
472
473 msg, _ := json.Marshal(input)
474 result := patch.Run(ctx, msg)
475 if result.Error != nil {
476 t.Fatalf("failed to create file: %v", result.Error)
477 }
478
479 // Test copy recipe (toClipboard + fromClipboard with same name)
480 input.Patches = []PatchRequest{{
481 Operation: "replace",
482 OldText: "original text",
483 NewText: "replaced text",
484 ToClipboard: "copy_test",
485 FromClipboard: "copy_test",
486 }}
487
488 msg, _ = json.Marshal(input)
489 result = patch.Run(ctx, msg)
490 if result.Error != nil {
491 t.Fatalf("copy recipe failed: %v", result.Error)
492 }
493
494 content, _ := os.ReadFile(testFile)
495 // The copy recipe should preserve the original text
496 if string(content) != "original text" {
497 t.Errorf("copy recipe failed, expected 'original text', got %q", string(content))
498 }
499}
500
501func TestPatchTool_RelativePaths(t *testing.T) {
502 tempDir := t.TempDir()
503 patch := &PatchTool{Pwd: tempDir}
504 ctx := context.Background()
505
506 // Test relative path resolution
507 input := PatchInput{
508 Path: "relative.txt", // relative path
509 Patches: []PatchRequest{{
510 Operation: "overwrite",
511 NewText: "relative path test\n",
512 }},
513 }
514
515 msg, _ := json.Marshal(input)
516 result := patch.Run(ctx, msg)
517 if result.Error != nil {
518 t.Fatalf("relative path failed: %v", result.Error)
519 }
520
521 // Check file was created in correct location
522 expectedPath := filepath.Join(tempDir, "relative.txt")
523 content, err := os.ReadFile(expectedPath)
524 if err != nil {
525 t.Fatalf("file not created at expected path: %v", err)
526 }
527 if string(content) != "relative path test\n" {
528 t.Error("relative path file content incorrect")
529 }
530}
531
532// Benchmark basic patch operations
533func BenchmarkPatchTool_BasicOperations(b *testing.B) {
534 tempDir := b.TempDir()
535 patch := &PatchTool{Pwd: tempDir}
536 ctx := context.Background()
537
538 testFile := filepath.Join(tempDir, "bench.go")
539 initialContent := "package main\n\nfunc test() {\n\tfor i := 0; i < 100; i++ {\n\t\tfmt.Println(i)\n\t}\n}\n"
540
541 // Setup
542 input := PatchInput{
543 Path: testFile,
544 Patches: []PatchRequest{{
545 Operation: "overwrite",
546 NewText: initialContent,
547 }},
548 }
549 msg, _ := json.Marshal(input)
550 patch.Run(ctx, msg)
551
552 b.ResetTimer()
553 for i := 0; i < b.N; i++ {
554 // Benchmark replace operation
555 input.Patches = []PatchRequest{{
556 Operation: "replace",
557 OldText: "fmt.Println(i)",
558 NewText: "fmt.Printf(\"%d\\n\", i)",
559 }}
560
561 msg, _ := json.Marshal(input)
562 result := patch.Run(ctx, msg)
563 if result.Error != nil {
564 b.Fatalf("benchmark failed: %v", result.Error)
565 }
566
567 // Reset for next iteration
568 input.Patches = []PatchRequest{{
569 Operation: "replace",
570 OldText: "fmt.Printf(\"%d\\n\", i)",
571 NewText: "fmt.Println(i)",
572 }}
573 msg, _ = json.Marshal(input)
574 patch.Run(ctx, msg)
575 }
576}
577
578func TestPatchTool_CallbackFunction(t *testing.T) {
579 tempDir := t.TempDir()
580 callbackCalled := false
581 var capturedInput PatchInput
582 var capturedOutput llm.ToolOut
583
584 patch := &PatchTool{
585 Pwd: tempDir,
586 Callback: func(input PatchInput, output llm.ToolOut) llm.ToolOut {
587 callbackCalled = true
588 capturedInput = input
589 capturedOutput = output
590 // Modify the output
591 output.LLMContent = llm.TextContent("Modified by callback")
592 return output
593 },
594 }
595
596 ctx := context.Background()
597 testFile := filepath.Join(tempDir, "callback.txt")
598
599 input := PatchInput{
600 Path: testFile,
601 Patches: []PatchRequest{{
602 Operation: "overwrite",
603 NewText: "callback test",
604 }},
605 }
606
607 msg, _ := json.Marshal(input)
608 result := patch.Run(ctx, msg)
609
610 if !callbackCalled {
611 t.Error("callback was not called")
612 }
613
614 if capturedInput.Path != testFile {
615 t.Error("callback did not receive correct input")
616 }
617
618 if len(result.LLMContent) == 0 || result.LLMContent[0].Text != "Modified by callback" {
619 t.Error("callback did not modify output correctly")
620 }
621
622 if capturedOutput.Error != nil {
623 t.Errorf("callback received error: %v", capturedOutput.Error)
624 }
625}