patch_test.go

  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}