v0.41.0 6084eb1

glob_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close