1package tools
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "github.com/patriceckhart/zot/packages/provider"
11)
12
13func TestGlobBasic(t *testing.T) {
14 dir := t.TempDir()
15 files := []string{
16 "main.go",
17 "README.md",
18 "pkg/util.go",
19 "pkg/util_test.go",
20 "pkg/sub/deep.go",
21 "docs/index.html",
22 }
23 for _, f := range files {
24 p := filepath.Join(dir, f)
25 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
26 t.Fatal(err)
27 }
28 if err := os.WriteFile(p, []byte("content"), 0o644); err != nil {
29 t.Fatal(err)
30 }
31 }
32
33 tool := &GlobTool{CWD: dir}
34
35 // 1. Simple extension match across tree (no slash in pattern)
36 res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go"}), nil)
37 if err != nil {
38 t.Fatalf("execute error: %v", err)
39 }
40 text := res.Content[0].(provider.TextBlock).Text
41 for _, want := range []string{"main.go", "pkg/util.go", "pkg/util_test.go", "pkg/sub/deep.go"} {
42 if !strings.Contains(text, want) {
43 t.Errorf("expected %q in result, got:\n%s", want, text)
44 }
45 }
46 if strings.Contains(text, "README.md") || strings.Contains(text, "docs/index.html") {
47 t.Errorf("unexpected file in result:\n%s", text)
48 }
49
50 // 2. Globstar match with path prefix
51 res, err = tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "pkg/**/*.go"}), nil)
52 if err != nil {
53 t.Fatalf("execute error: %v", err)
54 }
55 text = res.Content[0].(provider.TextBlock).Text
56 if strings.Contains(text, "main.go") {
57 t.Errorf("did not expect main.go in pkg/**/*.go, got:\n%s", text)
58 }
59 for _, want := range []string{"pkg/util.go", "pkg/util_test.go", "pkg/sub/deep.go"} {
60 if !strings.Contains(text, want) {
61 t.Errorf("expected %q in result, got:\n%s", want, text)
62 }
63 }
64
65 // 3. Specific path segment
66 res, err = tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "pkg/*.go"}), nil)
67 if err != nil {
68 t.Fatalf("execute error: %v", err)
69 }
70 text = res.Content[0].(provider.TextBlock).Text
71 if strings.Contains(text, "pkg/sub/deep.go") {
72 t.Errorf("did not expect deep.go in single-star pkg/*.go, got:\n%s", text)
73 }
74 if !strings.Contains(text, "pkg/util.go") || !strings.Contains(text, "pkg/util_test.go") {
75 t.Errorf("missing pkg/util.go in pkg/*.go, got:\n%s", text)
76 }
77}
78
79func TestGlobBraceExpansion(t *testing.T) {
80 dir := t.TempDir()
81 files := []string{
82 "a.go",
83 "b.json",
84 "c.ts",
85 "d.txt",
86 }
87 for _, f := range files {
88 p := filepath.Join(dir, f)
89 os.WriteFile(p, []byte("content"), 0o644)
90 }
91
92 tool := &GlobTool{CWD: dir}
93 res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.{go,json}"}), nil)
94 if err != nil {
95 t.Fatalf("execute error: %v", err)
96 }
97 text := res.Content[0].(provider.TextBlock).Text
98 if !strings.Contains(text, "a.go") || !strings.Contains(text, "b.json") {
99 t.Errorf("expected a.go and b.json, got:\n%s", text)
100 }
101 if strings.Contains(text, "c.ts") || strings.Contains(text, "d.txt") {
102 t.Errorf("unexpected files in result:\n%s", text)
103 }
104}
105
106func TestGlobGitignore(t *testing.T) {
107 dir := t.TempDir()
108 if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("ignored/\n*.log\n"), 0o644); err != nil {
109 t.Fatal(err)
110 }
111
112 files := []string{
113 "app.go",
114 "error.log",
115 "ignored/secret.go",
116 "sub/normal.go",
117 "sub/test.log",
118 }
119 for _, f := range files {
120 p := filepath.Join(dir, f)
121 os.MkdirAll(filepath.Dir(p), 0o755)
122 os.WriteFile(p, []byte("content"), 0o644)
123 }
124
125 tool := &GlobTool{CWD: dir}
126 res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "**/*"}), nil)
127 if err != nil {
128 t.Fatalf("execute error: %v", err)
129 }
130 text := res.Content[0].(provider.TextBlock).Text
131 if strings.Contains(text, "secret.go") || strings.Contains(text, "error.log") || strings.Contains(text, "test.log") {
132 t.Errorf("result contains gitignored files:\n%s", text)
133 }
134 if !strings.Contains(text, "app.go") || !strings.Contains(text, "sub/normal.go") {
135 t.Errorf("missing tracked files in:\n%s", text)
136 }
137}
138
139func TestGlobNestedGitignore(t *testing.T) {
140 dir := t.TempDir()
141 nested := filepath.Join(dir, "vendor")
142 os.MkdirAll(nested, 0o755)
143 os.WriteFile(filepath.Join(nested, ".gitignore"), []byte("cached/\n"), 0o644)
144
145 files := []string{
146 "root.go",
147 "vendor/keep.go",
148 "vendor/cached/temp.go",
149 }
150 for _, f := range files {
151 p := filepath.Join(dir, f)
152 os.MkdirAll(filepath.Dir(p), 0o755)
153 os.WriteFile(p, []byte("content"), 0o644)
154 }
155
156 tool := &GlobTool{CWD: dir}
157 res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "**/*.go"}), nil)
158 if err != nil {
159 t.Fatal(err)
160 }
161 text := res.Content[0].(provider.TextBlock).Text
162 if strings.Contains(text, "temp.go") {
163 t.Errorf("nested gitignore rule was not respected: %s", text)
164 }
165 if !strings.Contains(text, "root.go") || !strings.Contains(text, "vendor/keep.go") {
166 t.Errorf("expected files missing: %s", text)
167 }
168}
169
170func TestGlobHiddenFiles(t *testing.T) {
171 dir := t.TempDir()
172 files := []string{
173 "visible.txt",
174 ".hidden.txt",
175 ".config/settings.json",
176 }
177 for _, f := range files {
178 p := filepath.Join(dir, f)
179 os.MkdirAll(filepath.Dir(p), 0o755)
180 os.WriteFile(p, []byte("content"), 0o644)
181 }
182
183 tool := &GlobTool{CWD: dir}
184
185 // Default: skip hidden
186 res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.*"}), nil)
187 if err != nil {
188 t.Fatal(err)
189 }
190 text := res.Content[0].(provider.TextBlock).Text
191 if strings.Contains(text, ".hidden.txt") || strings.Contains(text, "settings.json") {
192 t.Errorf("hidden files should be skipped by default: %s", text)
193 }
194 if !strings.Contains(text, "visible.txt") {
195 t.Errorf("visible.txt missing: %s", text)
196 }
197
198 // Hidden: true
199 res, err = tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "**/*", "hidden": true}), nil)
200 if err != nil {
201 t.Fatal(err)
202 }
203 text = res.Content[0].(provider.TextBlock).Text
204 if !strings.Contains(text, ".hidden.txt") || !strings.Contains(text, ".config/settings.json") {
205 t.Errorf("hidden files missing when hidden=true: %s", text)
206 }
207}
208
209func TestGlobSubdirectoryPath(t *testing.T) {
210 dir := t.TempDir()
211 files := []string{
212 "root.txt",
213 "sub/a.txt",
214 "sub/b.txt",
215 "other/c.txt",
216 }
217 for _, f := range files {
218 p := filepath.Join(dir, f)
219 os.MkdirAll(filepath.Dir(p), 0o755)
220 os.WriteFile(p, []byte("content"), 0o644)
221 }
222
223 tool := &GlobTool{CWD: dir}
224 res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.txt", "path": "sub"}), nil)
225 if err != nil {
226 t.Fatal(err)
227 }
228 text := res.Content[0].(provider.TextBlock).Text
229 if strings.Contains(text, "root.txt") || strings.Contains(text, "other/c.txt") {
230 t.Errorf("result should only contain files from sub: %s", text)
231 }
232 if !strings.Contains(text, "sub/a.txt") || !strings.Contains(text, "sub/b.txt") {
233 t.Errorf("sub files missing: %s", text)
234 }
235}
236
237func TestGlobSubdirectoryHonorsParentGitignore(t *testing.T) {
238 dir := t.TempDir()
239 if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("sub/ignored.go\n"), 0o644); err != nil {
240 t.Fatal(err)
241 }
242 if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
243 t.Fatal(err)
244 }
245 for _, name := range []string{"ignored.go", "kept.go"} {
246 if err := os.WriteFile(filepath.Join(dir, "sub", name), []byte("content"), 0o644); err != nil {
247 t.Fatal(err)
248 }
249 }
250
251 tool := &GlobTool{CWD: dir}
252 res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go", "path": "sub"}), nil)
253 if err != nil {
254 t.Fatal(err)
255 }
256 text := res.Content[0].(provider.TextBlock).Text
257 if strings.Contains(text, "ignored.go") {
258 t.Fatalf("parent .gitignore rule was not respected: %s", text)
259 }
260 if !strings.Contains(text, "sub/kept.go") {
261 t.Fatalf("expected kept file in result: %s", text)
262 }
263}
264
265func TestGlobSymlinkSearchRoot(t *testing.T) {
266 dir := t.TempDir()
267 target := filepath.Join(dir, "target")
268 if err := os.Mkdir(target, 0o755); err != nil {
269 t.Fatal(err)
270 }
271 if err := os.WriteFile(filepath.Join(target, "found.go"), []byte("content"), 0o644); err != nil {
272 t.Fatal(err)
273 }
274 if err := os.Symlink(target, filepath.Join(dir, "link")); err != nil {
275 t.Skipf("symlinks unavailable: %v", err)
276 }
277
278 tool := &GlobTool{CWD: dir}
279 res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go", "path": "link"}), nil)
280 if err != nil {
281 t.Fatal(err)
282 }
283 if text := res.Content[0].(provider.TextBlock).Text; text != "link/found.go" {
284 t.Fatalf("unexpected symlink search result: %q", text)
285 }
286}
287
288func TestGlobNoMatches(t *testing.T) {
289 dir := t.TempDir()
290 os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0o644)
291
292 tool := &GlobTool{CWD: dir}
293 res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.rs"}), nil)
294 if err != nil {
295 t.Fatal(err)
296 }
297 text := res.Content[0].(provider.TextBlock).Text
298 if text != "No files matched the pattern." {
299 t.Fatalf("expected 'No files matched the pattern.', got: %q", text)
300 }
301 details := res.Details.(map[string]any)
302 if details["matches"] != 0 {
303 t.Fatalf("matches detail want 0, got %v", details["matches"])
304 }
305}
306
307func TestGlobValidationErrors(t *testing.T) {
308 dir := t.TempDir()
309 tool := &GlobTool{CWD: dir}
310
311 // Empty pattern
312 if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": ""}), nil); err == nil {
313 t.Fatal("expected error for empty pattern")
314 }
315
316 // Non-existent directory
317 if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go", "path": "nonexistent"}), nil); err == nil {
318 t.Fatal("expected error for non-existent path")
319 }
320
321 // File instead of directory
322 filePath := filepath.Join(dir, "file.txt")
323 os.WriteFile(filePath, []byte("text"), 0o644)
324 if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.txt", "path": "file.txt"}), nil); err == nil {
325 t.Fatal("expected error for file passed as path")
326 }
327}
328
329func TestGlobSandboxing(t *testing.T) {
330 dir := t.TempDir()
331 sandboxDir := filepath.Join(dir, "sandbox")
332 outsideDir := filepath.Join(dir, "outside")
333 os.MkdirAll(sandboxDir, 0o755)
334 os.MkdirAll(outsideDir, 0o755)
335
336 s := NewSandbox(sandboxDir)
337 s.Lock()
338
339 tool := &GlobTool{CWD: sandboxDir, Sandbox: s}
340
341 // Inside sandbox should succeed
342 res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go"}), nil)
343 if err != nil {
344 t.Fatalf("expected success inside sandbox, got %v", err)
345 }
346 if res.Content[0].(provider.TextBlock).Text != "No files matched the pattern." {
347 t.Fatalf("unexpected content: %v", res.Content[0])
348 }
349
350 // Absolute paths inside the sandbox should not leak the sandbox root.
351 insideFile := filepath.Join(sandboxDir, "inside.go")
352 if err := os.WriteFile(insideFile, []byte("content"), 0o644); err != nil {
353 t.Fatal(err)
354 }
355 res, err = tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go", "path": sandboxDir}), nil)
356 if err != nil {
357 t.Fatalf("expected absolute path inside sandbox to succeed, got %v", err)
358 }
359 if text := res.Content[0].(provider.TextBlock).Text; text != "inside.go" {
360 t.Fatalf("jailed result should be relative, got %q", text)
361 }
362
363 // Outside sandbox should fail.
364 if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go", "path": outsideDir}), nil); err == nil {
365 t.Fatal("expected sandboxing error for path outside sandbox")
366 }
367}