1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "runtime"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/bnema/zut/packages/core"
15 "github.com/bnema/zut/packages/provider"
16)
17
18func TestGrepPrefersRGAndFallsBackToGrep(t *testing.T) {
19 skipGrepScriptTestsOnWindows(t)
20 root := t.TempDir()
21 rg := writeGrepScript(t, "#!/bin/sh\nprintf 'rg selected\\n'\n")
22 grep := writeGrepScript(t, "#!/bin/sh\nprintf 'grep selected\\n'\n")
23
24 t.Run("rg", func(t *testing.T) {
25 tool := &GrepTool{CWD: root, LookPath: func(name string) (string, error) {
26 switch name {
27 case "rg":
28 return rg, nil
29 case "grep":
30 return grep, nil
31 default:
32 return "", os.ErrNotExist
33 }
34 }}
35 result := executeGrepTest(t, tool, root)
36 if got := grepText(result); got != "rg selected\n" {
37 t.Fatalf("output = %q, want rg output", got)
38 }
39 if grepDetails(result)["engine"] != "rg" {
40 t.Fatalf("engine = %v, want rg", grepDetails(result)["engine"])
41 }
42 })
43
44 t.Run("grep fallback", func(t *testing.T) {
45 tool := &GrepTool{CWD: root, LookPath: func(name string) (string, error) {
46 if name == "rg" {
47 return "", os.ErrNotExist
48 }
49 return grep, nil
50 }}
51 result := executeGrepTest(t, tool, root)
52 if got := grepText(result); got != "grep selected\n" {
53 t.Fatalf("output = %q, want grep output", got)
54 }
55 if grepDetails(result)["engine"] != "grep" {
56 t.Fatalf("engine = %v, want grep", grepDetails(result)["engine"])
57 }
58 })
59}
60
61func TestGrepChecksSandboxReadScopeBeforeRunning(t *testing.T) {
62 skipGrepScriptTestsOnWindows(t)
63 base := t.TempDir()
64 root := filepath.Join(base, "workspace")
65 if err := os.Mkdir(root, 0o755); err != nil {
66 t.Fatal(err)
67 }
68 outside := filepath.Join(base, "outside")
69 if err := os.Mkdir(outside, 0o755); err != nil {
70 t.Fatal(err)
71 }
72 sandbox := NewSandbox(root)
73 sandbox.Lock()
74 called := false
75 tool := &GrepTool{
76 CWD: root,
77 Sandbox: sandbox,
78 LookPath: func(string) (string, error) {
79 called = true
80 return "", errors.New("lookup should not run")
81 },
82 }
83 _, err := tool.Execute(context.Background(), grepArgsJSON("needle", outside), nil)
84 if err == nil || !strings.Contains(err.Error(), "jailed") {
85 t.Fatalf("error = %v, want sandbox denial", err)
86 }
87 if called {
88 t.Fatal("executable lookup ran before the sandbox check")
89 }
90}
91
92func TestGrepNoMatchIsSuccessful(t *testing.T) {
93 skipGrepScriptTestsOnWindows(t)
94 root := t.TempDir()
95 noMatch := writeGrepScript(t, "#!/bin/sh\nexit 1\n")
96 tool := &GrepTool{CWD: root, LookPath: lookupScripts(map[string]string{"rg": noMatch})}
97 result := executeGrepTest(t, tool, root)
98 if result.IsError {
99 t.Fatal("no-match result is an error")
100 }
101 if got := grepDetails(result)["exit_code"]; got != 0 {
102 t.Fatalf("reported exit code = %v, want 0", got)
103 }
104 if got := grepDetails(result)["process_exit_code"]; got != 1 {
105 t.Fatalf("process exit code = %v, want 1", got)
106 }
107 if got := grepText(result); got != "No matches found.\n" {
108 t.Fatalf("output = %q, want no-match message", got)
109 }
110}
111
112func TestGrepBoundsOutput(t *testing.T) {
113 skipGrepScriptTestsOnWindows(t)
114 root := t.TempDir()
115 script := writeGrepScript(t, "#!/bin/sh\ni=0\nwhile [ $i -lt 4000 ]; do\n printf 'matching line %04d with enough padding to hit the cap\\n' $i\n i=$((i + 1))\ndone\n")
116 tool := &GrepTool{CWD: root, LookPath: lookupScripts(map[string]string{"rg": script})}
117 result := executeGrepTest(t, tool, root)
118 text := grepText(result)
119 if !grepDetails(result)["bytes_truncated"].(bool) {
120 t.Fatal("bytes_truncated = false, want true")
121 }
122 if !strings.Contains(text, "truncated at") {
123 t.Fatalf("bounded output has no truncation marker: %q", text[len(text)-100:])
124 }
125 if len(text) > maxGrepOutputBytes {
126 t.Fatalf("bounded output length = %d, cap exceeded", len(text))
127 }
128}
129
130func TestGrepExactlyMaxOutputLinesIsNotTruncated(t *testing.T) {
131 input := strings.Repeat("matching line\n", maxGrepOutputLines)
132 output := &grepOutput{}
133 if _, err := output.Write([]byte(input)); err != nil {
134 t.Fatal(err)
135 }
136 got, truncated := output.text()
137 if truncated {
138 t.Fatal("exactly maxGrepOutputLines was marked truncated")
139 }
140 if got != input {
141 t.Fatalf("output was altered: got %d bytes, want %d", len(got), len(input))
142 }
143}
144
145func TestGrepCancellationPropagates(t *testing.T) {
146 skipGrepScriptTestsOnWindows(t)
147 root := t.TempDir()
148 started := filepath.Join(root, "started")
149 script := writeGrepScript(t, "#!/bin/sh\nprintf started > started\nsleep 10\n")
150 tool := &GrepTool{CWD: root, LookPath: lookupScripts(map[string]string{"rg": script})}
151 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
152 defer cancel()
153 resultCh := make(chan error, 1)
154 go func() {
155 _, err := tool.Execute(ctx, grepArgsJSON("needle", root), nil)
156 resultCh <- err
157 }()
158
159 startupDeadline := time.Now().Add(time.Second)
160 for {
161 if _, err := os.Stat(started); err == nil {
162 break
163 }
164 if time.Now().After(startupDeadline) {
165 t.Fatal("grep fixture did not start before the test deadline")
166 }
167 time.Sleep(5 * time.Millisecond)
168 }
169
170 select {
171 case err := <-resultCh:
172 if !errors.Is(err, context.DeadlineExceeded) {
173 t.Fatalf("error = %v, want deadline exceeded", err)
174 }
175 case <-time.After(time.Second):
176 t.Fatal("grep did not stop after context cancellation")
177 }
178}
179
180func TestGrepInternalTimeoutStopsSearch(t *testing.T) {
181 skipGrepScriptTestsOnWindows(t)
182 root := t.TempDir()
183 if err := os.WriteFile(filepath.Join(root, "hold"), nil, 0o644); err != nil {
184 t.Fatal(err)
185 }
186 script := writeGrepScript(t, "#!/bin/sh\nprintf 'started\\n'\nexec tail -f hold\n")
187 tool := &GrepTool{CWD: root, LookPath: lookupScripts(map[string]string{"rg": script})}
188
189 resultCh := make(chan error, 1)
190 go func() {
191 _, err := tool.executeWithTimeout(context.Background(), grepArgsJSON("needle", root), nil, 100*time.Millisecond)
192 resultCh <- err
193 }()
194
195 err := waitForGrepResult(t, resultCh)
196 var timeoutErr *grepTimeoutError
197 if !errors.As(err, &timeoutErr) {
198 t.Fatalf("error = %v, want internal grep timeout", err)
199 }
200 if errors.Is(err, context.DeadlineExceeded) {
201 t.Fatalf("internal timeout error = %v, want distinct caller deadline error", err)
202 }
203 if !strings.Contains(err.Error(), "try narrowing the path or glob") {
204 t.Fatalf("error = %v, want path/glob narrowing guidance", err)
205 }
206}
207
208func TestGrepCallerDeadlineTakesPrecedenceOverInternalTimeout(t *testing.T) {
209 skipGrepScriptTestsOnWindows(t)
210 root := t.TempDir()
211 if err := os.WriteFile(filepath.Join(root, "hold"), nil, 0o644); err != nil {
212 t.Fatal(err)
213 }
214 script := writeGrepScript(t, "#!/bin/sh\nprintf 'started\\n'\nexec tail -f hold\n")
215 tool := &GrepTool{CWD: root, LookPath: lookupScripts(map[string]string{"rg": script})}
216 ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
217 defer cancel()
218
219 resultCh := make(chan error, 1)
220 go func() {
221 _, err := tool.executeWithTimeout(ctx, grepArgsJSON("needle", root), nil, time.Second)
222 resultCh <- err
223 }()
224
225 err := waitForGrepResult(t, resultCh)
226 if !errors.Is(err, context.DeadlineExceeded) {
227 t.Fatalf("error = %v, want caller deadline exceeded", err)
228 }
229 var timeoutErr *grepTimeoutError
230 if errors.As(err, &timeoutErr) {
231 t.Fatalf("error = %v, want caller deadline rather than internal timeout", err)
232 }
233}
234
235func waitForGrepResult(t *testing.T, resultCh <-chan error) error {
236 t.Helper()
237 timer := time.NewTimer(2 * time.Second)
238 defer timer.Stop()
239 select {
240 case err := <-resultCh:
241 return err
242 case <-timer.C:
243 t.Fatal("grep fixture did not stop before the test deadline")
244 return nil
245 }
246}
247
248func TestGrepDoesNotRequireBashPermission(t *testing.T) {
249 skipGrepScriptTestsOnWindows(t)
250 root := t.TempDir()
251 script := writeGrepScript(t, "#!/bin/sh\nprintf 'read-only search\\n'\n")
252 sandbox := NewSandbox(root)
253 sandbox.Lock()
254 permissions := &PermissionSet{}
255 permissions.FS.Read = []string{root}
256 permissions.Bash.Mode = "none"
257 sandbox.SetPermissions(permissions)
258 tool := &GrepTool{CWD: root, Sandbox: sandbox, LookPath: lookupScripts(map[string]string{"rg": script})}
259 result := executeGrepTest(t, tool, root)
260 if got := grepText(result); got != "read-only search\n" {
261 t.Fatalf("output = %q", got)
262 }
263}
264
265func TestGrepSchemaValidation(t *testing.T) {
266 tool := &GrepTool{LookPath: func(string) (string, error) {
267 t.Fatal("schema-invalid input reached executable lookup")
268 return "", nil
269 }}
270 for _, tc := range []struct {
271 name string
272 raw string
273 }{
274 {"not object", `[]`},
275 {"missing pattern", `{"path":"."}`},
276 {"missing path", `{"pattern":"needle"}`},
277 {"empty pattern", `{"pattern":"","path":"."}`},
278 {"wrong type", `{"pattern":true,"path":"."}`},
279 {"unknown field", `{"pattern":"needle","path":".","shell":"echo"}`},
280 {"nul", "{\"pattern\":\"needle\\u0000\",\"path\":\".\"}"},
281 } {
282 t.Run(tc.name, func(t *testing.T) {
283 if _, err := tool.Execute(context.Background(), []byte(tc.raw), nil); err == nil {
284 t.Fatal("invalid arguments were accepted")
285 }
286 })
287 }
288
289 var schema map[string]any
290 if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
291 t.Fatal(err)
292 }
293 if schema["type"] != "object" {
294 t.Fatalf("schema type = %v", schema["type"])
295 }
296 if schema["additionalProperties"] != false {
297 t.Fatalf("schema additionalProperties = %v", schema["additionalProperties"])
298 }
299}
300
301func executeGrepTest(t *testing.T, tool *GrepTool, root string) core.ToolResult {
302 t.Helper()
303 result, err := tool.Execute(context.Background(), grepArgsJSON("needle", root), nil)
304 if err != nil {
305 t.Fatal(err)
306 }
307 return result
308}
309
310func grepDetails(result core.ToolResult) map[string]any {
311 return result.Details.(map[string]any)
312}
313
314func grepText(result core.ToolResult) string {
315 if len(result.Content) == 0 {
316 return ""
317 }
318 block, ok := result.Content[0].(provider.TextBlock)
319 if !ok {
320 return ""
321 }
322 return block.Text
323}
324
325func grepArgsJSON(pattern, path string) []byte {
326 return []byte(`{"pattern":` + strconvQuote(pattern) + `,"path":` + strconvQuote(path) + `}`)
327}
328
329func strconvQuote(s string) string {
330 data, _ := json.Marshal(s)
331 return string(data)
332}
333
334func lookupScripts(paths map[string]string) func(string) (string, error) {
335 return func(name string) (string, error) {
336 if path, ok := paths[name]; ok {
337 return path, nil
338 }
339 return "", os.ErrNotExist
340 }
341}
342
343func writeGrepScript(t *testing.T, content string) string {
344 t.Helper()
345 path := filepath.Join(t.TempDir(), "fake-grep")
346 if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
347 t.Fatal(err)
348 }
349 return path
350}
351
352func skipGrepScriptTestsOnWindows(t *testing.T) {
353 t.Helper()
354 if runtime.GOOS == "windows" {
355 t.Skip("test uses a POSIX executable fixture")
356 }
357}