1package tools
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "strings"
13 "sync"
14 "time"
15 "unicode/utf8"
16
17 "github.com/bnema/zut/packages/core"
18 "github.com/bnema/zut/packages/provider"
19)
20
21const (
22 maxGrepInputBytes = 16 * 1024
23 maxGrepOutputBytes = 50 * 1024
24 maxGrepOutputLines = 2000
25 grepTruncationReserve = 128
26 grepInternalTimeout = 30 * time.Second
27)
28
29// GrepTool searches text files below a requested path without invoking a
30// shell. It prefers ripgrep and uses the system grep as a portable fallback.
31// LookPath is injectable for deterministic tests; nil uses exec.LookPath.
32type GrepTool struct {
33 CWD string
34 Sandbox *Sandbox
35 LookPath func(string) (string, error)
36}
37
38var _ core.Tool = (*GrepTool)(nil)
39
40type grepArgs struct {
41 Pattern string `json:"pattern"`
42 Path string `json:"path"`
43}
44
45const grepSchema = `{"type":"object","properties":{"pattern":{"type":"string","description":"Regular expression to search for."},"path":{"type":"string","description":"File or directory to search recursively."}},"required":["pattern","path"],"additionalProperties":false}`
46
47func (t *GrepTool) Name() string { return "grep" }
48
49func (t *GrepTool) Description() string {
50 return "Search files for a regular expression using ripgrep when available, with a portable grep fallback; fallback regex syntax and ignore/hidden-file behavior may differ."
51}
52
53func (t *GrepTool) Schema() json.RawMessage { return json.RawMessage(grepSchema) }
54
55// Execute searches the requested file or directory. The search root is
56// checked before the executable is selected or started, so a denied path
57// cannot be used to probe or read through the subprocess.
58func (t *GrepTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
59 return t.executeWithTimeout(ctx, raw, progress, grepInternalTimeout)
60}
61
62// grepTimeoutError distinguishes the search's hard deadline from a caller
63// cancellation or deadline, so the caller gets actionable search guidance.
64type grepTimeoutError struct {
65 after time.Duration
66}
67
68func (e *grepTimeoutError) Error() string {
69 return fmt.Sprintf("grep: search timed out after %s; try narrowing the path or glob", e.after)
70}
71
72func (e *grepTimeoutError) Timeout() bool { return true }
73
74// executeWithTimeout keeps the production deadline in Execute while allowing
75// deadline behavior to be tested without waiting 30 seconds.
76func (t *GrepTool) executeWithTimeout(ctx context.Context, raw json.RawMessage, progress func(string), timeout time.Duration) (core.ToolResult, error) {
77 args, err := parseGrepArgs(raw)
78 if err != nil {
79 return core.ToolResult{}, err
80 }
81 if err := ctx.Err(); err != nil {
82 return core.ToolResult{}, err
83 }
84
85 root := filepath.Clean(resolvePath(t.CWD, args.Path))
86 if !filepath.IsAbs(root) {
87 root, err = filepath.Abs(root)
88 if err != nil {
89 return core.ToolResult{}, err
90 }
91 }
92 if err := t.Sandbox.CheckReadPath(root); err != nil {
93 return core.ToolResult{}, err
94 }
95 info, err := os.Stat(root)
96 if err != nil {
97 return core.ToolResult{}, err
98 }
99 if !info.IsDir() && !info.Mode().IsRegular() {
100 return core.ToolResult{}, fmt.Errorf("grep: %s is not a regular file or directory", args.Path)
101 }
102
103 engine, executable, err := t.searchExecutable()
104 if err != nil {
105 return core.ToolResult{}, err
106 }
107
108 commandArgs := grepCommandArgs(engine, args.Pattern, root)
109 // WithTimeout preserves a sooner deadline or cancellation from ctx; the
110 // internal deadline can only add a limit when the caller has not already
111 // supplied a stricter one.
112 runCtx, cancel := context.WithTimeout(ctx, timeout)
113 defer cancel()
114 cmd := exec.CommandContext(runCtx, executable, commandArgs...)
115 if info.IsDir() {
116 cmd.Dir = root
117 } else {
118 cmd.Dir = filepath.Dir(root)
119 }
120 // Reuse the process-tree cancellation behavior used by the direct command
121 // tools. This does not invoke a shell; executable is run directly above.
122 configureBashProcess(cmd, nil)
123
124 output := &grepOutput{
125 progress: progress,
126 cancel: func() {
127 if cmd.Cancel != nil {
128 _ = cmd.Cancel()
129 }
130 },
131 }
132 cmd.Stdout = output
133 cmd.Stderr = output
134 runErr := cmd.Run()
135 if err := ctx.Err(); err != nil {
136 return core.ToolResult{}, err
137 }
138 if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
139 return core.ToolResult{}, &grepTimeoutError{after: timeout}
140 }
141
142 actualExitCode := 0
143 if runErr != nil {
144 var exitErr *exec.ExitError
145 if !errors.As(runErr, &exitErr) {
146 return core.ToolResult{}, fmt.Errorf("grep: run %s: %w", engine, runErr)
147 }
148 actualExitCode = exitErr.ExitCode()
149 }
150
151 outputCancelled := output.wasCancelled()
152 matched := actualExitCode == 0 || outputCancelled
153 noMatch := actualExitCode == 1
154 if noMatch {
155 // Both rg and grep use exit status 1 for a successful search with no
156 // matches. Do not turn that normal result into a failed tool call.
157 matched = false
158 }
159
160 text, linesTruncated := output.text()
161 if noMatch && strings.TrimSpace(text) == "" {
162 text = "No matches found.\n"
163 }
164 if text == "" && actualExitCode != 0 {
165 text = fmt.Sprintf("%s exited with status %d.\n", engine, actualExitCode)
166 }
167
168 exitCode := actualExitCode
169 if noMatch || outputCancelled {
170 exitCode = 0
171 }
172 isError := actualExitCode > 1 || actualExitCode < 0
173 if outputCancelled {
174 // Reaching the output bound is an intentional, successful stop rather
175 // than a failed search. Preserve the process status separately for
176 // diagnostics while reporting the normalized tool exit code above.
177 isError = false
178 }
179 return core.ToolResult{
180 Content: []provider.Content{provider.TextBlock{Text: text}},
181 IsError: isError,
182 Context: provider.ToolContext{Discovers: grepResultPaths(text, root, info.IsDir())},
183 Details: map[string]any{
184 "engine": engine,
185 "path": t.Sandbox.DisplayPath(root, args.Path),
186 "exit_code": exitCode,
187 "process_exit_code": actualExitCode,
188 "matched": matched,
189 "bytes_truncated": output.bytesTruncated(),
190 "lines_truncated": linesTruncated,
191 "output_cancelled": outputCancelled,
192 },
193 }, nil
194}
195
196func grepResultPaths(output, root string, rootIsDir bool) []string {
197 seen := make(map[string]bool)
198 var paths []string
199 for _, line := range strings.Split(output, "\n") {
200 candidate, _, ok := strings.Cut(line, ":")
201 if !ok || candidate == "" {
202 continue
203 }
204 path := candidate
205 if !filepath.IsAbs(path) {
206 base := root
207 if !rootIsDir {
208 base = filepath.Dir(root)
209 }
210 path = filepath.Join(base, path)
211 }
212 path = canonicalResourcePath(path)
213 if !seen[path] {
214 seen[path] = true
215 paths = append(paths, path)
216 }
217 }
218 return paths
219}
220
221func parseGrepArgs(raw json.RawMessage) (grepArgs, error) {
222 if len(raw) == 0 || len(raw) > maxGrepInputBytes {
223 return grepArgs{}, fmt.Errorf("grep: invalid arguments")
224 }
225 var fields map[string]json.RawMessage
226 if err := json.Unmarshal(raw, &fields); err != nil || fields == nil {
227 return grepArgs{}, fmt.Errorf("grep: invalid arguments")
228 }
229 for key := range fields {
230 if key != "pattern" && key != "path" {
231 return grepArgs{}, fmt.Errorf("grep: unknown argument %q", key)
232 }
233 }
234 var args grepArgs
235 if err := json.Unmarshal(raw, &args); err != nil {
236 return grepArgs{}, fmt.Errorf("grep: invalid arguments: %w", err)
237 }
238 if _, ok := fields["pattern"]; !ok || args.Pattern == "" {
239 return grepArgs{}, fmt.Errorf("grep: pattern is required")
240 }
241 if _, ok := fields["path"]; !ok || args.Path == "" {
242 return grepArgs{}, fmt.Errorf("grep: path is required")
243 }
244 if !utf8.ValidString(args.Pattern) || !utf8.ValidString(args.Path) || strings.IndexByte(args.Pattern, 0) >= 0 || strings.IndexByte(args.Path, 0) >= 0 {
245 return grepArgs{}, fmt.Errorf("grep: arguments contain invalid characters")
246 }
247 return args, nil
248}
249
250func (t *GrepTool) searchExecutable() (string, string, error) {
251 lookup := t.LookPath
252 if lookup == nil {
253 lookup = exec.LookPath
254 }
255 if path, err := lookup("rg"); err == nil && path != "" {
256 return "rg", path, nil
257 }
258 if path, err := lookup("grep"); err == nil && path != "" {
259 return "grep", path, nil
260 }
261 return "", "", fmt.Errorf("grep: neither rg nor grep is available")
262}
263
264func grepCommandArgs(engine, pattern, root string) []string {
265 if engine == "rg" {
266 // These flags avoid headings and terminal color while retaining rg's
267 // fast default ignore and binary-file filtering behavior.
268 return []string{"--no-heading", "--line-number", "--color=never", "-e", pattern, root}
269 }
270 // -e keeps patterns beginning with '-' from being parsed as options. The
271 // absolute resolved root also makes this portable across GNU and BSD grep.
272 return []string{"-r", "-n", "-H", "-I", "-e", pattern, root}
273}
274
275type grepOutput struct {
276 mu sync.Mutex
277 data bytes.Buffer
278 truncated bool
279 cancelled bool
280 cancel func()
281 progress func(string)
282}
283
284func (w *grepOutput) Write(p []byte) (int, error) {
285 w.mu.Lock()
286 room := maxGrepOutputBytes - grepTruncationReserve - w.data.Len()
287 var streamed string
288 shouldCancel := false
289 if room <= 0 {
290 if !w.truncated {
291 w.truncated = true
292 w.cancelled = true
293 shouldCancel = true
294 }
295 } else if len(p) > room {
296 _, _ = w.data.Write(p[:room])
297 w.truncated = true
298 w.cancelled = true
299 shouldCancel = true
300 streamed = string(p[:room])
301 } else {
302 _, _ = w.data.Write(p)
303 streamed = string(p)
304 }
305 cancel := w.cancel
306 progress := w.progress
307 w.mu.Unlock()
308
309 if shouldCancel && cancel != nil {
310 cancel()
311 }
312 if progress != nil && streamed != "" {
313 progress(streamed)
314 }
315 return len(p), nil
316}
317
318func (w *grepOutput) bytesTruncated() bool {
319 w.mu.Lock()
320 defer w.mu.Unlock()
321 return w.truncated
322}
323
324func (w *grepOutput) wasCancelled() bool {
325 w.mu.Lock()
326 defer w.mu.Unlock()
327 return w.cancelled
328}
329
330func (w *grepOutput) text() (string, bool) {
331 w.mu.Lock()
332 defer w.mu.Unlock()
333 text := w.data.String()
334 lineCount := strings.Count(text, "\n")
335 if text != "" && !strings.HasSuffix(text, "\n") {
336 lineCount++
337 }
338 truncatedLines := lineCount > maxGrepOutputLines
339 if truncatedLines {
340 lines := strings.SplitAfter(text, "\n")
341 text = strings.Join(lines[:maxGrepOutputLines], "")
342 }
343 if w.truncated || truncatedLines {
344 if text != "" && !strings.HasSuffix(text, "\n") {
345 text += "\n"
346 }
347 if w.truncated {
348 text += fmt.Sprintf("... [truncated at %d bytes]\n", maxGrepOutputBytes)
349 }
350 if truncatedLines {
351 text += fmt.Sprintf("... [truncated at %d lines]\n", maxGrepOutputLines)
352 }
353 }
354 return text, truncatedLines
355}