| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | func TestDeleteRangeBasic(t *testing.T) { |
| 12 | f := filepath.Join(t.TempDir(), "a.txt") |
| 13 | body := "line1\nline2\nline3\nline4\nline5\n" |
| 14 | os.WriteFile(f, []byte(body), 0o644) |
| 15 | |
| 16 | out := runTool(t, deleteRange{}, map[string]any{ |
| 17 | "path": f, "start_anchor": "line2", "end_anchor": "line4", |
| 18 | }) |
| 19 | if !strings.Contains(out, "---") || !strings.Contains(out, "+++") { |
| 20 | t.Errorf("expected unified diff output, got: %s", out) |
| 21 | } |
| 22 | got, _ := os.ReadFile(f) |
| 23 | want := "line1\nline5\n" |
| 24 | if string(got) != want { |
| 25 | t.Errorf("file = %q, want %q", got, want) |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | func TestDeleteRangeInclusive(t *testing.T) { |
| 30 | f := filepath.Join(t.TempDir(), "a.txt") |
| 31 | os.WriteFile(f, []byte("line1\nline2\nline3\nline4\nline5\n"), 0o644) |
| 32 | runTool(t, deleteRange{}, map[string]any{ |
| 33 | "path": f, "start_anchor": "line2", "end_anchor": "line4", "inclusive": true, |
| 34 | }) |
| 35 | got, _ := os.ReadFile(f) |
| 36 | if string(got) != "line1\nline5\n" { |
| 37 | t.Errorf("inclusive=true: got %q, want %q", got, "line1\\nline5\\n") |
| 38 | } |
| 39 | |
| 40 | f2 := filepath.Join(t.TempDir(), "b.txt") |
| 41 | os.WriteFile(f2, []byte("line1\nline2\nline3\nline4\nline5\n"), 0o644) |
| 42 | runTool(t, deleteRange{}, map[string]any{ |
| 43 | "path": f2, "start_anchor": "line2", "end_anchor": "line4", "inclusive": false, |
| 44 | }) |
| 45 | got2, _ := os.ReadFile(f2) |
| 46 | if string(got2) != "line1\nline2\nline4\nline5\n" { |
| 47 | t.Errorf("inclusive=false: got %q, want %q", got2, "line1\\nline2\\nline4\\nline5\\n") |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | func TestDeleteRangeDuplicateAnchor(t *testing.T) { |
| 52 | f := filepath.Join(t.TempDir(), "dup.txt") |
| 53 | body := "line1\nline2\nline3\nline2\nline5\n" |
| 54 | os.WriteFile(f, []byte(body), 0o644) |
| 55 | |
| 56 | args := argsJSON(t, map[string]any{ |
| 57 | "path": f, "start_anchor": "line2", "end_anchor": "line5", |
| 58 | }) |
| 59 | _, err := (deleteRange{}).Execute(context.Background(), args) |
| 60 | if err == nil { |
| 61 | t.Fatal("expected duplicate anchor error") |
| 62 | } |
| 63 | if !strings.Contains(err.Error(), "not unique") { |
| 64 | t.Errorf("error should mention 'not unique': %v", err) |
| 65 | } |
| 66 | got, _ := os.ReadFile(f) |
| 67 | if string(got) != body { |
| 68 | t.Errorf("file modified despite error: %q", got) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | func TestDeleteRangeMissingAnchor(t *testing.T) { |
| 73 | f := filepath.Join(t.TempDir(), "missing.txt") |
| 74 | body := "line1\nline2\nline3\n" |
| 75 | os.WriteFile(f, []byte(body), 0o644) |
| 76 | |
| 77 | args := argsJSON(t, map[string]any{ |
| 78 | "path": f, "start_anchor": "line2", "end_anchor": "no_such_line", |
| 79 | }) |
| 80 | _, err := (deleteRange{}).Execute(context.Background(), args) |
| 81 | if err == nil { |
| 82 | t.Fatal("expected missing anchor error") |
| 83 | } |
| 84 | if !strings.Contains(err.Error(), "not found") { |
| 85 | t.Errorf("error should mention 'not found': %v", err) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func TestDeleteRangeReversed(t *testing.T) { |
| 90 | f := filepath.Join(t.TempDir(), "rev.txt") |
| 91 | body := "line1\nline2\nline3\nline4\nline5\n" |
| 92 | os.WriteFile(f, []byte(body), 0o644) |
| 93 | |
| 94 | args := argsJSON(t, map[string]any{ |
| 95 | "path": f, "start_anchor": "line4", "end_anchor": "line2", |
| 96 | }) |
| 97 | _, err := (deleteRange{}).Execute(context.Background(), args) |
| 98 | if err == nil { |
| 99 | t.Fatal("expected reversed anchor error") |
| 100 | } |
| 101 | if !strings.Contains(err.Error(), "after") { |
| 102 | t.Errorf("error should mention ordering: %v", err) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | func TestDeleteRangeRejectsEndAnchorOpeningBlock(t *testing.T) { |
| 107 | f := filepath.Join(t.TempDir(), "map.html") |
| 108 | body := strings.Join([]string{ |
| 109 | "// map engine", |
| 110 | "function switchToProvinceMap() {", |
| 111 | " redraw();", |
| 112 | "}", |
| 113 | "function highlightCurrentOnMap() {", |
| 114 | " if (!state.mapInstance) return;", |
| 115 | " repaint();", |
| 116 | "}", |
| 117 | "", |
| 118 | }, "\n") |
| 119 | os.WriteFile(f, []byte(body), 0o644) |
| 120 | |
| 121 | args := argsJSON(t, map[string]any{ |
| 122 | "path": f, "start_anchor": "// map engine", "end_anchor": "function highlightCurrentOnMap() {", |
| 123 | }) |
| 124 | _, err := (deleteRange{}).Execute(context.Background(), args) |
| 125 | if err == nil { |
| 126 | t.Fatal("expected block-opening end_anchor to be rejected") |
| 127 | } |
| 128 | for _, want := range []string{"appears to open a code block", "closing line", "edit_file/multi_edit"} { |
| 129 | if !strings.Contains(err.Error(), want) { |
| 130 | t.Errorf("error should mention %q: %v", want, err) |
| 131 | } |
| 132 | } |
| 133 | got, _ := os.ReadFile(f) |
| 134 | if string(got) != body { |
| 135 | t.Errorf("file modified despite rejected range:\n%s", got) |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | func TestDeleteRangeAllowsCompleteBraceBlock(t *testing.T) { |
| 140 | f := filepath.Join(t.TempDir(), "map.html") |
| 141 | body := strings.Join([]string{ |
| 142 | "function before() {", |
| 143 | " keep();", |
| 144 | "}", |
| 145 | "function removeMe() {", |
| 146 | " if (ok) {", |
| 147 | " redraw();", |
| 148 | " }", |
| 149 | "} // removeMe", |
| 150 | "function after() {", |
| 151 | " keep();", |
| 152 | "}", |
| 153 | "", |
| 154 | }, "\n") |
| 155 | os.WriteFile(f, []byte(body), 0o644) |
| 156 | |
| 157 | runTool(t, deleteRange{}, map[string]any{ |
| 158 | "path": f, "start_anchor": "function removeMe() {", "end_anchor": "} // removeMe", |
| 159 | }) |
| 160 | got, _ := os.ReadFile(f) |
| 161 | want := strings.Join([]string{ |
| 162 | "function before() {", |
| 163 | " keep();", |
| 164 | "}", |
| 165 | "function after() {", |
| 166 | " keep();", |
| 167 | "}", |
| 168 | "", |
| 169 | }, "\n") |
| 170 | if string(got) != want { |
| 171 | t.Errorf("file = %q, want %q", got, want) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | func TestDeleteRangeRejectsClosingBraceWithoutOpening(t *testing.T) { |
| 176 | f := filepath.Join(t.TempDir(), "map.html") |
| 177 | body := strings.Join([]string{ |
| 178 | "function keepHeader() {", |
| 179 | " removeBody();", |
| 180 | "} // keepHeader", |
| 181 | "function after() {", |
| 182 | " keep();", |
| 183 | "}", |
| 184 | "", |
| 185 | }, "\n") |
| 186 | os.WriteFile(f, []byte(body), 0o644) |
| 187 | |
| 188 | args := argsJSON(t, map[string]any{ |
| 189 | "path": f, "start_anchor": " removeBody();", "end_anchor": "} // keepHeader", |
| 190 | }) |
| 191 | _, err := (deleteRange{}).Execute(context.Background(), args) |
| 192 | if err == nil { |
| 193 | t.Fatal("expected range cutting a block close to be rejected") |
| 194 | } |
| 195 | for _, want := range []string{"would cut a code block", "closing brace", "opening brace"} { |
| 196 | if !strings.Contains(err.Error(), want) { |
| 197 | t.Errorf("error should mention %q: %v", want, err) |
| 198 | } |
| 199 | } |
| 200 | got, _ := os.ReadFile(f) |
| 201 | if string(got) != body { |
| 202 | t.Errorf("file modified despite rejected range:\n%s", got) |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | func TestDeleteRangeAllowsPartialBraceDeletionInPlainText(t *testing.T) { |
| 207 | f := filepath.Join(t.TempDir(), "notes.md") |
| 208 | body := strings.Join([]string{ |
| 209 | "intro", |
| 210 | "example {", |
| 211 | " remove this prose line", |
| 212 | "}", |
| 213 | "end", |
| 214 | "", |
| 215 | }, "\n") |
| 216 | os.WriteFile(f, []byte(body), 0o644) |
| 217 | |
| 218 | runTool(t, deleteRange{}, map[string]any{ |
| 219 | "path": f, "start_anchor": "example {", "end_anchor": " remove this prose line", |
| 220 | }) |
| 221 | got, _ := os.ReadFile(f) |
| 222 | want := strings.Join([]string{ |
| 223 | "intro", |
| 224 | "}", |
| 225 | "end", |
| 226 | "", |
| 227 | }, "\n") |
| 228 | if string(got) != want { |
| 229 | t.Errorf("file = %q, want %q", got, want) |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | func TestDeleteRangeDuplicateAnchorReportsLines(t *testing.T) { |
| 234 | f := filepath.Join(t.TempDir(), "dup-separator.js") |
| 235 | body := strings.Join([]string{ |
| 236 | "// ═══════════════════════════════════════", |
| 237 | "const a = 1;", |
| 238 | "// ═══════════════════════════════════════", |
| 239 | "const b = 2;", |
| 240 | "// ═══════════════════════════════════════", |
| 241 | "", |
| 242 | }, "\n") |
| 243 | os.WriteFile(f, []byte(body), 0o644) |
| 244 | |
| 245 | args := argsJSON(t, map[string]any{ |
| 246 | "path": f, "start_anchor": "// ═══════════════════════════════════════", "end_anchor": "const b = 2;", |
| 247 | }) |
| 248 | _, err := (deleteRange{}).Execute(context.Background(), args) |
| 249 | if err == nil { |
| 250 | t.Fatal("expected duplicate anchor error") |
| 251 | } |
| 252 | for _, want := range []string{"not unique", "matching lines include 1, 3, 5", "repeated separator lines"} { |
| 253 | if !strings.Contains(err.Error(), want) { |
| 254 | t.Errorf("error should mention %q: %v", want, err) |
| 255 | } |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | func TestDeleteRangeCRLF(t *testing.T) { |
| 260 | f := filepath.Join(t.TempDir(), "crlf.txt") |
| 261 | body := "line1\r\nline2\r\nline3\r\nline4\r\nline5\r\n" |
| 262 | os.WriteFile(f, []byte(body), 0o644) |
| 263 | |
| 264 | runTool(t, deleteRange{}, map[string]any{ |
| 265 | "path": f, "start_anchor": "line2", "end_anchor": "line4", |
| 266 | }) |
| 267 | got, _ := os.ReadFile(f) |
| 268 | want := "line1\r\nline5\r\n" |
| 269 | if string(got) != want { |
| 270 | t.Errorf("CRLF file: got %q, want %q", got, want) |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | func TestDeleteRangeWholeNewlineTerminatedFile(t *testing.T) { |
| 275 | f := filepath.Join(t.TempDir(), "whole.txt") |
| 276 | os.WriteFile(f, []byte("line1\n"), 0o644) |
| 277 | |
| 278 | runTool(t, deleteRange{}, map[string]any{ |
| 279 | "path": f, "start_anchor": "line1", "end_anchor": "line1", |
| 280 | }) |
| 281 | got, _ := os.ReadFile(f) |
| 282 | if string(got) != "" { |
| 283 | t.Errorf("whole-file delete left content %q, want empty", got) |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | func TestDeleteRangePreview(t *testing.T) { |
| 288 | f := filepath.Join(t.TempDir(), "preview.txt") |
| 289 | body := "line1\nline2\nline3\nline4\nline5\n" |
| 290 | os.WriteFile(f, []byte(body), 0o644) |
| 291 | |
| 292 | change, err := deleteRange{}.Preview(argsJSON(t, map[string]any{ |
| 293 | "path": f, "start_anchor": "line2", "end_anchor": "line4", |
| 294 | })) |
| 295 | if err != nil { |
| 296 | t.Fatalf("Preview: %v", err) |
| 297 | } |
| 298 | |
| 299 | got, _ := os.ReadFile(f) |
| 300 | if string(got) != body { |
| 301 | t.Errorf("Preview mutated the file: %q", got) |
| 302 | } |
| 303 | |
| 304 | if change.Kind != "modify" { |
| 305 | t.Errorf("kind = %q, want modify", change.Kind) |
| 306 | } |
| 307 | if change.OldText != body { |
| 308 | t.Errorf("OldText = %q, want %q", change.OldText, body) |
| 309 | } |
| 310 | } |
| 311 |