| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "reflect" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "reasonix/internal/memory" |
| 12 | ) |
| 13 | |
| 14 | func TestResolveRefsInjectsOnlyNewNestedInstructionsOnce(t *testing.T) { |
| 15 | root := t.TempDir() |
| 16 | service := filepath.Join(root, "services", "api") |
| 17 | sibling := filepath.Join(root, "services", "web") |
| 18 | for _, dir := range []string{service, sibling} { |
| 19 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 20 | t.Fatal(err) |
| 21 | } |
| 22 | } |
| 23 | for path, body := range map[string]string{ |
| 24 | filepath.Join(root, "AGENTS.md"): "ROOT RULE", |
| 25 | filepath.Join(root, "services", "AGENTS.md"): "SERVICES RULE", |
| 26 | filepath.Join(service, "AGENTS.md"): "API RULE", |
| 27 | filepath.Join(sibling, "AGENTS.md"): "WEB RULE", |
| 28 | filepath.Join(service, "handler.go"): "package api", |
| 29 | filepath.Join(service, "handler_test.go"): "package api", |
| 30 | } { |
| 31 | if err := os.WriteFile(path, []byte(body), 0o644); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | c := New(Options{WorkspaceRoot: root, Memory: memory.Load(memory.Options{CWD: root})}) |
| 37 | block, errs := c.ResolveRefs(context.Background(), "review @services/api/handler.go and @services/api/handler_test.go") |
| 38 | if len(errs) != 0 { |
| 39 | t.Fatalf("ResolveRefs errors = %v", errs) |
| 40 | } |
| 41 | for _, want := range []string{"<path-instructions", "SERVICES RULE", "API RULE", "package api"} { |
| 42 | if !strings.Contains(block, want) { |
| 43 | t.Fatalf("resolved block missing %q:\n%s", want, block) |
| 44 | } |
| 45 | } |
| 46 | for _, unwanted := range []string{"ROOT RULE", "WEB RULE"} { |
| 47 | if strings.Contains(block, unwanted) { |
| 48 | t.Fatalf("resolved block included %q outside the nested delta:\n%s", unwanted, block) |
| 49 | } |
| 50 | } |
| 51 | if strings.Count(block, "SERVICES RULE") != 1 || strings.Count(block, "API RULE") != 1 { |
| 52 | t.Fatalf("nested instructions were duplicated across refs:\n%s", block) |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | func TestFileRefLine(t *testing.T) { |
| 57 | dir := t.TempDir() |
| 58 | pdf := filepath.Join(dir, "report.pdf") |
| 59 | if err := os.WriteFile(pdf, []byte("%PDF-1.4 fake"), 0o644); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | |
| 63 | if got, ok := FileRefLine(" " + pdf + " "); !ok || got != "@"+pdf { |
| 64 | t.Fatalf("FileRefLine(existing) = %q, %v", got, ok) |
| 65 | } |
| 66 | if got, ok := FileRefLine(`"` + pdf + `"`); !ok || got != "@"+pdf { |
| 67 | t.Fatalf("FileRefLine(quoted) = %q, %v", got, ok) |
| 68 | } |
| 69 | if _, ok := FileRefLine("/compact"); ok { |
| 70 | t.Fatal("a slash command must not resolve as a file ref") |
| 71 | } |
| 72 | if _, ok := FileRefLine(dir); ok { |
| 73 | t.Fatal("a directory must not resolve as a file ref") |
| 74 | } |
| 75 | if _, ok := FileRefLine(""); ok { |
| 76 | t.Fatal("empty must not resolve as a file ref") |
| 77 | } |
| 78 | |
| 79 | spaced := filepath.Join(dir, "report 2026.pdf") |
| 80 | if err := os.WriteFile(spaced, []byte("%PDF-1.4 fake"), 0o644); err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | if got, ok := FileRefLine(spaced); !ok || got != "@"+EscapeRefPath(spaced) { |
| 84 | t.Fatalf("FileRefLine(spaced) = %q, %v; want escaped ref", got, ok) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | func TestEscapeRefPathRoundTrip(t *testing.T) { |
| 89 | cases := []struct{ in, escaped string }{ |
| 90 | {"plain.txt", "plain.txt"}, |
| 91 | {"my file.txt", `my\ file.txt`}, |
| 92 | {"a\tb.txt", "a\\\tb.txt"}, |
| 93 | {`C:\dir\file.png`, `C:\dir\file.png`}, |
| 94 | } |
| 95 | for _, c := range cases { |
| 96 | if got := EscapeRefPath(c.in); got != c.escaped { |
| 97 | t.Errorf("EscapeRefPath(%q) = %q, want %q", c.in, got, c.escaped) |
| 98 | } |
| 99 | if got := UnescapeRefPath(c.escaped); got != c.in { |
| 100 | t.Errorf("UnescapeRefPath(%q) = %q, want %q", c.escaped, got, c.in) |
| 101 | } |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | // TestDetectRefsEscapedSpacePath closes the loop pastedFileRef and completion |
| 106 | // rely on: an @token with escaped spaces resolves to the real workspace file. |
| 107 | func TestDetectRefsEscapedSpacePath(t *testing.T) { |
| 108 | workspace := t.TempDir() |
| 109 | if err := os.WriteFile(filepath.Join(workspace, "my file.txt"), []byte("x"), 0o644); err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | refs := (&Controller{workspaceRoot: workspace}).detectRefs(`see @my\ file.txt after`) |
| 113 | if len(refs) != 1 || refs[0].kind != refFile || refs[0].path != "my file.txt" { |
| 114 | t.Fatalf("refs = %+v, want one file ref for \"my file.txt\"", refs) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | func TestSlashCodeCommentLine(t *testing.T) { |
| 119 | cases := []struct { |
| 120 | line string |
| 121 | want bool |
| 122 | }{ |
| 123 | {"// explain this", true}, |
| 124 | {" /* explain this */", true}, |
| 125 | {"/**\n * explain this\n */", true}, |
| 126 | {"/compact", false}, |
| 127 | {"/mcp__server__prompt", false}, |
| 128 | {"/missing/Foo.kt:12: error", false}, |
| 129 | {"hello", false}, |
| 130 | } |
| 131 | for _, c := range cases { |
| 132 | if got := SlashCodeCommentLine(c.line); got != c.want { |
| 133 | t.Errorf("SlashCodeCommentLine(%q) = %v, want %v", c.line, got, c.want) |
| 134 | } |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | func TestParseRefTokens(t *testing.T) { |
| 139 | cases := []struct { |
| 140 | line string |
| 141 | want []string |
| 142 | }{ |
| 143 | {"see @docs:doc://x and @src/main.go", []string{"docs:doc://x", "src/main.go"}}, |
| 144 | {"trailing @file.go.", []string{"file.go"}}, |
| 145 | {"dedup @a @a", []string{"a"}}, |
| 146 | {"no refs here", nil}, |
| 147 | {"email a@b.com keeps token", []string{"b.com"}}, |
| 148 | {`open @docs/my\ file.md now`, []string{"docs/my file.md"}}, |
| 149 | {`trailing @my\ file.md.`, []string{"my file.md"}}, |
| 150 | {`win @C:\dir\shot.png ok`, []string{`C:\dir\shot.png`}}, |
| 151 | {`unescaped @my file.md`, []string{"my"}}, |
| 152 | } |
| 153 | for _, c := range cases { |
| 154 | got := parseRefTokens(c.line) |
| 155 | if len(got) == 0 && len(c.want) == 0 { |
| 156 | continue |
| 157 | } |
| 158 | if !reflect.DeepEqual(got, c.want) { |
| 159 | t.Errorf("parseRefTokens(%q) = %v, want %v", c.line, got, c.want) |
| 160 | } |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | func TestClassifyRef(t *testing.T) { |
| 165 | known := map[string]bool{"docs": true} |
| 166 | files := map[string]bool{ |
| 167 | "src/main.go": true, |
| 168 | "README.md": true, |
| 169 | ".reasonix/attachments/clipboard-20260601-010203.000000.png": true, |
| 170 | ".reasonix/attachments/clipboard-20260601-010203.000000.yml": true, |
| 171 | ".reasonix/attachments/clipboard-20260601-010203.000000.zip": true, |
| 172 | } |
| 173 | exists := func(p string) bool { return files[p] } |
| 174 | |
| 175 | cases := []struct { |
| 176 | token string |
| 177 | wantOK bool |
| 178 | wantKnd refKind |
| 179 | }{ |
| 180 | {"docs:doc://style", true, refResource}, // known server + uri |
| 181 | {"src/main.go", true, refFile}, // existing file |
| 182 | {"README.md", true, refFile}, // existing file |
| 183 | {".reasonix/attachments/clipboard-20260601-010203.000000.png", true, refImage}, |
| 184 | {".reasonix/attachments/clipboard-20260601-010203.000000.yml", true, refFile}, |
| 185 | {".reasonix/attachments/clipboard-20260601-010203.000000.zip", true, refFile}, |
| 186 | {"ghost:issue://1", false, 0}, // unknown server, no such file |
| 187 | {"missing.go", false, 0}, // nonexistent path → not a ref |
| 188 | {"docs:", false, 0}, // empty uri → not a resource, no file |
| 189 | } |
| 190 | for _, c := range cases { |
| 191 | r, ok := classifyRef(c.token, known, exists) |
| 192 | if ok != c.wantOK { |
| 193 | t.Errorf("classifyRef(%q) ok = %v, want %v", c.token, ok, c.wantOK) |
| 194 | continue |
| 195 | } |
| 196 | if ok && r.kind != c.wantKnd { |
| 197 | t.Errorf("classifyRef(%q) kind = %v, want %v", c.token, r.kind, c.wantKnd) |
| 198 | } |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | func TestResolveRefsAttachmentKinds(t *testing.T) { |
| 203 | temp := t.TempDir() |
| 204 | attachmentsDir := filepath.Join(temp, ".reasonix", "attachments") |
| 205 | if err := os.MkdirAll(attachmentsDir, 0o755); err != nil { |
| 206 | t.Fatal(err) |
| 207 | } |
| 208 | ymlRef := filepath.ToSlash(".reasonix/attachments/config.yml") |
| 209 | zipRef := filepath.ToSlash(".reasonix/attachments/archive.zip") |
| 210 | pngRef := filepath.ToSlash(".reasonix/attachments/shot.png") |
| 211 | if err := os.WriteFile(filepath.Join(temp, filepath.FromSlash(ymlRef)), []byte("name: reasonix\n"), 0o644); err != nil { |
| 212 | t.Fatal(err) |
| 213 | } |
| 214 | if err := os.WriteFile(filepath.Join(temp, filepath.FromSlash(zipRef)), []byte{'P', 'K', 0x03, 0x04, 0x00}, 0o644); err != nil { |
| 215 | t.Fatal(err) |
| 216 | } |
| 217 | if err := os.WriteFile(filepath.Join(temp, filepath.FromSlash(pngRef)), []byte("\x89PNG\r\n\x1a\n"), 0o644); err != nil { |
| 218 | t.Fatal(err) |
| 219 | } |
| 220 | |
| 221 | oldCwd, err := os.Getwd() |
| 222 | if err != nil { |
| 223 | t.Fatal(err) |
| 224 | } |
| 225 | if err := os.Chdir(temp); err != nil { |
| 226 | t.Fatal(err) |
| 227 | } |
| 228 | t.Cleanup(func() { |
| 229 | if err := os.Chdir(oldCwd); err != nil { |
| 230 | t.Error(err) |
| 231 | } |
| 232 | }) |
| 233 | |
| 234 | line := "check @" + ymlRef + " @" + zipRef + " @" + pngRef |
| 235 | block, errs := (&Controller{}).ResolveRefs(context.Background(), line) |
| 236 | if len(errs) != 0 { |
| 237 | t.Fatalf("ResolveRefs errors = %v", errs) |
| 238 | } |
| 239 | if !strings.Contains(block, `<file path="`+ymlRef+`">`) || !strings.Contains(block, "name: reasonix") { |
| 240 | t.Fatalf("expected yml attachment to resolve as file content, got: %s", block) |
| 241 | } |
| 242 | if !strings.Contains(block, `<file path="`+zipRef+`">`) || !strings.Contains(block, "[binary file "+zipRef) { |
| 243 | t.Fatalf("expected zip attachment to resolve as binary file note, got: %s", block) |
| 244 | } |
| 245 | if !strings.Contains(block, `<image path="`+pngRef+`">`) { |
| 246 | t.Fatalf("expected png attachment to resolve as image block, got: %s", block) |
| 247 | } |
| 248 | if !strings.Contains(block, "OCR/image/vision tool") || !strings.Contains(block, "image bytes are not inlined") { |
| 249 | t.Fatalf("expected image attachment note to mention tool-readable path without inlined bytes, got: %s", block) |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | func TestReadFileRef(t *testing.T) { |
| 254 | dir := t.TempDir() |
| 255 | |
| 256 | textPath := filepath.Join(dir, "hello.txt") |
| 257 | if err := os.WriteFile(textPath, []byte("line one\nline two\n"), 0o644); err != nil { |
| 258 | t.Fatal(err) |
| 259 | } |
| 260 | binPath := filepath.Join(dir, "blob.bin") |
| 261 | if err := os.WriteFile(binPath, []byte{'a', 0x00, 'b'}, 0o644); err != nil { |
| 262 | t.Fatal(err) |
| 263 | } |
| 264 | bigPath := filepath.Join(dir, "big.txt") |
| 265 | if err := os.WriteFile(bigPath, []byte(strings.Repeat("a", maxFileRefBytes+100)), 0o644); err != nil { |
| 266 | t.Fatal(err) |
| 267 | } |
| 268 | imagePath := filepath.Join(dir, "shot.png") |
| 269 | if err := os.WriteFile(imagePath, []byte("\x89PNG\r\n\x1a\n"), 0o644); err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | |
| 273 | // Text file: content verbatim, not a directory. |
| 274 | if got, isDir, err := readFileRef(textPath, ""); err != nil || isDir || got != "line one\nline two\n" { |
| 275 | t.Errorf("text file = (%q, %v, %v)", got, isDir, err) |
| 276 | } |
| 277 | |
| 278 | // Binary file: noted, not dumped. |
| 279 | if got, _, err := readFileRef(binPath, ""); err != nil || !strings.Contains(got, "binary file") { |
| 280 | t.Errorf("binary file = (%q, %v), want a binary note", got, err) |
| 281 | } |
| 282 | |
| 283 | // Image file: identified as image-specific guidance, not generic binary. |
| 284 | if got, _, err := readFileRef(imagePath, ""); err != nil || !strings.Contains(got, "image file") { |
| 285 | t.Errorf("image file = (%q, %v), want an image note", got, err) |
| 286 | } |
| 287 | if got, _, err := readFileRef(imagePath, ""); err != nil || !strings.Contains(got, "not sent as direct model image input") || !strings.Contains(got, "OCR/image/vision tool") { |
| 288 | t.Errorf("unscoped image file = (%q, %v), want a non-attached image note", got, err) |
| 289 | } |
| 290 | |
| 291 | // Large file: truncated with a marker. |
| 292 | if got, _, err := readFileRef(bigPath, ""); err != nil || !strings.Contains(got, "truncated") { |
| 293 | t.Errorf("big file should be truncated, got len=%d err=%v", len(got), err) |
| 294 | } |
| 295 | |
| 296 | // Directory: recursive listing with relative paths including a trailing slash for subdirs. |
| 297 | if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil { |
| 298 | t.Fatal(err) |
| 299 | } |
| 300 | if err := os.WriteFile(filepath.Join(dir, "sub", "nested.txt"), []byte("nested"), 0o644); err != nil { |
| 301 | t.Fatal(err) |
| 302 | } |
| 303 | if err := os.MkdirAll(filepath.Join(dir, "node_modules", "pkg"), 0o755); err != nil { |
| 304 | t.Fatal(err) |
| 305 | } |
| 306 | if err := os.WriteFile(filepath.Join(dir, "node_modules", "pkg", "noise.js"), []byte("noise"), 0o644); err != nil { |
| 307 | t.Fatal(err) |
| 308 | } |
| 309 | got, isDir, err := readFileRef(dir, "") |
| 310 | if err != nil || !isDir { |
| 311 | t.Fatalf("dir = (isDir=%v, err=%v)", isDir, err) |
| 312 | } |
| 313 | if !strings.Contains(got, "directory listing only") || !strings.Contains(got, "file contents are not inlined") { |
| 314 | t.Errorf("dir listing = %q, want a directory reference note", got) |
| 315 | } |
| 316 | if !strings.Contains(got, "hello.txt") || !strings.Contains(got, "sub/") || !strings.Contains(got, "sub/nested.txt") { |
| 317 | t.Errorf("dir listing = %q, want hello.txt, sub/, and sub/nested.txt", got) |
| 318 | } |
| 319 | if strings.Contains(got, "node_modules") || strings.Contains(got, "noise.js") { |
| 320 | t.Errorf("dir listing = %q, want generated/vendor directories skipped", got) |
| 321 | } |
| 322 | |
| 323 | // Missing path: error. |
| 324 | if _, _, err := readFileRef(filepath.Join(dir, "nope"), ""); err == nil { |
| 325 | t.Error("missing path should error") |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | func TestReadFileRefPDFExtraction(t *testing.T) { |
| 330 | dir := t.TempDir() |
| 331 | pdfPath := filepath.Join(dir, "report.pdf") |
| 332 | if err := os.WriteFile(pdfPath, []byte("%PDF-1.4 fake"), 0o644); err != nil { |
| 333 | t.Fatal(err) |
| 334 | } |
| 335 | |
| 336 | oldExtract := extractPDFText |
| 337 | t.Cleanup(func() { extractPDFText = oldExtract }) |
| 338 | |
| 339 | extractPDFText = func(path string) (pdfExtractResult, error) { |
| 340 | if path != pdfPath { |
| 341 | t.Fatalf("extract path = %q, want %q", path, pdfPath) |
| 342 | } |
| 343 | return pdfExtractResult{text: "Quarterly results\nRevenue up", tool: "test-extractor"}, nil |
| 344 | } |
| 345 | got, isDir, err := readFileRef(pdfPath, "") |
| 346 | if err != nil || isDir { |
| 347 | t.Fatalf("pdf text = (isDir=%v, err=%v)", isDir, err) |
| 348 | } |
| 349 | if !strings.Contains(got, "PDF text extracted") || !strings.Contains(got, "Revenue up") { |
| 350 | t.Fatalf("pdf text extraction missing from output: %s", got) |
| 351 | } |
| 352 | |
| 353 | extractPDFText = func(string) (pdfExtractResult, error) { |
| 354 | return pdfExtractResult{text: " ", tool: "test-extractor"}, nil |
| 355 | } |
| 356 | got, _, err = readFileRef(pdfPath, "") |
| 357 | if err != nil { |
| 358 | t.Fatalf("empty pdf text err = %v", err) |
| 359 | } |
| 360 | if !strings.Contains(got, "no extractable text") || !strings.Contains(got, "OCR") { |
| 361 | t.Fatalf("empty pdf should ask for OCR, got: %s", got) |
| 362 | } |
| 363 | |
| 364 | extractPDFText = func(string) (pdfExtractResult, error) { |
| 365 | return pdfExtractResult{}, os.ErrNotExist |
| 366 | } |
| 367 | got, _, err = readFileRef(pdfPath, "") |
| 368 | if err != nil { |
| 369 | t.Fatalf("failed pdf text err = %v", err) |
| 370 | } |
| 371 | if !strings.Contains(got, "text extraction unavailable") || !strings.Contains(got, "multimodal/vision") { |
| 372 | t.Fatalf("failed pdf should mention OCR/vision fallback, got: %s", got) |
| 373 | } |
| 374 | } |
| 375 | |
| 376 | func TestRunPDFTextCommandCapsStderr(t *testing.T) { |
| 377 | t.Setenv("GO_WANT_PDF_STDERR_HELPER", "1") |
| 378 | |
| 379 | _, _, err := runPDFTextCommand(os.Args[0], []string{"-test.run=TestPDFStderrHelperProcess", "--"}) |
| 380 | if err == nil { |
| 381 | t.Fatal("expected helper command to fail") |
| 382 | } |
| 383 | msg := err.Error() |
| 384 | if !strings.Contains(msg, "truncated") { |
| 385 | t.Fatalf("expected stderr truncation marker, got: %q", msg) |
| 386 | } |
| 387 | if len(msg) > maxFileRefBytes+1024 { |
| 388 | t.Fatalf("stderr error grew too large: len=%d", len(msg)) |
| 389 | } |
| 390 | } |
| 391 | |
| 392 | func TestPDFStderrHelperProcess(t *testing.T) { |
| 393 | if os.Getenv("GO_WANT_PDF_STDERR_HELPER") != "1" { |
| 394 | return |
| 395 | } |
| 396 | _, _ = os.Stderr.WriteString(strings.Repeat("x", maxFileRefBytes+4096)) |
| 397 | os.Exit(7) |
| 398 | } |
| 399 | |
| 400 | func TestResolveBareNamesDuplicates(t *testing.T) { |
| 401 | temp := t.TempDir() |
| 402 | |
| 403 | if err := os.MkdirAll(filepath.Join(temp, "a"), 0o755); err != nil { |
| 404 | t.Fatal(err) |
| 405 | } |
| 406 | if err := os.MkdirAll(filepath.Join(temp, "b"), 0o755); err != nil { |
| 407 | t.Fatal(err) |
| 408 | } |
| 409 | if err := os.MkdirAll(filepath.Join(temp, "c"), 0o755); err != nil { |
| 410 | t.Fatal(err) |
| 411 | } |
| 412 | |
| 413 | if err := os.WriteFile(filepath.Join(temp, "a", "helper.go"), []byte("package a"), 0o644); err != nil { |
| 414 | t.Fatal(err) |
| 415 | } |
| 416 | if err := os.WriteFile(filepath.Join(temp, "b", "helper.go"), []byte("package b"), 0o644); err != nil { |
| 417 | t.Fatal(err) |
| 418 | } |
| 419 | if err := os.WriteFile(filepath.Join(temp, "c", "main.go"), []byte("package c"), 0o644); err != nil { |
| 420 | t.Fatal(err) |
| 421 | } |
| 422 | |
| 423 | oldCwd, err := os.Getwd() |
| 424 | if err != nil { |
| 425 | t.Fatal(err) |
| 426 | } |
| 427 | if err := os.Chdir(temp); err != nil { |
| 428 | t.Fatal(err) |
| 429 | } |
| 430 | t.Cleanup(func() { |
| 431 | if err := os.Chdir(oldCwd); err != nil { |
| 432 | t.Error(err) |
| 433 | } |
| 434 | }) |
| 435 | |
| 436 | refs := []ref{ |
| 437 | {kind: refFile, raw: "helper.go"}, |
| 438 | {kind: refFile, raw: "main.go"}, |
| 439 | } |
| 440 | |
| 441 | resolved := resolveBareNames(refs, "") |
| 442 | |
| 443 | if len(resolved) != 2 { |
| 444 | t.Fatalf("expected 2 resolved refs, got %d", len(resolved)) |
| 445 | } |
| 446 | |
| 447 | helperRef := resolved[0] |
| 448 | mainRef := resolved[1] |
| 449 | |
| 450 | if helperRef.path != "a/helper.go" && helperRef.path != "b/helper.go" { |
| 451 | t.Errorf("expected helper.go path to be a/helper.go or b/helper.go, got %q", helperRef.path) |
| 452 | } |
| 453 | if mainRef.path != "c/main.go" { |
| 454 | t.Errorf("expected main.go path to be c/main.go, got %q", mainRef.path) |
| 455 | } |
| 456 | } |
| 457 | |
| 458 | func TestReadFileRefWithBaseDir(t *testing.T) { |
| 459 | base := t.TempDir() |
| 460 | sub := filepath.Join(base, "proj") |
| 461 | if err := os.MkdirAll(sub, 0o755); err != nil { |
| 462 | t.Fatal(err) |
| 463 | } |
| 464 | if err := os.WriteFile(filepath.Join(sub, "hello.txt"), []byte("hello"), 0o644); err != nil { |
| 465 | t.Fatal(err) |
| 466 | } |
| 467 | |
| 468 | // Relative path "proj/hello.txt" resolves via baseDir when not in CWD. |
| 469 | got, isDir, err := readFileRef("proj/hello.txt", base) |
| 470 | if err != nil { |
| 471 | t.Fatalf("readFileRef with baseDir: %v", err) |
| 472 | } |
| 473 | if isDir { |
| 474 | t.Error("expected file, not directory") |
| 475 | } |
| 476 | if got != "hello" { |
| 477 | t.Errorf("got %q, want %q", got, "hello") |
| 478 | } |
| 479 | |
| 480 | // Empty baseDir falls back to direct path (absolute). |
| 481 | got2, _, err2 := readFileRef(filepath.Join(sub, "hello.txt"), "") |
| 482 | if err2 != nil { |
| 483 | t.Fatalf("readFileRef with empty baseDir: %v", err2) |
| 484 | } |
| 485 | if got2 != "hello" { |
| 486 | t.Errorf("got %q, want %q", got2, "hello") |
| 487 | } |
| 488 | |
| 489 | if err := os.MkdirAll(filepath.Join(sub, "src"), 0o755); err != nil { |
| 490 | t.Fatal(err) |
| 491 | } |
| 492 | if err := os.WriteFile(filepath.Join(sub, "src", "main.go"), []byte("package main"), 0o644); err != nil { |
| 493 | t.Fatal(err) |
| 494 | } |
| 495 | if err := os.MkdirAll(filepath.Join(sub, "dist"), 0o755); err != nil { |
| 496 | t.Fatal(err) |
| 497 | } |
| 498 | if err := os.WriteFile(filepath.Join(sub, "dist", "bundle.js"), []byte("generated"), 0o644); err != nil { |
| 499 | t.Fatal(err) |
| 500 | } |
| 501 | gotDir, isDir, err := readFileRef("proj", base) |
| 502 | if err != nil || !isDir { |
| 503 | t.Fatalf("readFileRef scoped dir = (isDir=%v, err=%v)", isDir, err) |
| 504 | } |
| 505 | if !strings.Contains(gotDir, "directory listing only") || !strings.Contains(gotDir, "src/") || !strings.Contains(gotDir, "src/main.go") { |
| 506 | t.Fatalf("scoped dir listing missing contract or nested file:\n%s", gotDir) |
| 507 | } |
| 508 | if strings.Contains(gotDir, "dist/") || strings.Contains(gotDir, "bundle.js") { |
| 509 | t.Fatalf("scoped dir listing should skip generated dirs:\n%s", gotDir) |
| 510 | } |
| 511 | } |
| 512 | |
| 513 | func TestResolveBareNamesWithWorkspaceRoot(t *testing.T) { |
| 514 | root := t.TempDir() |
| 515 | if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil { |
| 516 | t.Fatal(err) |
| 517 | } |
| 518 | if err := os.WriteFile(filepath.Join(root, "src", "main.go"), []byte("package main"), 0o644); err != nil { |
| 519 | t.Fatal(err) |
| 520 | } |
| 521 | |
| 522 | refs := []ref{{kind: refFile, raw: "main.go"}} |
| 523 | resolved := resolveBareNames(refs, root) |
| 524 | |
| 525 | if len(resolved) != 1 { |
| 526 | t.Fatalf("expected 1 ref, got %d", len(resolved)) |
| 527 | } |
| 528 | if resolved[0].path != "src/main.go" { |
| 529 | t.Errorf("expected src/main.go, got %q", resolved[0].path) |
| 530 | } |
| 531 | } |
| 532 | |
| 533 | func TestResolveBareNamesSkipsAlreadyResolvedRefs(t *testing.T) { |
| 534 | refs := []ref{{kind: refFile, raw: "main.go", path: "main.go"}} |
| 535 | |
| 536 | resolved := resolveBareNames(refs, t.TempDir()) |
| 537 | |
| 538 | if len(resolved) != 1 { |
| 539 | t.Fatalf("expected 1 ref, got %d", len(resolved)) |
| 540 | } |
| 541 | if resolved[0].path != "main.go" { |
| 542 | t.Fatalf("already resolved ref path = %q, want main.go", resolved[0].path) |
| 543 | } |
| 544 | } |
| 545 | |
| 546 | func TestResolveBareNamesWithWorkspaceRootStoresRootFilePath(t *testing.T) { |
| 547 | root := t.TempDir() |
| 548 | if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main"), 0o644); err != nil { |
| 549 | t.Fatal(err) |
| 550 | } |
| 551 | |
| 552 | refs := []ref{{kind: refFile, raw: "main.go"}} |
| 553 | resolved := resolveBareNames(refs, root) |
| 554 | |
| 555 | if len(resolved) != 1 { |
| 556 | t.Fatalf("expected 1 ref, got %d", len(resolved)) |
| 557 | } |
| 558 | if resolved[0].path != "main.go" { |
| 559 | t.Fatalf("root workspace ref path = %q, want main.go", resolved[0].path) |
| 560 | } |
| 561 | } |
| 562 | |
| 563 | func TestResolveBareNamesRejectsUnsafeBareNames(t *testing.T) { |
| 564 | root := t.TempDir() |
| 565 | if err := os.WriteFile(filepath.Join(root, "safe.txt"), []byte("safe"), 0o644); err != nil { |
| 566 | t.Fatal(err) |
| 567 | } |
| 568 | if err := os.WriteFile(filepath.Join(root, "bad..name.txt"), []byte("unsafe"), 0o644); err != nil { |
| 569 | t.Fatal(err) |
| 570 | } |
| 571 | |
| 572 | refs := []ref{ |
| 573 | {kind: refFile, raw: "safe.txt"}, |
| 574 | {kind: refFile, raw: "bad..name.txt"}, |
| 575 | {kind: refFile, raw: ".."}, |
| 576 | } |
| 577 | resolved := resolveBareNames(refs, root) |
| 578 | |
| 579 | if resolved[0].path != "safe.txt" { |
| 580 | t.Fatalf("safe bare name path = %q, want safe.txt", resolved[0].path) |
| 581 | } |
| 582 | if resolved[1].path != "" { |
| 583 | t.Fatalf("unsafe bare name should stay unresolved, got %q", resolved[1].path) |
| 584 | } |
| 585 | if resolved[2].path != "" { |
| 586 | t.Fatalf("parent-dir bare name should stay unresolved, got %q", resolved[2].path) |
| 587 | } |
| 588 | } |
| 589 | |
| 590 | func TestResolveAbsRef(t *testing.T) { |
| 591 | temp := t.TempDir() |
| 592 | |
| 593 | _, _, ok := resolveAbsRef("foo.txt", "") |
| 594 | if !ok { |
| 595 | t.Errorf("empty base: expected ok=true with CLI fallback") |
| 596 | } |
| 597 | |
| 598 | absInBase := filepath.Join(temp, "foo.txt") |
| 599 | absPath, absBase, ok := resolveAbsRef(absInBase, temp) |
| 600 | if !ok || absPath != absInBase || absBase != temp { |
| 601 | t.Errorf("absolute path under base: got (%q, %q, %v), want (%q, %q, true)", absPath, absBase, ok, absInBase, temp) |
| 602 | } |
| 603 | |
| 604 | if _, _, ok := resolveAbsRef(filepath.Join(temp, "..", "outside.txt"), temp); ok { |
| 605 | t.Errorf("absolute path outside base should be rejected") |
| 606 | } |
| 607 | |
| 608 | want := filepath.Join(temp, "sub", "file.txt") |
| 609 | absPath, absBase, ok = resolveAbsRef(filepath.Join("sub", "file.txt"), temp) |
| 610 | if !ok || absPath != want || absBase != temp { |
| 611 | t.Errorf("relative in base: got (%q, %q, %v), want (%q, %q, true)", absPath, absBase, ok, want, temp) |
| 612 | } |
| 613 | |
| 614 | if _, _, ok := resolveAbsRef(".."+string(filepath.Separator)+"outside.txt", temp); ok { |
| 615 | t.Errorf("path traversal should be rejected") |
| 616 | } |
| 617 | if _, _, ok := resolveAbsRef("sub/../../escape.txt", temp); ok { |
| 618 | t.Errorf("path traversal should be rejected") |
| 619 | } |
| 620 | } |
| 621 | |
| 622 | func TestReadFileRefBlocksPathTraversal(t *testing.T) { |
| 623 | temp := t.TempDir() |
| 624 | if err := os.WriteFile(filepath.Join(temp, "safe.txt"), []byte("safe"), 0o644); err != nil { |
| 625 | t.Fatal(err) |
| 626 | } |
| 627 | if err := os.WriteFile(filepath.Join(temp, "..", "outside.txt"), []byte("outside"), 0o644); err != nil { |
| 628 | t.Fatal(err) |
| 629 | } |
| 630 | t.Cleanup(func() { _ = os.Remove(filepath.Join(temp, "..", "outside.txt")) }) |
| 631 | |
| 632 | if _, isDir, err := readFileRef(".."+string(filepath.Separator)+"outside.txt", temp); err == nil { |
| 633 | t.Errorf("expected traversal to fail, got isDir=%v err=%v", isDir, err) |
| 634 | } |
| 635 | } |
| 636 | |
| 637 | func TestDetectRefsUsesWorkspaceRootNotProcessCWD(t *testing.T) { |
| 638 | cwd := t.TempDir() |
| 639 | workspace := t.TempDir() |
| 640 | if err := os.WriteFile(filepath.Join(cwd, "cwd-only.txt"), []byte("wrong"), 0o644); err != nil { |
| 641 | t.Fatal(err) |
| 642 | } |
| 643 | if err := os.WriteFile(filepath.Join(workspace, "workspace.txt"), []byte("right"), 0o644); err != nil { |
| 644 | t.Fatal(err) |
| 645 | } |
| 646 | |
| 647 | oldCwd, err := os.Getwd() |
| 648 | if err != nil { |
| 649 | t.Fatal(err) |
| 650 | } |
| 651 | if err := os.Chdir(cwd); err != nil { |
| 652 | t.Fatal(err) |
| 653 | } |
| 654 | t.Cleanup(func() { |
| 655 | if err := os.Chdir(oldCwd); err != nil { |
| 656 | t.Error(err) |
| 657 | } |
| 658 | }) |
| 659 | |
| 660 | refs := (&Controller{workspaceRoot: workspace}).detectRefs("see @cwd-only.txt and @workspace.txt") |
| 661 | if len(refs) != 1 || refs[0].raw != "workspace.txt" { |
| 662 | t.Fatalf("detectRefs should only see workspace files, got %+v", refs) |
| 663 | } |
| 664 | |
| 665 | block, errs := (&Controller{workspaceRoot: workspace}).ResolveRefs(context.Background(), "see @cwd-only.txt") |
| 666 | if block != "" || len(errs) != 0 { |
| 667 | t.Fatalf("cwd-only file should not be treated as a ref, block=%q errs=%v", block, errs) |
| 668 | } |
| 669 | } |
| 670 | |
| 671 | func TestScopedRefsRequireExternalFolderRegistration(t *testing.T) { |
| 672 | workspace := t.TempDir() |
| 673 | external := t.TempDir() |
| 674 | if err := os.WriteFile(filepath.Join(external, "outside.txt"), []byte("outside"), 0o644); err != nil { |
| 675 | t.Fatal(err) |
| 676 | } |
| 677 | |
| 678 | c := &Controller{workspaceRoot: workspace} |
| 679 | block, errs := c.ResolveScopedRefs(context.Background(), "see @"+external) |
| 680 | if block != "" || len(errs) != 0 { |
| 681 | t.Fatalf("unregistered external dir should not resolve, block=%q errs=%v", block, errs) |
| 682 | } |
| 683 | } |
| 684 | |
| 685 | func TestRegisterExternalFolderRefResolvesScopedDir(t *testing.T) { |
| 686 | workspace := t.TempDir() |
| 687 | parent := t.TempDir() |
| 688 | external := filepath.Join(parent, "Folder With Spaces") |
| 689 | if err := os.MkdirAll(filepath.Join(external, "sub"), 0o755); err != nil { |
| 690 | t.Fatal(err) |
| 691 | } |
| 692 | if err := os.WriteFile(filepath.Join(external, "sub", "outside.txt"), []byte("outside"), 0o644); err != nil { |
| 693 | t.Fatal(err) |
| 694 | } |
| 695 | expectedExternal := external |
| 696 | if resolved, err := filepath.EvalSymlinks(external); err == nil { |
| 697 | expectedExternal = resolved |
| 698 | } |
| 699 | expectedDisplayPath := filepath.ToSlash(expectedExternal) |
| 700 | |
| 701 | registrar := &recordingExternalFolderToolRefs{} |
| 702 | c := &Controller{workspaceRoot: workspace, externalFolderToolRefs: registrar} |
| 703 | token, displayPath, err := c.RegisterExternalFolderRef(external) |
| 704 | if err != nil { |
| 705 | t.Fatalf("RegisterExternalFolderRef: %v", err) |
| 706 | } |
| 707 | if registrar.token != token || registrar.root != expectedExternal { |
| 708 | t.Fatalf("tool read root registration = (%q, %q), want (%q, %q)", registrar.token, registrar.root, token, expectedExternal) |
| 709 | } |
| 710 | if strings.ContainsAny(token, " \t\r\n") { |
| 711 | t.Fatalf("external folder token must be whitespace-free, got %q", token) |
| 712 | } |
| 713 | if displayPath != expectedDisplayPath { |
| 714 | t.Fatalf("display path = %q, want %q", displayPath, expectedDisplayPath) |
| 715 | } |
| 716 | |
| 717 | refs := c.detectRefs("see @" + token + "/") |
| 718 | if len(refs) != 1 { |
| 719 | t.Fatalf("detectRefs registered external folder = %+v, want 1 ref", refs) |
| 720 | } |
| 721 | if refs[0].path != "." || refs[0].baseDir != expectedExternal || refs[0].displayPath != expectedDisplayPath { |
| 722 | t.Fatalf("external ref = %+v, want path '.' baseDir/displayPath for external folder", refs[0]) |
| 723 | } |
| 724 | |
| 725 | block, errs := c.ResolveScopedRefs(context.Background(), "see @"+token+"/") |
| 726 | if len(errs) != 0 { |
| 727 | t.Fatalf("ResolveScopedRefs errors = %v", errs) |
| 728 | } |
| 729 | if !strings.Contains(block, `<dir path="`+expectedDisplayPath+`">`) || |
| 730 | !strings.Contains(block, "directory listing only") || |
| 731 | !strings.Contains(block, "sub/") || |
| 732 | !strings.Contains(block, "sub/outside.txt") { |
| 733 | t.Fatalf("registered external folder should resolve as a dir listing:\n%s", block) |
| 734 | } |
| 735 | |
| 736 | block, errs = c.ResolveScopedRefs(context.Background(), "read @"+token+"/sub/outside.txt") |
| 737 | if len(errs) != 0 { |
| 738 | t.Fatalf("ResolveScopedRefs child errors = %v", errs) |
| 739 | } |
| 740 | if !strings.Contains(block, `<file path="`+expectedDisplayPath+`/sub/outside.txt">`) || |
| 741 | !strings.Contains(block, "outside") { |
| 742 | t.Fatalf("registered external child should resolve as file content:\n%s", block) |
| 743 | } |
| 744 | |
| 745 | block, errs = c.ResolveScopedRefs(context.Background(), "escape @"+token+"/../secret.txt") |
| 746 | if block != "" || len(errs) != 0 { |
| 747 | t.Fatalf("external folder ref must not resolve escaping subpaths, block=%q errs=%v", block, errs) |
| 748 | } |
| 749 | } |
| 750 | |
| 751 | type recordingExternalFolderToolRefs struct { |
| 752 | token string |
| 753 | root string |
| 754 | } |
| 755 | |
| 756 | func (r *recordingExternalFolderToolRefs) RegisterReadRoot(token, root string) { |
| 757 | r.token = token |
| 758 | r.root = root |
| 759 | } |
| 760 | |
| 761 | func TestExternalFolderRefListAndSearch(t *testing.T) { |
| 762 | parent := t.TempDir() |
| 763 | external := filepath.Join(parent, "Folder With Spaces") |
| 764 | if err := os.MkdirAll(filepath.Join(external, "src"), 0o755); err != nil { |
| 765 | t.Fatal(err) |
| 766 | } |
| 767 | if err := os.WriteFile(filepath.Join(external, "src", "outside.txt"), []byte("outside"), 0o644); err != nil { |
| 768 | t.Fatal(err) |
| 769 | } |
| 770 | if err := os.MkdirAll(filepath.Join(external, "node_modules"), 0o755); err != nil { |
| 771 | t.Fatal(err) |
| 772 | } |
| 773 | if err := os.WriteFile(filepath.Join(external, "node_modules", "outside.txt"), []byte("noise"), 0o644); err != nil { |
| 774 | t.Fatal(err) |
| 775 | } |
| 776 | expectedExternal := external |
| 777 | if resolved, err := filepath.EvalSymlinks(external); err == nil { |
| 778 | expectedExternal = resolved |
| 779 | } |
| 780 | expectedDisplayPath := filepath.ToSlash(expectedExternal) |
| 781 | |
| 782 | c := &Controller{} |
| 783 | token, _, err := c.RegisterExternalFolderRef(external) |
| 784 | if err != nil { |
| 785 | t.Fatalf("RegisterExternalFolderRef: %v", err) |
| 786 | } |
| 787 | |
| 788 | rootEntries, handled := c.ListExternalFolderRefDir(token + "/") |
| 789 | if !handled { |
| 790 | t.Fatal("ListExternalFolderRefDir should handle the registered root token") |
| 791 | } |
| 792 | if len(rootEntries) != 1 || rootEntries[0].Name != "src" || !rootEntries[0].IsDir { |
| 793 | t.Fatalf("root entries = %+v, want src/ and skipped node_modules", rootEntries) |
| 794 | } |
| 795 | |
| 796 | srcEntries, handled := c.ListExternalFolderRefDir(token + "/src/") |
| 797 | if !handled { |
| 798 | t.Fatal("ListExternalFolderRefDir should handle registered child dirs") |
| 799 | } |
| 800 | if len(srcEntries) != 1 || |
| 801 | srcEntries[0].Name != "outside.txt" || |
| 802 | srcEntries[0].Path != token+"/src/outside.txt" || |
| 803 | srcEntries[0].DisplayPath != expectedDisplayPath+"/src/outside.txt" { |
| 804 | t.Fatalf("src entries = %+v, want outside.txt token/display path", srcEntries) |
| 805 | } |
| 806 | |
| 807 | results := c.SearchExternalFolderRefs("outside", 10) |
| 808 | if len(results) != 1 || |
| 809 | results[0].Path != token+"/src/outside.txt" || |
| 810 | results[0].DisplayName != "Folder With Spaces/src/outside.txt" || |
| 811 | results[0].DisplayPath != expectedDisplayPath+"/src/outside.txt" { |
| 812 | t.Fatalf("search results = %+v, want external outside.txt with token and display paths", results) |
| 813 | } |
| 814 | } |
| 815 | |
| 816 | func TestResolveRefsWithWorkspaceRootStoresRelativePath(t *testing.T) { |
| 817 | workspace := t.TempDir() |
| 818 | absPath := filepath.Join(workspace, "docs", "note.txt") |
| 819 | if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { |
| 820 | t.Fatal(err) |
| 821 | } |
| 822 | if err := os.WriteFile(absPath, []byte("workspace note"), 0o644); err != nil { |
| 823 | t.Fatal(err) |
| 824 | } |
| 825 | |
| 826 | c := &Controller{workspaceRoot: workspace} |
| 827 | refs := c.detectRefs("see @" + absPath) |
| 828 | if len(refs) != 1 { |
| 829 | t.Fatalf("detectRefs absolute workspace path = %+v, want 1 ref", refs) |
| 830 | } |
| 831 | if refs[0].path != "docs/note.txt" { |
| 832 | t.Fatalf("ref path = %q, want workspace-relative path", refs[0].path) |
| 833 | } |
| 834 | block, errs := c.ResolveRefs(context.Background(), "see @"+absPath) |
| 835 | if len(errs) != 0 { |
| 836 | t.Fatalf("ResolveRefs errors = %v", errs) |
| 837 | } |
| 838 | if !strings.Contains(block, `<file path="docs/note.txt">`) || !strings.Contains(block, "workspace note") { |
| 839 | t.Fatalf("ResolveRefs block did not use relative workspace path:\n%s", block) |
| 840 | } |
| 841 | } |
| 842 | |
| 843 | func TestWorkspaceImageRefsAlsoAttachAsModelImages(t *testing.T) { |
| 844 | workspace := t.TempDir() |
| 845 | diagram := filepath.Join(workspace, "docs", "diagram.png") |
| 846 | if err := os.MkdirAll(filepath.Dir(diagram), 0o755); err != nil { |
| 847 | t.Fatal(err) |
| 848 | } |
| 849 | if err := os.WriteFile(diagram, []byte("\x89PNG\r\n\x1a\n"), 0o644); err != nil { |
| 850 | t.Fatal(err) |
| 851 | } |
| 852 | attachment := filepath.Join(workspace, ".reasonix", "attachments", "shot.png") |
| 853 | if err := os.MkdirAll(filepath.Dir(attachment), 0o755); err != nil { |
| 854 | t.Fatal(err) |
| 855 | } |
| 856 | if err := os.WriteFile(attachment, []byte("\x89PNG\r\n\x1a\n"), 0o644); err != nil { |
| 857 | t.Fatal(err) |
| 858 | } |
| 859 | |
| 860 | writeVisionTestConfig(t, workspace) |
| 861 | c := &Controller{workspaceRoot: workspace, modelRef: "custom/vision-pro"} |
| 862 | refs := c.detectRefs("see @" + diagram + " @" + attachment) |
| 863 | if len(refs) != 2 { |
| 864 | t.Fatalf("detectRefs = %+v, want two refs", refs) |
| 865 | } |
| 866 | if refs[0].kind != refFile || refs[0].path != "docs/diagram.png" { |
| 867 | t.Fatalf("workspace png ref = %+v, want file ref", refs[0]) |
| 868 | } |
| 869 | if refs[1].kind != refImage || refs[1].path != ".reasonix/attachments/shot.png" { |
| 870 | t.Fatalf("attachment png ref = %+v, want image attachment ref", refs[1]) |
| 871 | } |
| 872 | |
| 873 | block, errs := c.ResolveRefs(context.Background(), "see @"+diagram) |
| 874 | if len(errs) != 0 { |
| 875 | t.Fatalf("ResolveRefs errors = %v", errs) |
| 876 | } |
| 877 | if !strings.Contains(block, `<file path="docs/diagram.png">`) || !strings.Contains(block, "sent as direct model image input only when the selected model supports vision") || !strings.Contains(block, "OCR/image/vision tool") { |
| 878 | t.Fatalf("workspace png should resolve as direct-vision-or-tool image metadata:\n%s", block) |
| 879 | } |
| 880 | if urls := c.inputImages("see @" + diagram); len(urls) != 1 || !strings.HasPrefix(urls[0], "data:image/png;base64,") { |
| 881 | t.Fatalf("workspace png inputImages = %v, want one png data URL", urls) |
| 882 | } |
| 883 | } |
| 884 | |
| 885 | func TestResolveRefsWithoutWorkspaceDoesNotClaimImageAttachment(t *testing.T) { |
| 886 | dir := t.TempDir() |
| 887 | imagePath := filepath.Join(dir, "shot.png") |
| 888 | if err := os.WriteFile(imagePath, []byte("\x89PNG\r\n\x1a\n"), 0o644); err != nil { |
| 889 | t.Fatal(err) |
| 890 | } |
| 891 | |
| 892 | block, errs := New(Options{}).ResolveRefs(context.Background(), "see @"+imagePath) |
| 893 | if len(errs) != 0 { |
| 894 | t.Fatalf("ResolveRefs errors = %v", errs) |
| 895 | } |
| 896 | if !strings.Contains(block, "not sent as direct model image input") || !strings.Contains(block, "OCR/image/vision tool") { |
| 897 | t.Fatalf("unscoped image ref should not claim model image attachment:\n%s", block) |
| 898 | } |
| 899 | } |
| 900 | |
| 901 | func TestReadFileRefPDFExtractionWithBaseDirUsesAbsPath(t *testing.T) { |
| 902 | base := t.TempDir() |
| 903 | pdfPath := filepath.Join(base, "docs", "report.pdf") |
| 904 | if err := os.MkdirAll(filepath.Dir(pdfPath), 0o755); err != nil { |
| 905 | t.Fatal(err) |
| 906 | } |
| 907 | if err := os.WriteFile(pdfPath, []byte("%PDF-1.4 fake"), 0o644); err != nil { |
| 908 | t.Fatal(err) |
| 909 | } |
| 910 | |
| 911 | outside := t.TempDir() |
| 912 | oldCwd, err := os.Getwd() |
| 913 | if err != nil { |
| 914 | t.Fatal(err) |
| 915 | } |
| 916 | if err := os.Chdir(outside); err != nil { |
| 917 | t.Fatal(err) |
| 918 | } |
| 919 | t.Cleanup(func() { |
| 920 | if err := os.Chdir(oldCwd); err != nil { |
| 921 | t.Error(err) |
| 922 | } |
| 923 | }) |
| 924 | |
| 925 | oldExtract := extractPDFText |
| 926 | t.Cleanup(func() { extractPDFText = oldExtract }) |
| 927 | extractPDFText = func(path string) (pdfExtractResult, error) { |
| 928 | if path != pdfPath { |
| 929 | t.Fatalf("extract path = %q, want %q", path, pdfPath) |
| 930 | } |
| 931 | return pdfExtractResult{text: "workspace pdf", tool: "test-extractor"}, nil |
| 932 | } |
| 933 | |
| 934 | got, isDir, err := readFileRef("docs/report.pdf", base) |
| 935 | if err != nil || isDir { |
| 936 | t.Fatalf("scoped pdf = (isDir=%v, err=%v)", isDir, err) |
| 937 | } |
| 938 | if !strings.Contains(got, "workspace pdf") { |
| 939 | t.Fatalf("scoped pdf extraction missing text: %s", got) |
| 940 | } |
| 941 | } |
| 942 |