| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "testing" |
| 8 | |
| 9 | "reasonix/internal/diff" |
| 10 | "reasonix/internal/tool" |
| 11 | ) |
| 12 | |
| 13 | // TestWritersImplementPreviewer locks in that every file-writer exposes the |
| 14 | // optional Previewer capability the front-end type-asserts on. A new writer |
| 15 | // that forgets Preview fails here. |
| 16 | func TestWritersImplementPreviewer(t *testing.T) { |
| 17 | for _, tl := range []tool.Tool{writeFile{}, editFile{}, multiEdit{}} { |
| 18 | if _, ok := tl.(tool.Previewer); !ok { |
| 19 | t.Errorf("%s does not implement tool.Previewer", tl.Name()) |
| 20 | } |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | // TestPreviewMatchesExecute is the anti-drift guarantee: for each writer, |
| 25 | // Preview's NewText must equal the bytes Execute actually persists. It runs |
| 26 | // both against an identical starting file so a future change to an Execute body |
| 27 | // that isn't mirrored into Preview fails this test instead of silently making |
| 28 | // the approval card lie. |
| 29 | func TestPreviewMatchesExecute(t *testing.T) { |
| 30 | cases := []struct { |
| 31 | name string |
| 32 | tool tool.Tool |
| 33 | // seed is the file's content before the call ("" means create fresh). |
| 34 | seed string |
| 35 | args func(path string) map[string]any |
| 36 | }{ |
| 37 | { |
| 38 | name: "write_file create", |
| 39 | tool: writeFile{}, |
| 40 | seed: "", |
| 41 | args: func(p string) map[string]any { |
| 42 | return map[string]any{"path": p, "content": "fresh\nfile\n"} |
| 43 | }, |
| 44 | }, |
| 45 | { |
| 46 | name: "write_file overwrite", |
| 47 | tool: writeFile{}, |
| 48 | seed: "old content\n", |
| 49 | args: func(p string) map[string]any { |
| 50 | return map[string]any{"path": p, "content": "new content\n"} |
| 51 | }, |
| 52 | }, |
| 53 | { |
| 54 | name: "edit_file", |
| 55 | tool: editFile{}, |
| 56 | seed: "hello world\n", |
| 57 | args: func(p string) map[string]any { |
| 58 | return map[string]any{"path": p, "old_string": "world", "new_string": "reasonix"} |
| 59 | }, |
| 60 | }, |
| 61 | { |
| 62 | name: "edit_file fuzzy", |
| 63 | tool: editFile{}, |
| 64 | seed: "alpha \nbeta \n", |
| 65 | args: func(p string) map[string]any { |
| 66 | return map[string]any{"path": p, "old_string": "alpha\nbeta", "new_string": "ALPHA\nBETA"} |
| 67 | }, |
| 68 | }, |
| 69 | { |
| 70 | name: "multi_edit", |
| 71 | tool: multiEdit{}, |
| 72 | seed: "package old\n\nfunc old() {\n\told()\n}\n", |
| 73 | args: func(p string) map[string]any { |
| 74 | return map[string]any{"path": p, "edits": []map[string]any{ |
| 75 | {"old_string": "package old", "new_string": "package new"}, |
| 76 | {"old_string": "old", "new_string": "reasonix", "replace_all": true}, |
| 77 | }} |
| 78 | }, |
| 79 | }, |
| 80 | } |
| 81 | |
| 82 | for _, tc := range cases { |
| 83 | t.Run(tc.name, func(t *testing.T) { |
| 84 | // Preview against one copy. |
| 85 | pf := filepath.Join(t.TempDir(), "f.txt") |
| 86 | if tc.seed != "" { |
| 87 | os.WriteFile(pf, []byte(tc.seed), 0o644) |
| 88 | } |
| 89 | prev, ok := tc.tool.(tool.Previewer) |
| 90 | if !ok { |
| 91 | t.Fatalf("%s not a Previewer", tc.tool.Name()) |
| 92 | } |
| 93 | change, err := prev.Preview(argsJSON(t, tc.args(pf))) |
| 94 | if err != nil { |
| 95 | t.Fatalf("Preview: %v", err) |
| 96 | } |
| 97 | // Preview must not have touched disk. |
| 98 | if tc.seed == "" { |
| 99 | if _, statErr := os.Stat(pf); statErr == nil { |
| 100 | t.Fatal("Preview created the file (should be side-effect free)") |
| 101 | } |
| 102 | } else if b, _ := os.ReadFile(pf); string(b) != tc.seed { |
| 103 | t.Fatalf("Preview mutated the file: %q", b) |
| 104 | } |
| 105 | |
| 106 | // Execute against a second identical copy. |
| 107 | ef := filepath.Join(t.TempDir(), "f.txt") |
| 108 | if tc.seed != "" { |
| 109 | os.WriteFile(ef, []byte(tc.seed), 0o644) |
| 110 | } |
| 111 | if _, err := tc.tool.Execute(context.Background(), argsJSON(t, tc.args(ef))); err != nil { |
| 112 | t.Fatalf("Execute: %v", err) |
| 113 | } |
| 114 | got, _ := os.ReadFile(ef) |
| 115 | if string(got) != change.NewText { |
| 116 | t.Fatalf("Preview.NewText != Execute result\n preview: %q\n execute: %q", change.NewText, got) |
| 117 | } |
| 118 | if change.OldText != tc.seed { |
| 119 | t.Fatalf("Preview.OldText = %q, want seed %q", change.OldText, tc.seed) |
| 120 | } |
| 121 | }) |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | // TestPreviewKindAndTally checks the metadata a UI shows: create vs modify and |
| 126 | // the +N/-M tallies. |
| 127 | func TestPreviewKindAndTally(t *testing.T) { |
| 128 | // write_file to a nonexistent path is a create. |
| 129 | nf := filepath.Join(t.TempDir(), "new.txt") |
| 130 | c, err := writeFile{}.Preview(argsJSON(t, map[string]any{"path": nf, "content": "a\nb\nc\n"})) |
| 131 | if err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | if c.Kind != diff.Create { |
| 135 | t.Errorf("kind = %q, want create", c.Kind) |
| 136 | } |
| 137 | if c.Added != 3 || c.Removed != 0 { |
| 138 | t.Errorf("+%d/-%d, want +3/-0", c.Added, c.Removed) |
| 139 | } |
| 140 | |
| 141 | // edit_file on an existing file is a modify with balanced tallies. |
| 142 | ef := filepath.Join(t.TempDir(), "e.txt") |
| 143 | os.WriteFile(ef, []byte("one\ntwo\nthree\n"), 0o644) |
| 144 | c, err = editFile{}.Preview(argsJSON(t, map[string]any{"path": ef, "old_string": "two", "new_string": "TWO"})) |
| 145 | if err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | if c.Kind != diff.Modify { |
| 149 | t.Errorf("kind = %q, want modify", c.Kind) |
| 150 | } |
| 151 | if c.Added != 1 || c.Removed != 1 { |
| 152 | t.Errorf("+%d/-%d, want +1/-1", c.Added, c.Removed) |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | // TestPreviewMirrorsErrors confirms an unworkable call fails in Preview the |
| 157 | // same way it would in Execute — so a UI never previews an impossible change. |
| 158 | func TestPreviewMirrorsErrors(t *testing.T) { |
| 159 | f := filepath.Join(t.TempDir(), "x.txt") |
| 160 | os.WriteFile(f, []byte("x x x"), 0o644) |
| 161 | |
| 162 | if _, err := (editFile{}).Preview(argsJSON(t, map[string]any{"path": f, "old_string": "x", "new_string": "y"})); err == nil { |
| 163 | t.Error("expected not-unique error from Preview") |
| 164 | } |
| 165 | missing := filepath.Join(t.TempDir(), "nope.txt") |
| 166 | if _, err := (editFile{}).Preview(argsJSON(t, map[string]any{"path": missing, "old_string": "a", "new_string": "b"})); err == nil { |
| 167 | t.Error("expected read error for missing file") |
| 168 | } |
| 169 | } |
| 170 |