claudetool: add clipboard support to patch tool
diff --git a/claudetool/patch_test.go b/claudetool/patch_test.go
new file mode 100644
index 0000000..93bbe1c
--- /dev/null
+++ b/claudetool/patch_test.go
@@ -0,0 +1,625 @@
+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)
+ }
+}