package tools import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" ) func TestApplyPatchChangesMultipleFiles(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "old.txt"), []byte("before\n"), 0o600); err != nil { t.Fatal(err) } patch := `*** Begin Patch *** Update File: old.txt @@ -before +after *** Add File: new.txt +created *** End Patch` out, err := NewApplyPatchTool(dir).Execute(context.Background(), mustJSON(t, map[string]any{"patch": patch})) if err != nil { t.Fatal(err) } if !strings.Contains(out, "2 file(s) changed") { t.Fatalf("output = %q", out) } assertFileContent(t, filepath.Join(dir, "old.txt"), "after\n") assertFileContent(t, filepath.Join(dir, "new.txt"), "created\n") if info, err := os.Stat(filepath.Join(dir, "old.txt")); err != nil || info.Mode().Perm() != 0o600 { t.Fatalf("updated mode = %v, err = %v", info.Mode().Perm(), err) } } func TestApplyPatchRollsBackEarlierFilesOnFailure(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "first.txt") if err := os.WriteFile(path, []byte("original\n"), 0o644); err != nil { t.Fatal(err) } patch := `*** Begin Patch *** Update File: first.txt @@ -original +changed *** Update File: missing.txt @@ -missing +changed *** End Patch` _, err := NewApplyPatchTool(dir).Execute(context.Background(), mustJSON(t, map[string]any{"patch": patch})) if err == nil { t.Fatal("expected patch failure") } assertFileContent(t, path, "original\n") } func TestApplyPatchDryRunDoesNotWrite(t *testing.T) { dir := t.TempDir() patch := "*** Begin Patch\n*** Add File: dry.txt\n+dry\n*** End Patch" out, err := NewApplyPatchTool(dir).Execute(context.Background(), mustJSON(t, map[string]any{"patch": patch, "dry_run": true})) if err != nil { t.Fatal(err) } if !strings.Contains(out, "dry run") { t.Fatalf("output = %q", out) } if _, err := os.Stat(filepath.Join(dir, "dry.txt")); !os.IsNotExist(err) { t.Fatalf("dry run created file: %v", err) } } func TestApplyPatchRejectsMalformedEnvelopeAndDuplicatePaths(t *testing.T) { for _, patch := range []string{ "*** Begin Patch\n*** Add File: x.txt\n+x", "prefix\n*** Begin Patch\n*** Add File: x.txt\n+x\n*** End Patch", "*** Begin Patch\n*** Add File: x.txt\n+x\n*** Delete File: x.txt\n*** End Patch", } { if _, err := parsePatch(patch); err == nil { t.Fatalf("expected parse failure for %q", patch) } } } func TestResolvePathRejectsSymlinkEscape(t *testing.T) { parent := t.TempDir() base := filepath.Join(parent, "project") outside := filepath.Join(parent, "outside") if err := os.MkdirAll(base, 0o755); err != nil { t.Fatal(err) } if err := os.MkdirAll(outside, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0o644); err != nil { t.Fatal(err) } if err := os.Symlink(outside, filepath.Join(base, "link")); err != nil { t.Skipf("symlinks unavailable: %v", err) } if _, err := resolvePath(base, "link/secret.txt"); err == nil { t.Fatal("expected read symlink escape rejection") } if _, err := resolvePathForWrite(base, "link/new.txt"); err == nil { t.Fatal("expected write symlink escape rejection") } } func TestTodoRejectsInvalidData(t *testing.T) { tool := NewTodoTool() if _, err := tool.Execute(context.Background(), json.RawMessage(`{"action":"create","items":[""]}`)); err == nil { t.Fatal("expected empty task rejection") } if _, err := tool.Execute(context.Background(), json.RawMessage(`{"action":"create","items":["one"]}`)); err != nil { t.Fatal(err) } if _, err := tool.Execute(context.Background(), json.RawMessage(`{"action":"update","task":"one","status":"invalid"}`)); err == nil { t.Fatal("expected invalid status rejection") } } func TestProjectOutlineIncludesGoTypeScriptAndRustSymbols(t *testing.T) { dir := t.TempDir() files := map[string]string{ "main.go": "package demo\nfunc Run() {}\n", "ui.ts": "export class Panel {}\nexport function render() {}\n", "lib.rs": "pub struct Worker;\npub fn execute() {}\n", } for name, content := range files { if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { t.Fatal(err) } } out, err := NewProjectOutlineTool(dir).Execute(context.Background(), json.RawMessage(`{}`)) if err != nil { t.Fatal(err) } for _, want := range []string{"main.go", "Run", "ui.ts", "Panel", "render", "lib.rs", "Worker", "execute"} { if !strings.Contains(out, want) { t.Fatalf("outline missing %q:\n%s", want, out) } } } func TestRollbackStoreRestoresInReverseOrder(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "state.txt") if err := os.WriteFile(path, []byte("one"), 0o644); err != nil { t.Fatal(err) } store := NewRollbackStore() first, err := store.Capture("edit", "state.txt", path) if err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte("two"), 0o644); err != nil { t.Fatal(err) } second, err := store.Capture("edit", "state.txt", path) if err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte("three"), 0o644); err != nil { t.Fatal(err) } if store.LastID() != second { t.Fatalf("last ID = %q", store.LastID()) } if _, err := store.Restore(second); err != nil { t.Fatal(err) } assertFileContent(t, path, "two") if store.LastID() != first { t.Fatalf("last ID after restore = %q, want %q", store.LastID(), first) } } func mustJSON(t *testing.T, value any) json.RawMessage { t.Helper() data, err := json.Marshal(value) if err != nil { t.Fatal(err) } return data } func assertFileContent(t *testing.T, path, want string) { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if string(data) != want { t.Fatalf("%s content = %q, want %q", path, string(data), want) } }