| package claudetool |
| |
| import ( |
| "context" |
| "encoding/json" |
| "os" |
| "path/filepath" |
| "strings" |
| "testing" |
| |
| "sketch.dev/llm" |
| ) |
| |
| func TestPatchTool_BasicOperations(t *testing.T) { |
| tempDir := t.TempDir() |
| patch := &PatchTool{Pwd: tempDir} |
| ctx := context.Background() |
| |
| // Test overwrite operation (creates new file) |
| testFile := filepath.Join(tempDir, "test.txt") |
| input := PatchInput{ |
| Path: testFile, |
| Patches: []PatchRequest{{ |
| Operation: "overwrite", |
| NewText: "Hello World\n", |
| }}, |
| } |
| |
| msg, _ := json.Marshal(input) |
| result := patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("overwrite failed: %v", result.Error) |
| } |
| |
| content, err := os.ReadFile(testFile) |
| if err != nil { |
| t.Fatalf("failed to read file: %v", err) |
| } |
| if string(content) != "Hello World\n" { |
| t.Errorf("expected 'Hello World\\n', got %q", string(content)) |
| } |
| |
| // Test replace operation |
| input.Patches = []PatchRequest{{ |
| Operation: "replace", |
| OldText: "World", |
| NewText: "Patch", |
| }} |
| |
| msg, _ = json.Marshal(input) |
| result = patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("replace failed: %v", result.Error) |
| } |
| |
| content, _ = os.ReadFile(testFile) |
| if string(content) != "Hello Patch\n" { |
| t.Errorf("expected 'Hello Patch\\n', got %q", string(content)) |
| } |
| |
| // Test append_eof operation |
| input.Patches = []PatchRequest{{ |
| Operation: "append_eof", |
| NewText: "Appended line\n", |
| }} |
| |
| msg, _ = json.Marshal(input) |
| result = patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("append_eof failed: %v", result.Error) |
| } |
| |
| content, _ = os.ReadFile(testFile) |
| expected := "Hello Patch\nAppended line\n" |
| if string(content) != expected { |
| t.Errorf("expected %q, got %q", expected, string(content)) |
| } |
| |
| // Test prepend_bof operation |
| input.Patches = []PatchRequest{{ |
| Operation: "prepend_bof", |
| NewText: "Prepended line\n", |
| }} |
| |
| msg, _ = json.Marshal(input) |
| result = patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("prepend_bof failed: %v", result.Error) |
| } |
| |
| content, _ = os.ReadFile(testFile) |
| expected = "Prepended line\nHello Patch\nAppended line\n" |
| if string(content) != expected { |
| t.Errorf("expected %q, got %q", expected, string(content)) |
| } |
| } |
| |
| func TestPatchTool_ClipboardOperations(t *testing.T) { |
| tempDir := t.TempDir() |
| patch := &PatchTool{Pwd: tempDir} |
| ctx := context.Background() |
| |
| testFile := filepath.Join(tempDir, "clipboard.txt") |
| |
| // Create initial content |
| input := PatchInput{ |
| Path: testFile, |
| Patches: []PatchRequest{{ |
| Operation: "overwrite", |
| NewText: "function original() {\n return 'original';\n}\n", |
| }}, |
| } |
| |
| msg, _ := json.Marshal(input) |
| result := patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("initial overwrite failed: %v", result.Error) |
| } |
| |
| // Test toClipboard operation |
| input.Patches = []PatchRequest{{ |
| Operation: "replace", |
| OldText: "function original() {\n return 'original';\n}", |
| NewText: "function renamed() {\n return 'renamed';\n}", |
| ToClipboard: "saved_func", |
| }} |
| |
| msg, _ = json.Marshal(input) |
| result = patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("toClipboard failed: %v", result.Error) |
| } |
| |
| // Test fromClipboard operation |
| input.Patches = []PatchRequest{{ |
| Operation: "append_eof", |
| FromClipboard: "saved_func", |
| }} |
| |
| msg, _ = json.Marshal(input) |
| result = patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("fromClipboard failed: %v", result.Error) |
| } |
| |
| content, _ := os.ReadFile(testFile) |
| if !strings.Contains(string(content), "function original()") { |
| t.Error("clipboard content not restored properly") |
| } |
| } |
| |
| func TestPatchTool_IndentationAdjustment(t *testing.T) { |
| tempDir := t.TempDir() |
| patch := &PatchTool{Pwd: tempDir} |
| ctx := context.Background() |
| |
| testFile := filepath.Join(tempDir, "indent.go") |
| |
| // Create file with tab indentation |
| input := PatchInput{ |
| Path: testFile, |
| Patches: []PatchRequest{{ |
| Operation: "overwrite", |
| NewText: "package main\n\nfunc main() {\n\tif true {\n\t\t// placeholder\n\t}\n}\n", |
| }}, |
| } |
| |
| msg, _ := json.Marshal(input) |
| result := patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("initial setup failed: %v", result.Error) |
| } |
| |
| // Test indentation adjustment: convert spaces to tabs |
| input.Patches = []PatchRequest{{ |
| Operation: "replace", |
| OldText: "// placeholder", |
| NewText: " fmt.Println(\"hello\")\n fmt.Println(\"world\")", |
| Reindent: &Reindent{ |
| Strip: " ", |
| Add: "\t\t", |
| }, |
| }} |
| |
| msg, _ = json.Marshal(input) |
| result = patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("indentation adjustment failed: %v", result.Error) |
| } |
| |
| content, _ := os.ReadFile(testFile) |
| expected := "\t\tfmt.Println(\"hello\")\n\t\tfmt.Println(\"world\")" |
| if !strings.Contains(string(content), expected) { |
| t.Errorf("indentation not adjusted correctly, got:\n%s", string(content)) |
| } |
| } |
| |
| func TestPatchTool_FuzzyMatching(t *testing.T) { |
| tempDir := t.TempDir() |
| patch := &PatchTool{Pwd: tempDir} |
| ctx := context.Background() |
| |
| testFile := filepath.Join(tempDir, "fuzzy.go") |
| |
| // Create Go file with specific indentation |
| input := PatchInput{ |
| Path: testFile, |
| Patches: []PatchRequest{{ |
| Operation: "overwrite", |
| NewText: "package main\n\nfunc test() {\n\tif condition {\n\t\tfmt.Println(\"hello\")\n\t\tfmt.Println(\"world\")\n\t}\n}\n", |
| }}, |
| } |
| |
| msg, _ := json.Marshal(input) |
| result := patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("initial setup failed: %v", result.Error) |
| } |
| |
| // Test fuzzy matching with different whitespace |
| input.Patches = []PatchRequest{{ |
| Operation: "replace", |
| OldText: "if condition {\n fmt.Println(\"hello\")\n fmt.Println(\"world\")\n }", // spaces instead of tabs |
| NewText: "if condition {\n\t\tfmt.Println(\"modified\")\n\t}", |
| }} |
| |
| msg, _ = json.Marshal(input) |
| result = patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("fuzzy matching failed: %v", result.Error) |
| } |
| |
| content, _ := os.ReadFile(testFile) |
| if !strings.Contains(string(content), "modified") { |
| t.Error("fuzzy matching did not work") |
| } |
| } |
| |
| func TestPatchTool_ErrorCases(t *testing.T) { |
| tempDir := t.TempDir() |
| patch := &PatchTool{Pwd: tempDir} |
| ctx := context.Background() |
| |
| testFile := filepath.Join(tempDir, "error.txt") |
| |
| // Test replace operation on non-existent file |
| input := PatchInput{ |
| Path: testFile, |
| Patches: []PatchRequest{{ |
| Operation: "replace", |
| OldText: "something", |
| NewText: "else", |
| }}, |
| } |
| |
| msg, _ := json.Marshal(input) |
| result := patch.Run(ctx, msg) |
| if result.Error == nil { |
| t.Error("expected error for replace on non-existent file") |
| } |
| |
| // Create file with duplicate text |
| input.Patches = []PatchRequest{{ |
| Operation: "overwrite", |
| NewText: "duplicate\nduplicate\n", |
| }} |
| |
| msg, _ = json.Marshal(input) |
| result = patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("failed to create test file: %v", result.Error) |
| } |
| |
| // Test non-unique text |
| input.Patches = []PatchRequest{{ |
| Operation: "replace", |
| OldText: "duplicate", |
| NewText: "unique", |
| }} |
| |
| msg, _ = json.Marshal(input) |
| result = patch.Run(ctx, msg) |
| if result.Error == nil || !strings.Contains(result.Error.Error(), "not unique") { |
| t.Error("expected non-unique error") |
| } |
| |
| // Test missing text |
| input.Patches = []PatchRequest{{ |
| Operation: "replace", |
| OldText: "nonexistent", |
| NewText: "something", |
| }} |
| |
| msg, _ = json.Marshal(input) |
| result = patch.Run(ctx, msg) |
| if result.Error == nil || !strings.Contains(result.Error.Error(), "not found") { |
| t.Error("expected not found error") |
| } |
| |
| // Test invalid clipboard reference |
| input.Patches = []PatchRequest{{ |
| Operation: "append_eof", |
| FromClipboard: "nonexistent", |
| }} |
| |
| msg, _ = json.Marshal(input) |
| result = patch.Run(ctx, msg) |
| if result.Error == nil || !strings.Contains(result.Error.Error(), "clipboard") { |
| t.Error("expected clipboard error") |
| } |
| } |
| |
| func TestPatchTool_FlexibleInputParsing(t *testing.T) { |
| tempDir := t.TempDir() |
| patch := &PatchTool{Pwd: tempDir} |
| ctx := context.Background() |
| |
| testFile := filepath.Join(tempDir, "flexible.txt") |
| |
| // Test single patch format (PatchInputOne) |
| inputOne := PatchInputOne{ |
| Path: testFile, |
| Patches: PatchRequest{ |
| Operation: "overwrite", |
| NewText: "Single patch format\n", |
| }, |
| } |
| |
| msg, _ := json.Marshal(inputOne) |
| result := patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("single patch format failed: %v", result.Error) |
| } |
| |
| content, _ := os.ReadFile(testFile) |
| if string(content) != "Single patch format\n" { |
| t.Error("single patch format did not work") |
| } |
| |
| // Test string patch format (PatchInputOneString) |
| patchStr := `{"operation": "replace", "oldText": "Single", "newText": "Modified"}` |
| inputStr := PatchInputOneString{ |
| Path: testFile, |
| Patches: patchStr, |
| } |
| |
| msg, _ = json.Marshal(inputStr) |
| result = patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("string patch format failed: %v", result.Error) |
| } |
| |
| content, _ = os.ReadFile(testFile) |
| if !strings.Contains(string(content), "Modified") { |
| t.Error("string patch format did not work") |
| } |
| } |
| |
| func TestPatchTool_AutogeneratedDetection(t *testing.T) { |
| tempDir := t.TempDir() |
| patch := &PatchTool{Pwd: tempDir} |
| ctx := context.Background() |
| |
| testFile := filepath.Join(tempDir, "generated.go") |
| |
| // Create autogenerated file |
| input := PatchInput{ |
| Path: testFile, |
| Patches: []PatchRequest{{ |
| Operation: "overwrite", |
| NewText: "// Code generated by tool. DO NOT EDIT.\npackage main\n\nfunc generated() {}\n", |
| }}, |
| } |
| |
| msg, _ := json.Marshal(input) |
| result := patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("failed to create generated file: %v", result.Error) |
| } |
| |
| // Test patching autogenerated file (should warn but work) |
| input.Patches = []PatchRequest{{ |
| Operation: "replace", |
| OldText: "func generated() {}", |
| NewText: "func modified() {}", |
| }} |
| |
| msg, _ = json.Marshal(input) |
| result = patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("patching generated file failed: %v", result.Error) |
| } |
| |
| if len(result.LLMContent) == 0 || !strings.Contains(result.LLMContent[0].Text, "autogenerated") { |
| t.Error("expected autogenerated warning") |
| } |
| } |
| |
| func TestPatchTool_MultiplePatches(t *testing.T) { |
| tempDir := t.TempDir() |
| patch := &PatchTool{Pwd: tempDir} |
| ctx := context.Background() |
| |
| testFile := filepath.Join(tempDir, "multi.go") |
| var msg []byte |
| var result llm.ToolOut |
| |
| // Apply multiple patches - first create file, then modify |
| input := PatchInput{ |
| Path: testFile, |
| Patches: []PatchRequest{{ |
| Operation: "overwrite", |
| NewText: "package main\n\nfunc first() {\n\tprintln(\"first\")\n}\n\nfunc second() {\n\tprintln(\"second\")\n}\n", |
| }}, |
| } |
| |
| msg, _ = json.Marshal(input) |
| result = patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("failed to create initial file: %v", result.Error) |
| } |
| |
| // Now apply multiple patches in one call |
| input.Patches = []PatchRequest{ |
| { |
| Operation: "replace", |
| OldText: "println(\"first\")", |
| NewText: "println(\"ONE\")", |
| }, |
| { |
| Operation: "replace", |
| OldText: "println(\"second\")", |
| NewText: "println(\"TWO\")", |
| }, |
| { |
| Operation: "append_eof", |
| NewText: "\n// Multiple patches applied\n", |
| }, |
| } |
| |
| msg, _ = json.Marshal(input) |
| result = patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("multiple patches failed: %v", result.Error) |
| } |
| |
| content, _ := os.ReadFile(testFile) |
| contentStr := string(content) |
| if !strings.Contains(contentStr, "ONE") || !strings.Contains(contentStr, "TWO") { |
| t.Error("multiple patches not applied correctly") |
| } |
| if !strings.Contains(contentStr, "Multiple patches applied") { |
| t.Error("append_eof in multiple patches not applied") |
| } |
| } |
| |
| func TestPatchTool_CopyRecipe(t *testing.T) { |
| tempDir := t.TempDir() |
| patch := &PatchTool{Pwd: tempDir} |
| ctx := context.Background() |
| |
| testFile := filepath.Join(tempDir, "copy.txt") |
| |
| // Create initial content |
| input := PatchInput{ |
| Path: testFile, |
| Patches: []PatchRequest{{ |
| Operation: "overwrite", |
| NewText: "original text", |
| }}, |
| } |
| |
| msg, _ := json.Marshal(input) |
| result := patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("failed to create file: %v", result.Error) |
| } |
| |
| // Test copy recipe (toClipboard + fromClipboard with same name) |
| input.Patches = []PatchRequest{{ |
| Operation: "replace", |
| OldText: "original text", |
| NewText: "replaced text", |
| ToClipboard: "copy_test", |
| FromClipboard: "copy_test", |
| }} |
| |
| msg, _ = json.Marshal(input) |
| result = patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("copy recipe failed: %v", result.Error) |
| } |
| |
| content, _ := os.ReadFile(testFile) |
| // The copy recipe should preserve the original text |
| if string(content) != "original text" { |
| t.Errorf("copy recipe failed, expected 'original text', got %q", string(content)) |
| } |
| } |
| |
| func TestPatchTool_RelativePaths(t *testing.T) { |
| tempDir := t.TempDir() |
| patch := &PatchTool{Pwd: tempDir} |
| ctx := context.Background() |
| |
| // Test relative path resolution |
| input := PatchInput{ |
| Path: "relative.txt", // relative path |
| Patches: []PatchRequest{{ |
| Operation: "overwrite", |
| NewText: "relative path test\n", |
| }}, |
| } |
| |
| msg, _ := json.Marshal(input) |
| result := patch.Run(ctx, msg) |
| if result.Error != nil { |
| t.Fatalf("relative path failed: %v", result.Error) |
| } |
| |
| // Check file was created in correct location |
| expectedPath := filepath.Join(tempDir, "relative.txt") |
| content, err := os.ReadFile(expectedPath) |
| if err != nil { |
| t.Fatalf("file not created at expected path: %v", err) |
| } |
| if string(content) != "relative path test\n" { |
| t.Error("relative path file content incorrect") |
| } |
| } |
| |
| // Benchmark basic patch operations |
| func BenchmarkPatchTool_BasicOperations(b *testing.B) { |
| tempDir := b.TempDir() |
| patch := &PatchTool{Pwd: tempDir} |
| ctx := context.Background() |
| |
| testFile := filepath.Join(tempDir, "bench.go") |
| initialContent := "package main\n\nfunc test() {\n\tfor i := 0; i < 100; i++ {\n\t\tfmt.Println(i)\n\t}\n}\n" |
| |
| // Setup |
| input := PatchInput{ |
| Path: testFile, |
| Patches: []PatchRequest{{ |
| Operation: "overwrite", |
| NewText: initialContent, |
| }}, |
| } |
| msg, _ := json.Marshal(input) |
| patch.Run(ctx, msg) |
| |
| b.ResetTimer() |
| for i := 0; i < b.N; i++ { |
| // Benchmark replace operation |
| input.Patches = []PatchRequest{{ |
| Operation: "replace", |
| OldText: "fmt.Println(i)", |
| NewText: "fmt.Printf(\"%d\\n\", i)", |
| }} |
| |
| msg, _ := json.Marshal(input) |
| result := patch.Run(ctx, msg) |
| if result.Error != nil { |
| b.Fatalf("benchmark failed: %v", result.Error) |
| } |
| |
| // Reset for next iteration |
| input.Patches = []PatchRequest{{ |
| Operation: "replace", |
| OldText: "fmt.Printf(\"%d\\n\", i)", |
| NewText: "fmt.Println(i)", |
| }} |
| msg, _ = json.Marshal(input) |
| patch.Run(ctx, msg) |
| } |
| } |
| |
| func TestPatchTool_CallbackFunction(t *testing.T) { |
| tempDir := t.TempDir() |
| callbackCalled := false |
| var capturedInput PatchInput |
| var capturedOutput llm.ToolOut |
| |
| patch := &PatchTool{ |
| Pwd: tempDir, |
| Callback: func(input PatchInput, output llm.ToolOut) llm.ToolOut { |
| callbackCalled = true |
| capturedInput = input |
| capturedOutput = output |
| // Modify the output |
| output.LLMContent = llm.TextContent("Modified by callback") |
| return output |
| }, |
| } |
| |
| ctx := context.Background() |
| testFile := filepath.Join(tempDir, "callback.txt") |
| |
| input := PatchInput{ |
| Path: testFile, |
| Patches: []PatchRequest{{ |
| Operation: "overwrite", |
| NewText: "callback test", |
| }}, |
| } |
| |
| msg, _ := json.Marshal(input) |
| result := patch.Run(ctx, msg) |
| |
| if !callbackCalled { |
| t.Error("callback was not called") |
| } |
| |
| if capturedInput.Path != testFile { |
| t.Error("callback did not receive correct input") |
| } |
| |
| if len(result.LLMContent) == 0 || result.LLMContent[0].Text != "Modified by callback" { |
| t.Error("callback did not modify output correctly") |
| } |
| |
| if capturedOutput.Error != nil { |
| t.Errorf("callback received error: %v", capturedOutput.Error) |
| } |
| } |