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)
+	}
+}