| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | "reasonix/internal/tool" |
| 13 | ) |
| 14 | |
| 15 | func writeEnvelopeFixture(t *testing.T, name string, lines int) (dir, path string) { |
| 16 | t.Helper() |
| 17 | dir = t.TempDir() |
| 18 | path = filepath.Join(dir, name) |
| 19 | var b strings.Builder |
| 20 | for i := 1; i <= lines; i++ { |
| 21 | fmt.Fprintf(&b, "line %d\n", i) |
| 22 | } |
| 23 | if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | return dir, path |
| 27 | } |
| 28 | |
| 29 | func readEnvelope(t *testing.T, r readFile, args string) (tool.ReadResultEnvelope, string, bool) { |
| 30 | t.Helper() |
| 31 | out, env, err := r.ExecuteRead(context.Background(), json.RawMessage(args)) |
| 32 | if err != nil { |
| 33 | t.Fatalf("Execute(%s): %v", args, err) |
| 34 | } |
| 35 | return env, out, true |
| 36 | } |
| 37 | |
| 38 | func TestReadEnvelopeInspectDefaultsToOneBoundedPage(t *testing.T) { |
| 39 | dir, _ := writeEnvelopeFixture(t, "a.go", 5) |
| 40 | r := readFile{workDir: dir} |
| 41 | env, _, ok := readEnvelope(t, r, `{"path":"a.go"}`) |
| 42 | if !ok { |
| 43 | t.Fatal("ReadEnvelope must describe an inspect read") |
| 44 | } |
| 45 | if env.ProtocolVersion != tool.ReadResultProtocolVersion { |
| 46 | t.Fatalf("ProtocolVersion = %d, want %d", env.ProtocolVersion, tool.ReadResultProtocolVersion) |
| 47 | } |
| 48 | if env.Intent != tool.ReadIntentInspect { |
| 49 | t.Fatalf("Intent = %q, want %q", env.Intent, tool.ReadIntentInspect) |
| 50 | } |
| 51 | if env.RequestedRange != nil { |
| 52 | t.Fatalf("an inspect read requests no window, got %+v", env.RequestedRange) |
| 53 | } |
| 54 | if got, want := env.DeliveredRanges, []tool.ReadRange{{Start: 0, End: 5}}; len(got) != 1 || got[0] != want[0] { |
| 55 | t.Fatalf("DeliveredRanges = %+v, want %+v", got, want) |
| 56 | } |
| 57 | if env.HasMore || !env.EOF || env.SourceCut != tool.ReadCutNone { |
| 58 | t.Fatalf("small file must read to EOF: %+v", env) |
| 59 | } |
| 60 | if env.NextCursor != "" { |
| 61 | t.Fatalf("no continuation cursor at EOF, got %q", env.NextCursor) |
| 62 | } |
| 63 | if !filepath.IsAbs(env.Source.CanonicalPath) || env.Source.Snapshot == "" { |
| 64 | t.Fatalf("source identity missing: %+v", env.Source) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | func TestReadEnvelopeRangeReportsRequestedAndDeliveredWindow(t *testing.T) { |
| 69 | dir, _ := writeEnvelopeFixture(t, "a.go", 10) |
| 70 | r := readFile{workDir: dir} |
| 71 | env, _, ok := readEnvelope(t, r, `{"path":"a.go","offset":2,"limit":3}`) |
| 72 | if !ok { |
| 73 | t.Fatal("ReadEnvelope must describe an explicit range") |
| 74 | } |
| 75 | if env.Intent != tool.ReadIntentRange { |
| 76 | t.Fatalf("Intent = %q, want %q", env.Intent, tool.ReadIntentRange) |
| 77 | } |
| 78 | if env.RequestedRange == nil || *env.RequestedRange != (tool.ReadRange{Start: 2, End: 5}) { |
| 79 | t.Fatalf("RequestedRange = %+v, want {2 5}", env.RequestedRange) |
| 80 | } |
| 81 | if got, want := env.DeliveredRanges, []tool.ReadRange{{Start: 2, End: 5}}; len(got) != 1 || got[0] != want[0] { |
| 82 | t.Fatalf("DeliveredRanges = %+v, want %+v", got, want) |
| 83 | } |
| 84 | if !env.HasMore || env.EOF || env.SourceCut != tool.ReadCutPageLimit { |
| 85 | t.Fatalf("paged read must report a page cut: %+v", env) |
| 86 | } |
| 87 | cursor, ok := tool.DecodeReadCursor(env.NextCursor) |
| 88 | if !ok || cursor.NextStart != 5 { |
| 89 | t.Fatalf("NextCursor = %q (cursor %+v, ok=%v), want next_start 5", env.NextCursor, cursor, ok) |
| 90 | } |
| 91 | if cursor.Path != env.Source.CanonicalPath || cursor.Snapshot != env.Source.Snapshot || cursor.NextStart != 5 { |
| 92 | t.Fatalf("cursor %+v must name this envelope's source and position", cursor) |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | func TestReadEnvelopePastEOFDeliversNothing(t *testing.T) { |
| 97 | dir, _ := writeEnvelopeFixture(t, "a.go", 3) |
| 98 | r := readFile{workDir: dir} |
| 99 | env, _, ok := readEnvelope(t, r, `{"path":"a.go","offset":99,"limit":10}`) |
| 100 | if !ok { |
| 101 | t.Fatal("ReadEnvelope must describe a past-EOF read") |
| 102 | } |
| 103 | if len(env.DeliveredRanges) != 0 || !env.EOF || env.HasMore { |
| 104 | t.Fatalf("past-EOF read delivered content: %+v", env) |
| 105 | } |
| 106 | if env.SourceEnd == nil || *env.SourceEnd != 3 { |
| 107 | t.Fatalf("past-EOF read must still report the source end, got %+v", env.SourceEnd) |
| 108 | } |
| 109 | if env.Source.Snapshot == "" { |
| 110 | t.Fatal("an existing file must still carry a source snapshot") |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | func TestReadEnvelopeEmptyFileIsEOF(t *testing.T) { |
| 115 | dir := t.TempDir() |
| 116 | if err := os.WriteFile(filepath.Join(dir, "empty.txt"), nil, 0o600); err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | r := readFile{workDir: dir} |
| 120 | env, _, ok := readEnvelope(t, r, `{"path":"empty.txt"}`) |
| 121 | if !ok || !env.EOF || env.HasMore || len(env.DeliveredRanges) != 0 { |
| 122 | t.Fatalf("empty file envelope = %+v (ok=%v)", env, ok) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | func TestReadEnvelopeVersionTokenFollowsDeliveredContent(t *testing.T) { |
| 127 | dir, path := writeEnvelopeFixture(t, "a.go", 4) |
| 128 | r := readFile{workDir: dir} |
| 129 | before, _, ok := readEnvelope(t, r, `{"path":"a.go"}`) |
| 130 | if !ok { |
| 131 | t.Fatal("first read must produce an envelope") |
| 132 | } |
| 133 | if err := os.WriteFile(path, []byte("line 1\nline 2 changed\nline 3\nline 4\n"), 0o600); err != nil { |
| 134 | t.Fatal(err) |
| 135 | } |
| 136 | after, _, ok := readEnvelope(t, r, `{"path":"a.go"}`) |
| 137 | if !ok { |
| 138 | t.Fatal("second read must produce an envelope") |
| 139 | } |
| 140 | if before.Source.Snapshot == after.Source.Snapshot { |
| 141 | t.Fatal("an edited line must change the content version token") |
| 142 | } |
| 143 | if before.DeliveredRanges[0] != after.DeliveredRanges[0] { |
| 144 | t.Fatalf("window geometry changed unexpectedly: %+v vs %+v", before.DeliveredRanges, after.DeliveredRanges) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | // TestReadEnvelopeSeparatesSourceIdentityFromWindowDigest pins the v2 split: a |
| 149 | // line-terminator rewrite moves the source snapshot (the file changed) while the |
| 150 | // decoded window digest stays put, so neither can stand in for the other. |
| 151 | func TestReadEnvelopeSeparatesSourceIdentityFromWindowDigest(t *testing.T) { |
| 152 | dir := t.TempDir() |
| 153 | path := filepath.Join(dir, "a.txt") |
| 154 | if err := os.WriteFile(path, []byte("alpha\r\nbeta\r\n"), 0o600); err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | r := readFile{workDir: dir} |
| 158 | crlf, _, ok := readEnvelope(t, r, `{"path":"a.txt"}`) |
| 159 | if !ok { |
| 160 | t.Fatal("crlf read must produce an envelope") |
| 161 | } |
| 162 | if err := os.WriteFile(path, []byte("alpha\nbeta\n"), 0o600); err != nil { |
| 163 | t.Fatal(err) |
| 164 | } |
| 165 | lf, _, ok := readEnvelope(t, r, `{"path":"a.txt"}`) |
| 166 | if !ok { |
| 167 | t.Fatal("lf read must produce an envelope") |
| 168 | } |
| 169 | if crlf.Source.Snapshot == lf.Source.Snapshot { |
| 170 | t.Fatalf("a raw byte change must move the source snapshot: %q", crlf.Source.Snapshot) |
| 171 | } |
| 172 | if crlf.WindowDigest != lf.WindowDigest { |
| 173 | t.Fatalf("decoded window digest changed unexpectedly: %q vs %q", crlf.WindowDigest, lf.WindowDigest) |
| 174 | } |
| 175 | if crlf.DeliveredRanges[0] != lf.DeliveredRanges[0] { |
| 176 | t.Fatalf("decoded windows differ: %+v vs %+v", crlf.DeliveredRanges, lf.DeliveredRanges) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | func TestReadEnvelopeKeepsUnicodeWindowsIntact(t *testing.T) { |
| 181 | dir := t.TempDir() |
| 182 | path := filepath.Join(dir, "u.go") |
| 183 | body := "设置面板 → 标题\n第二行\nemoji 🙂 结束\n" |
| 184 | if err := os.WriteFile(path, []byte(body), 0o600); err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | r := readFile{workDir: dir} |
| 188 | env, out, ok := readEnvelope(t, r, `{"path":"u.go","offset":1,"limit":2}`) |
| 189 | if !ok { |
| 190 | t.Fatal("unicode read must produce an envelope") |
| 191 | } |
| 192 | if got, want := env.DeliveredRanges, []tool.ReadRange{{Start: 1, End: 3}}; len(got) != 1 || got[0] != want[0] { |
| 193 | t.Fatalf("DeliveredRanges = %+v, want %+v", got, want) |
| 194 | } |
| 195 | window, ok := tool.ParseReadWindow(out) |
| 196 | if !ok || len(window.Lines) != 2 { |
| 197 | t.Fatalf("rendered window = %+v (ok=%v)", window, ok) |
| 198 | } |
| 199 | if window.Lines[0] != "第二行" { |
| 200 | t.Fatalf("first delivered line = %q, want 第二行", window.Lines[0]) |
| 201 | } |
| 202 | if want := tool.WindowDigest(env.Source.CanonicalPath, window); env.WindowDigest != want { |
| 203 | t.Fatalf("WindowDigest = %q, want %q", env.WindowDigest, want) |
| 204 | } |
| 205 | again, _, _ := readEnvelope(t, r, `{"path":"u.go","offset":1,"limit":2}`) |
| 206 | if again.Source.Snapshot != env.Source.Snapshot { |
| 207 | t.Fatal("identical unicode window must produce a stable version token") |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | func TestReadIntentDefaultsAndExplicitForms(t *testing.T) { |
| 212 | dir, _ := writeEnvelopeFixture(t, "a.go", 10) |
| 213 | r := readFile{workDir: dir} |
| 214 | cases := []struct { |
| 215 | args string |
| 216 | wantIntent tool.ReadIntent |
| 217 | wantWindow bool |
| 218 | }{ |
| 219 | {`{"path":"a.go"}`, tool.ReadIntentInspect, false}, |
| 220 | {`{"path":"a.go","offset":1}`, tool.ReadIntentRange, true}, |
| 221 | {`{"path":"a.go","limit":5}`, tool.ReadIntentRange, true}, |
| 222 | {`{"path":"a.go","intent":"full"}`, tool.ReadIntentFull, false}, |
| 223 | {`{"path":"a.go","intent":"inspect","offset":1,"limit":5}`, tool.ReadIntentInspect, true}, |
| 224 | } |
| 225 | for _, tc := range cases { |
| 226 | t.Run(tc.args, func(t *testing.T) { |
| 227 | env, _, ok := readEnvelope(t, r, tc.args) |
| 228 | if !ok { |
| 229 | t.Fatalf("ReadEnvelope(%s) reported no envelope", tc.args) |
| 230 | } |
| 231 | if env.Intent != tc.wantIntent { |
| 232 | t.Fatalf("Intent = %q, want %q", env.Intent, tc.wantIntent) |
| 233 | } |
| 234 | if (env.RequestedRange != nil) != tc.wantWindow { |
| 235 | t.Fatalf("RequestedRange = %+v, want present=%v", env.RequestedRange, tc.wantWindow) |
| 236 | } |
| 237 | }) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | func TestReadIntentRejectsConflictingParameters(t *testing.T) { |
| 242 | dir, _ := writeEnvelopeFixture(t, "a.go", 3) |
| 243 | r := readFile{workDir: dir} |
| 244 | cases := []struct { |
| 245 | name string |
| 246 | args string |
| 247 | want string |
| 248 | }{ |
| 249 | {"range without a window", `{"path":"a.go","intent":"range"}`, "intent=range requires"}, |
| 250 | {"unknown intent", `{"path":"a.go","intent":"peek"}`, "intent must be inspect, range, or full"}, |
| 251 | } |
| 252 | for _, tc := range cases { |
| 253 | t.Run(tc.name, func(t *testing.T) { |
| 254 | _, err := r.Execute(context.Background(), json.RawMessage(tc.args)) |
| 255 | if err == nil || !strings.Contains(err.Error(), tc.want) { |
| 256 | t.Fatalf("Execute(%s) error = %v, want one containing %q", tc.args, err, tc.want) |
| 257 | } |
| 258 | if _, ok := r.ReadEnvelope(context.Background(), json.RawMessage(tc.args), " 1→a\n"); ok { |
| 259 | t.Fatalf("ReadEnvelope(%s) must not describe a rejected call", tc.args) |
| 260 | } |
| 261 | }) |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | func TestLegacyFullIntentIsOneBoundedWindow(t *testing.T) { |
| 266 | dir, _ := writeEnvelopeFixture(t, "a.go", 30) |
| 267 | r := readFile{workDir: dir} |
| 268 | out, err := r.Execute(context.Background(), json.RawMessage(`{"path":"a.go","intent":"full","offset":10,"limit":2}`)) |
| 269 | if err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | window, ok := tool.ParseReadWindow(out) |
| 273 | if !ok || window.StartLine != 11 || len(window.Lines) != 2 { |
| 274 | t.Fatalf("bounded legacy full window = %+v, ok=%v", window, ok) |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | func TestReadEnvelopeRejectsCallsWithoutAPath(t *testing.T) { |
| 279 | r := readFile{workDir: t.TempDir()} |
| 280 | if _, ok := r.ReadEnvelope(context.Background(), json.RawMessage(`{"offset":1}`), " 1→a\n"); ok { |
| 281 | t.Fatal("a call without a path has no envelope") |
| 282 | } |
| 283 | if _, ok := r.ReadEnvelope(context.Background(), json.RawMessage(`not json`), " 1→a\n"); ok { |
| 284 | t.Fatal("malformed args have no envelope") |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | func TestReadEnvelopeNeverAppearsInProviderText(t *testing.T) { |
| 289 | dir, _ := writeEnvelopeFixture(t, "a.go", 10) |
| 290 | r := readFile{workDir: dir} |
| 291 | _, out, _ := readEnvelope(t, r, `{"path":"a.go","offset":2,"limit":3}`) |
| 292 | for _, leak := range []string{"protocol_version", "version_token", "delivered_ranges", "next_cursor", "read_id"} { |
| 293 | if strings.Contains(out, leak) { |
| 294 | t.Fatalf("host-only envelope field %q leaked into reader output:\n%s", leak, out) |
| 295 | } |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | func TestReadEnvelopeNamesTheServingStore(t *testing.T) { |
| 300 | dir, _ := writeEnvelopeFixture(t, "a.go", 10) |
| 301 | r := readFile{workDir: dir} |
| 302 | first, _, ok := readEnvelope(t, r, `{"path":"a.go","offset":0,"limit":4}`) |
| 303 | if !ok { |
| 304 | t.Fatal("first page must produce an envelope") |
| 305 | } |
| 306 | if first.Source.Kind != tool.ReadSourceDisk || first.Source.Identity == "" || first.Source.Snapshot == "" { |
| 307 | t.Fatalf("source identity missing: %+v", first.Source) |
| 308 | } |
| 309 | second, _, ok := readEnvelope(t, r, `{"path":"a.go","offset":4,"limit":4}`) |
| 310 | if !ok { |
| 311 | t.Fatal("second page must produce an envelope") |
| 312 | } |
| 313 | if second.Source.Snapshot != first.Source.Snapshot { |
| 314 | t.Fatalf("one read's snapshot must not move between pages: %q vs %q", first.Source.Snapshot, second.Source.Snapshot) |
| 315 | } |
| 316 | if second.WindowDigest == first.WindowDigest { |
| 317 | t.Fatal("different windows must have different digests") |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | func TestReadEnvelopeSourceEndIsTrustworthy(t *testing.T) { |
| 322 | dir, _ := writeEnvelopeFixture(t, "a.go", 3) |
| 323 | r := readFile{workDir: dir} |
| 324 | complete, _, ok := readEnvelope(t, r, `{"path":"a.go"}`) |
| 325 | if !ok || complete.SourceEnd == nil || *complete.SourceEnd != 3 { |
| 326 | t.Fatalf("a complete read must report the source end: %+v", complete.SourceEnd) |
| 327 | } |
| 328 | pastEOF, _, ok := readEnvelope(t, r, `{"path":"a.go","offset":99,"limit":10}`) |
| 329 | if !ok || pastEOF.SourceEnd == nil || *pastEOF.SourceEnd != 3 { |
| 330 | t.Fatalf("a past-EOF read must report the source end: %+v", pastEOF.SourceEnd) |
| 331 | } |
| 332 | empty := t.TempDir() |
| 333 | if err := os.WriteFile(filepath.Join(empty, "empty.txt"), nil, 0o600); err != nil { |
| 334 | t.Fatal(err) |
| 335 | } |
| 336 | er := readFile{workDir: empty} |
| 337 | env, _, ok := readEnvelope(t, er, `{"path":"empty.txt"}`) |
| 338 | if !ok || env.SourceEnd == nil || *env.SourceEnd != 0 { |
| 339 | t.Fatalf("an empty file must report a zero source end: %+v", env.SourceEnd) |
| 340 | } |
| 341 | } |
| 342 |