1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "runtime"
9 "strings"
10 "testing"
11
12 "github.com/bnema/zut/packages/core"
13 "github.com/bnema/zut/packages/provider"
14)
15
16func globArgsJSON(pattern, path string, hidden bool) json.RawMessage {
17 args := map[string]any{"pattern": pattern}
18 if path != "" {
19 args["path"] = path
20 }
21 if hidden {
22 args["hidden"] = true
23 }
24 raw, _ := json.Marshal(args)
25 return raw
26}
27
28func writeGlobFile(t *testing.T, path, content string) {
29 t.Helper()
30 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
31 t.Fatal(err)
32 }
33 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
34 t.Fatal(err)
35 }
36}
37
38func globDetails(result core.ToolResult) map[string]any {
39 return result.Details.(map[string]any)
40}
41
42func globResultText(t *testing.T, result core.ToolResult) string {
43 t.Helper()
44 if len(result.Content) != 1 {
45 t.Fatalf("content blocks = %d, want 1", len(result.Content))
46 }
47 text, ok := result.Content[0].(provider.TextBlock)
48 if !ok {
49 t.Fatalf("content type = %T, want TextBlock", result.Content[0])
50 }
51 return text.Text
52}
53
54func TestGlobStarMatchesBasenames(t *testing.T) {
55 root := t.TempDir()
56 writeGlobFile(t, filepath.Join(root, "a.go"), "")
57 writeGlobFile(t, filepath.Join(root, "b.txt"), "")
58 writeGlobFile(t, filepath.Join(root, "sub", "c.go"), "")
59
60 tool := &GlobTool{CWD: root}
61 result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "", false), nil)
62 if err != nil {
63 t.Fatal(err)
64 }
65 text := globResultText(t, result)
66 if !strings.Contains(text, "a.go") || !strings.Contains(text, "sub/c.go") {
67 t.Fatalf("output missing matches: %q", text)
68 }
69 if strings.Contains(text, "b.txt") {
70 t.Fatalf("output contains non-match: %q", text)
71 }
72 if globDetails(result)["matches"] != 2 {
73 t.Fatalf("matches = %v, want 2", globDetails(result)["matches"])
74 }
75}
76
77func TestGlobGlobstarMatchesNestedPaths(t *testing.T) {
78 root := t.TempDir()
79 writeGlobFile(t, filepath.Join(root, "a.go"), "")
80 writeGlobFile(t, filepath.Join(root, "sub", "deep", "b.go"), "")
81
82 tool := &GlobTool{CWD: root}
83 result, err := tool.Execute(context.Background(), globArgsJSON("**/*.go", "", false), nil)
84 if err != nil {
85 t.Fatal(err)
86 }
87 text := globResultText(t, result)
88 if !strings.Contains(text, "a.go") || !strings.Contains(text, "sub/deep/b.go") {
89 t.Fatalf("globstar missed nested matches: %q", text)
90 }
91}
92
93func TestGlobQuestionMarkAndCharacterClass(t *testing.T) {
94 root := t.TempDir()
95 writeGlobFile(t, filepath.Join(root, "a1.go"), "")
96 writeGlobFile(t, filepath.Join(root, "a2.go"), "")
97 writeGlobFile(t, filepath.Join(root, "abc.go"), "")
98
99 tool := &GlobTool{CWD: root}
100 result, err := tool.Execute(context.Background(), globArgsJSON("a?.go", "", false), nil)
101 if err != nil {
102 t.Fatal(err)
103 }
104 if got := globDetails(result)["matches"]; got != 2 {
105 t.Fatalf("? matches = %v, want 2", got)
106 }
107
108 result, err = tool.Execute(context.Background(), globArgsJSON("a[12].go", "", false), nil)
109 if err != nil {
110 t.Fatal(err)
111 }
112 if got := globDetails(result)["matches"]; got != 2 {
113 t.Fatalf("class matches = %v, want 2", got)
114 }
115}
116
117func TestGlobSlashPatternMatchesRelativePath(t *testing.T) {
118 root := t.TempDir()
119 writeGlobFile(t, filepath.Join(root, "src", "a.go"), "")
120 writeGlobFile(t, filepath.Join(root, "other", "a.go"), "")
121
122 tool := &GlobTool{CWD: root}
123 result, err := tool.Execute(context.Background(), globArgsJSON("src/*.go", "", false), nil)
124 if err != nil {
125 t.Fatal(err)
126 }
127 text := globResultText(t, result)
128 if !strings.Contains(text, "src/a.go") || strings.Contains(text, "other/a.go") {
129 t.Fatalf("path pattern matched wrong set: %q", text)
130 }
131}
132
133func TestGlobInvalidPatternIsRejected(t *testing.T) {
134 tool := &GlobTool{CWD: t.TempDir()}
135 if _, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":""}`), nil); err == nil {
136 t.Fatal("empty pattern accepted")
137 }
138 if _, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":"a["}`), nil); err == nil {
139 t.Fatal("unterminated class accepted")
140 }
141 if _, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":1}`), nil); err == nil {
142 t.Fatal("non-string pattern accepted")
143 }
144 if _, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":"*.go","bogus":1}`), nil); err == nil {
145 t.Fatal("unknown argument accepted")
146 }
147}
148
149func TestGlobHiddenToggle(t *testing.T) {
150 root := t.TempDir()
151 writeGlobFile(t, filepath.Join(root, ".hidden.go"), "")
152 writeGlobFile(t, filepath.Join(root, ".hdir", "inside.go"), "")
153 writeGlobFile(t, filepath.Join(root, "visible.go"), "")
154 writeGlobFile(t, filepath.Join(root, ".git", "objects", "x"), "")
155
156 tool := &GlobTool{CWD: root}
157 result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "", false), nil)
158 if err != nil {
159 t.Fatal(err)
160 }
161 text := globResultText(t, result)
162 if strings.Contains(text, ".hidden") || strings.Contains(text, ".hdir") {
163 t.Fatalf("hidden entries leaked: %q", text)
164 }
165
166 result, err = tool.Execute(context.Background(), globArgsJSON("*.go", "", true), nil)
167 if err != nil {
168 t.Fatal(err)
169 }
170 text = globResultText(t, result)
171 if !strings.Contains(text, ".hidden.go") || !strings.Contains(text, ".hdir/inside.go") {
172 t.Fatalf("hidden opt-in missed entries: %q", text)
173 }
174 if strings.Contains(text, ".git") {
175 t.Fatalf(".git leaked with hidden opt-in: %q", text)
176 }
177}
178
179func TestGlobHonorsRootAndNestedIgnores(t *testing.T) {
180 root := t.TempDir()
181 writeGlobFile(t, filepath.Join(root, ".gitignore"), "ignored.txt\nbuild/\n")
182 writeGlobFile(t, filepath.Join(root, "ignored.txt"), "")
183 writeGlobFile(t, filepath.Join(root, "kept.txt"), "")
184 writeGlobFile(t, filepath.Join(root, "build", "out.txt"), "")
185 writeGlobFile(t, filepath.Join(root, "sub", ".gitignore"), "local.txt\n")
186 writeGlobFile(t, filepath.Join(root, "sub", "local.txt"), "")
187 writeGlobFile(t, filepath.Join(root, "sub", "keep.txt"), "")
188
189 tool := &GlobTool{CWD: root}
190 result, err := tool.Execute(context.Background(), globArgsJSON("*.txt", "", false), nil)
191 if err != nil {
192 t.Fatal(err)
193 }
194 text := globResultText(t, result)
195 for _, leaked := range []string{"ignored.txt", "build/out.txt", "local.txt"} {
196 if strings.Contains(text, leaked) {
197 t.Fatalf("ignored file leaked: %q in %q", leaked, text)
198 }
199 }
200 if !strings.Contains(text, "kept.txt") || !strings.Contains(text, "sub/keep.txt") {
201 t.Fatalf("kept files missing: %q", text)
202 }
203
204 // Narrowing with path preserves the inherited root rules.
205 result, err = tool.Execute(context.Background(), globArgsJSON("*.txt", "sub", false), nil)
206 if err != nil {
207 t.Fatal(err)
208 }
209 text = globResultText(t, result)
210 if strings.Contains(text, "local.txt") {
211 t.Fatalf("nested ignore lost with path narrowing: %q", text)
212 }
213 if !strings.Contains(text, "keep.txt") {
214 t.Fatalf("narrowed search missed keep.txt: %q", text)
215 }
216}
217
218func TestGlobNegationUnignores(t *testing.T) {
219 root := t.TempDir()
220 writeGlobFile(t, filepath.Join(root, ".gitignore"), "*.log\n!important.log\n")
221 writeGlobFile(t, filepath.Join(root, "a.log"), "")
222 writeGlobFile(t, filepath.Join(root, "important.log"), "")
223
224 tool := &GlobTool{CWD: root}
225 result, err := tool.Execute(context.Background(), globArgsJSON("*.log", "", false), nil)
226 if err != nil {
227 t.Fatal(err)
228 }
229 text := globResultText(t, result)
230 if strings.Contains(text, "a.log") || !strings.Contains(text, "important.log") {
231 t.Fatalf("negation mishandled: %q", text)
232 }
233}
234
235func TestGlobWalksExplicitlyRequestedIgnoredRoot(t *testing.T) {
236 root := t.TempDir()
237 writeGlobFile(t, filepath.Join(root, ".gitignore"), "build/\n")
238 writeGlobFile(t, filepath.Join(root, "build", "out.txt"), "")
239
240 tool := &GlobTool{CWD: root}
241 result, err := tool.Execute(context.Background(), globArgsJSON("*.txt", "build", false), nil)
242 if err != nil {
243 t.Fatal(err)
244 }
245 if !strings.Contains(globResultText(t, result), "build/out.txt") {
246 t.Fatalf("explicit ignored root not walked: %q", globResultText(t, result))
247 }
248}
249
250func TestGlobFileRootIsRejected(t *testing.T) {
251 root := t.TempDir()
252 writeGlobFile(t, filepath.Join(root, "a.go"), "")
253
254 tool := &GlobTool{CWD: root}
255 if _, err := tool.Execute(context.Background(), globArgsJSON("*.go", "a.go", false), nil); err == nil {
256 t.Fatal("file search root accepted")
257 }
258}
259
260func TestGlobEmptyResults(t *testing.T) {
261 tool := &GlobTool{CWD: t.TempDir()}
262 result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "", false), nil)
263 if err != nil {
264 t.Fatal(err)
265 }
266 if got := globResultText(t, result); got != "No files matched the pattern." {
267 t.Fatalf("empty output = %q", got)
268 }
269 if globDetails(result)["matches"] != 0 || globDetails(result)["truncated"] != false {
270 t.Fatalf("empty details = %v", result.Details)
271 }
272}
273
274func TestGlobSortsDeterministically(t *testing.T) {
275 root := t.TempDir()
276 for _, name := range []string{"z.go", "m.go", "a.go", "sub/z.go", "sub/a.go"} {
277 writeGlobFile(t, filepath.Join(root, name), "")
278 }
279 tool := &GlobTool{CWD: root}
280 first, err := tool.Execute(context.Background(), globArgsJSON("**/*.go", "", false), nil)
281 if err != nil {
282 t.Fatal(err)
283 }
284 second, err := tool.Execute(context.Background(), globArgsJSON("**/*.go", "", false), nil)
285 if err != nil {
286 t.Fatal(err)
287 }
288 a, b := globResultText(t, first), globResultText(t, second)
289 if a != b {
290 t.Fatalf("nondeterministic order:\n%s\n%s", a, b)
291 }
292 lines := strings.Split(a, "\n")
293 for i := 1; i < len(lines); i++ {
294 if lines[i-1] > lines[i] {
295 t.Fatalf("unsorted output: %q", a)
296 }
297 }
298}
299
300func TestGlobRespectsSandbox(t *testing.T) {
301 base := t.TempDir()
302 root := filepath.Join(base, "workspace")
303 if err := os.Mkdir(root, 0o755); err != nil {
304 t.Fatal(err)
305 }
306 outside := filepath.Join(base, "outside")
307 if err := os.Mkdir(outside, 0o755); err != nil {
308 t.Fatal(err)
309 }
310 writeGlobFile(t, filepath.Join(outside, "secret.go"), "")
311
312 sandbox := NewSandbox(root)
313 sandbox.Lock()
314 tool := &GlobTool{CWD: root, Sandbox: sandbox}
315 if _, err := tool.Execute(context.Background(), globArgsJSON("*.go", outside, false), nil); err == nil {
316 t.Fatal("search outside jail accepted")
317 } else if !strings.Contains(err.Error(), "jailed") {
318 t.Fatalf("error = %v, want jail denial", err)
319 }
320}
321
322func TestGlobRootSymlinkInsideAndOutsideJail(t *testing.T) {
323 if os.Getenv("OS") == "Windows_NT" {
324 t.Skip("symlink tests need privileges on windows")
325 }
326 base := t.TempDir()
327 root := filepath.Join(base, "workspace")
328 inner := filepath.Join(root, "inner")
329 if err := os.MkdirAll(inner, 0o755); err != nil {
330 t.Fatal(err)
331 }
332 writeGlobFile(t, filepath.Join(inner, "a.go"), "")
333 outside := filepath.Join(base, "outside")
334 if err := os.MkdirAll(outside, 0o755); err != nil {
335 t.Fatal(err)
336 }
337 writeGlobFile(t, filepath.Join(outside, "secret.go"), "")
338
339 sandbox := NewSandbox(root)
340 sandbox.Lock()
341
342 inside := filepath.Join(root, "link-inside")
343 if err := os.Symlink(inner, inside); err != nil {
344 t.Skipf("symlinks unsupported: %v", err)
345 }
346 tool := &GlobTool{CWD: root, Sandbox: sandbox}
347 result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "link-inside", false), nil)
348 if err != nil {
349 t.Fatal(err)
350 }
351 if !strings.Contains(globResultText(t, result), "a.go") {
352 t.Fatalf("symlinked root not traversed: %v", result)
353 }
354
355 escape := filepath.Join(root, "link-outside")
356 if err := os.Symlink(outside, escape); err != nil {
357 t.Skipf("symlinks unsupported: %v", err)
358 }
359 if _, err := tool.Execute(context.Background(), globArgsJSON("*.go", "link-outside", false), nil); err == nil {
360 t.Fatal("symlinked root escaping the jail accepted")
361 } else if !strings.Contains(err.Error(), "jailed") {
362 t.Fatalf("error = %v, want jail denial", err)
363 }
364}
365
366func TestGlobCancellationPropagates(t *testing.T) {
367 root := t.TempDir()
368 writeGlobFile(t, filepath.Join(root, "a.go"), "")
369 tool := &GlobTool{CWD: root}
370 ctx, cancel := context.WithCancel(context.Background())
371 cancel()
372 if _, err := tool.Execute(ctx, globArgsJSON("*.go", "", false), nil); err == nil {
373 t.Fatal("cancelled context accepted")
374 }
375}
376
377func TestGlobReportsMetadata(t *testing.T) {
378 root := t.TempDir()
379 writeGlobFile(t, filepath.Join(root, "a.go"), "")
380 tool := &GlobTool{CWD: root}
381 result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "", false), nil)
382 if err != nil {
383 t.Fatal(err)
384 }
385 if globDetails(result)["pattern"] != "*.go" {
386 t.Fatalf("pattern detail = %v", result.Details)
387 }
388 if globDetails(result)["truncated"] != false || globDetails(result)["matches"] != 1 {
389 t.Fatalf("details = %v", result.Details)
390 }
391}
392
393func TestGlobBoundsDepth(t *testing.T) {
394 root := t.TempDir()
395 deep := root
396 for i := 0; i < maxGlobDepth+5; i++ {
397 deep = filepath.Join(deep, "d")
398 }
399 writeGlobFile(t, filepath.Join(deep, "deep.go"), "")
400 writeGlobFile(t, filepath.Join(root, "top.go"), "")
401
402 tool := &GlobTool{CWD: root}
403 result, err := tool.Execute(context.Background(), globArgsJSON("**/*.go", "", false), nil)
404 if err != nil {
405 t.Fatal(err)
406 }
407 text := globResultText(t, result)
408 if !strings.Contains(text, "top.go") {
409 t.Fatalf("shallow match missing: %q", text)
410 }
411 if strings.Contains(text, "deep.go") {
412 t.Fatalf("depth bound not enforced: %q", text)
413 }
414}
415
416func TestGlobBoundsResults(t *testing.T) {
417 root := t.TempDir()
418 for i := 0; i < maxGlobMatches+50; i++ {
419 writeGlobFile(t, filepath.Join(root, strings.Repeat("f", 4)+string(rune('a'+i/26))+string(rune('a'+i%26))+".go"), "")
420 }
421
422 tool := &GlobTool{CWD: root}
423 result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "", false), nil)
424 if err != nil {
425 t.Fatal(err)
426 }
427 if got := globDetails(result)["matches"]; got != maxGlobMatches {
428 t.Fatalf("matches = %v, want %d", got, maxGlobMatches)
429 }
430 if globDetails(result)["truncated"] != true {
431 t.Fatalf("truncated = %v, want true", globDetails(result))
432 }
433 if !strings.Contains(globResultText(t, result), "Truncated") {
434 t.Fatalf("truncation note missing: %q", globResultText(t, result))
435 }
436}
437
438func TestGlobUnreadableEntriesAreSkipped(t *testing.T) {
439 root := t.TempDir()
440 writeGlobFile(t, filepath.Join(root, "a.go"), "")
441 denied := filepath.Join(root, "denied")
442 if err := os.MkdirAll(denied, 0o755); err != nil {
443 t.Fatal(err)
444 }
445 writeGlobFile(t, filepath.Join(denied, "b.go"), "")
446 if err := os.Chmod(denied, 0o000); err != nil {
447 t.Skipf("chmod unsupported: %v", err)
448 }
449 defer func() { _ = os.Chmod(denied, 0o755) }()
450
451 tool := &GlobTool{CWD: root}
452 result, err := tool.Execute(context.Background(), globArgsJSON("**/*.go", "", false), nil)
453 if err != nil {
454 t.Fatal(err)
455 }
456 if !strings.Contains(globResultText(t, result), "a.go") {
457 t.Fatalf("readable match missing: %q", globResultText(t, result))
458 }
459}
460
461func TestGlobNegatedCharacterClass(t *testing.T) {
462 root := t.TempDir()
463 writeGlobFile(t, filepath.Join(root, "a.go"), "")
464 writeGlobFile(t, filepath.Join(root, "b.go"), "")
465 writeGlobFile(t, filepath.Join(root, "c.go"), "")
466
467 tool := &GlobTool{CWD: root}
468 result, err := tool.Execute(context.Background(), globArgsJSON("[!ab].go", "", false), nil)
469 if err != nil {
470 t.Fatal(err)
471 }
472 text := globResultText(t, result)
473 if !strings.Contains(text, "c.go") || strings.Contains(text, "a.go") || strings.Contains(text, "b.go") {
474 t.Fatalf("negated class matched wrong set: %q", text)
475 }
476}
477
478func TestGlobSkipsSymlinkedDirectoryTargets(t *testing.T) {
479 if runtime.GOOS == "windows" {
480 t.Skip("symlink tests need privileges on windows")
481 }
482 root := t.TempDir()
483 inner := filepath.Join(root, "inner")
484 if err := os.MkdirAll(inner, 0o755); err != nil {
485 t.Fatal(err)
486 }
487 writeGlobFile(t, filepath.Join(inner, "a.go"), "")
488 link := filepath.Join(root, "dirlink")
489 if err := os.Symlink(inner, link); err != nil {
490 t.Skipf("symlinks unsupported: %v", err)
491 }
492
493 tool := &GlobTool{CWD: root}
494 result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "", false), nil)
495 if err != nil {
496 t.Fatal(err)
497 }
498 if strings.Contains(globResultText(t, result), "dirlink") {
499 t.Fatalf("symlinked directory listed as a file: %q", globResultText(t, result))
500 }
501}
502
503func TestGlobSymlinkedRootKeepsRequestedName(t *testing.T) {
504 if runtime.GOOS == "windows" {
505 t.Skip("symlink tests need privileges on windows")
506 }
507 base := t.TempDir()
508 inner := filepath.Join(base, "inner")
509 if err := os.MkdirAll(inner, 0o755); err != nil {
510 t.Fatal(err)
511 }
512 writeGlobFile(t, filepath.Join(inner, "a.go"), "")
513 link := filepath.Join(base, "link-inside")
514 if err := os.Symlink(inner, link); err != nil {
515 t.Skipf("symlinks unsupported: %v", err)
516 }
517
518 tool := &GlobTool{CWD: base}
519 result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "link-inside", false), nil)
520 if err != nil {
521 t.Fatal(err)
522 }
523 if got := globResultText(t, result); got != "link-inside/a.go" {
524 t.Fatalf("output = %q, want link-inside/a.go", got)
525 }
526}
527
528func TestGlobReportsDiscoveredPaths(t *testing.T) {
529 root := t.TempDir()
530 writeGlobFile(t, filepath.Join(root, "sub", "a.go"), "")
531 tool := &GlobTool{CWD: root}
532 result, err := tool.Execute(context.Background(), globArgsJSON("**/*.go", "", false), nil)
533 if err != nil {
534 t.Fatal(err)
535 }
536 discovers := result.Context
537 want := canonicalResourcePath(filepath.Join(root, "sub", "a.go"))
538 if len(discovers.Discovers) != 1 || discovers.Discovers[0] != want {
539 t.Fatalf("discovers = %v, want [%s]", discovers.Discovers, want)
540 }
541}