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 Details: map[string]any{
183 "engine": engine,
184 "path": t.Sandbox.DisplayPath(root, args.Path),
185 "exit_code": exitCode,
186 "process_exit_code": actualExitCode,
187 "matched": matched,
188 "bytes_truncated": output.bytesTruncated(),
189 "lines_truncated": linesTruncated,
190 "output_cancelled": outputCancelled,
191 },
192 }, nil
193}
194
195func parseGrepArgs(raw json.RawMessage) (grepArgs, error) {
196 if len(raw) == 0 || len(raw) > maxGrepInputBytes {
197 return grepArgs{}, fmt.Errorf("grep: invalid arguments")
198 }
199 var fields map[string]json.RawMessage
200 if err := json.Unmarshal(raw, &fields); err != nil || fields == nil {
201 return grepArgs{}, fmt.Errorf("grep: invalid arguments")
202 }
203 for key := range fields {
204 if key != "pattern" && key != "path" {
205 return grepArgs{}, fmt.Errorf("grep: unknown argument %q", key)
206 }
207 }
208 var args grepArgs
209 if err := json.Unmarshal(raw, &args); err != nil {
210 return grepArgs{}, fmt.Errorf("grep: invalid arguments: %w", err)
211 }
212 if _, ok := fields["pattern"]; !ok || args.Pattern == "" {
213 return grepArgs{}, fmt.Errorf("grep: pattern is required")
214 }
215 if _, ok := fields["path"]; !ok || args.Path == "" {
216 return grepArgs{}, fmt.Errorf("grep: path is required")
217 }
218 if !utf8.ValidString(args.Pattern) || !utf8.ValidString(args.Path) || strings.IndexByte(args.Pattern, 0) >= 0 || strings.IndexByte(args.Path, 0) >= 0 {
219 return grepArgs{}, fmt.Errorf("grep: arguments contain invalid characters")
220 }
221 return args, nil
222}
223
224func (t *GrepTool) searchExecutable() (string, string, error) {
225 lookup := t.LookPath
226 if lookup == nil {
227 lookup = exec.LookPath
228 }
229 if path, err := lookup("rg"); err == nil && path != "" {
230 return "rg", path, nil
231 }
232 if path, err := lookup("grep"); err == nil && path != "" {
233 return "grep", path, nil
234 }
235 return "", "", fmt.Errorf("grep: neither rg nor grep is available")
236}
237
238func grepCommandArgs(engine, pattern, root string) []string {
239 if engine == "rg" {
240 // These flags avoid headings and terminal color while retaining rg's
241 // fast default ignore and binary-file filtering behavior.
242 return []string{"--no-heading", "--line-number", "--color=never", "-e", pattern, root}
243 }
244 // -e keeps patterns beginning with '-' from being parsed as options. The
245 // absolute resolved root also makes this portable across GNU and BSD grep.
246 return []string{"-r", "-n", "-H", "-I", "-e", pattern, root}
247}
248
249type grepOutput struct {
250 mu sync.Mutex
251 data bytes.Buffer
252 truncated bool
253 cancelled bool
254 cancel func()
255 progress func(string)
256}
257
258func (w *grepOutput) Write(p []byte) (int, error) {
259 w.mu.Lock()
260 room := maxGrepOutputBytes - grepTruncationReserve - w.data.Len()
261 var streamed string
262 shouldCancel := false
263 if room <= 0 {
264 if !w.truncated {
265 w.truncated = true
266 w.cancelled = true
267 shouldCancel = true
268 }
269 } else if len(p) > room {
270 _, _ = w.data.Write(p[:room])
271 w.truncated = true
272 w.cancelled = true
273 shouldCancel = true
274 streamed = string(p[:room])
275 } else {
276 _, _ = w.data.Write(p)
277 streamed = string(p)
278 }
279 cancel := w.cancel
280 progress := w.progress
281 w.mu.Unlock()
282
283 if shouldCancel && cancel != nil {
284 cancel()
285 }
286 if progress != nil && streamed != "" {
287 progress(streamed)
288 }
289 return len(p), nil
290}
291
292func (w *grepOutput) bytesTruncated() bool {
293 w.mu.Lock()
294 defer w.mu.Unlock()
295 return w.truncated
296}
297
298func (w *grepOutput) wasCancelled() bool {
299 w.mu.Lock()
300 defer w.mu.Unlock()
301 return w.cancelled
302}
303
304func (w *grepOutput) text() (string, bool) {
305 w.mu.Lock()
306 defer w.mu.Unlock()
307 text := w.data.String()
308 lineCount := strings.Count(text, "\n")
309 if text != "" && !strings.HasSuffix(text, "\n") {
310 lineCount++
311 }
312 truncatedLines := lineCount > maxGrepOutputLines
313 if truncatedLines {
314 lines := strings.SplitAfter(text, "\n")
315 text = strings.Join(lines[:maxGrepOutputLines], "")
316 }
317 if w.truncated || truncatedLines {
318 if text != "" && !strings.HasSuffix(text, "\n") {
319 text += "\n"
320 }
321 if w.truncated {
322 text += fmt.Sprintf("... [truncated at %d bytes]\n", maxGrepOutputBytes)
323 }
324 if truncatedLines {
325 text += fmt.Sprintf("... [truncated at %d lines]\n", maxGrepOutputLines)
326 }
327 }
328 return text, truncatedLines
329}