Skip to main content

agent_engine/tools/
grep.rs

1//! The `grep` tool: ripgrep's engine in-process (`tools::search`), with the
2//! output `grep -rn` gave: `path:line:text` for matches, `path-line-text` for
3//! context lines, `--` between groups when `context` is set, and no path
4//! prefix when `path` is a file.
5
6use super::search::{self, content, pattern};
7use super::{resolve_path_in, Tool, ToolContext};
8use crate::{Result, RuntimeError};
9use serde_json::{json, Value};
10use std::time::Duration;
11
12/// How long one search may run before it returns what it has.
13const TIMEOUT: Duration = Duration::from_secs(15);
14
15/// Most context lines either side of a match.
16const MAX_CONTEXT: usize = 1000;
17
18pub struct GrepTool;
19
20#[async_trait::async_trait]
21impl Tool for GrepTool {
22    fn effect(&self) -> crate::tools::catalog::ToolEffect {
23        crate::tools::catalog::ToolEffect::ReadOnly
24    }
25
26    fn origin(&self) -> crate::tools::ToolOrigin {
27        crate::tools::ToolOrigin::Builtin
28    }
29
30    fn name(&self) -> &str {
31        "grep"
32    }
33
34    fn description(&self) -> &str {
35        "Search file contents with a regex (Rust regex syntax, as in ripgrep). Returns matching lines with file paths and line numbers. Respects .gitignore (use no_ignore to include ignored files); hidden files are searched, binary files skipped."
36    }
37
38    fn parameters(&self) -> Value {
39        json!({
40            "type": "object",
41            "properties": {
42                "pattern": {
43                    "type": "string",
44                    "description": "Regex pattern to search for"
45                },
46                "path": {
47                    "type": "string",
48                    "description": "File or directory to search in (default: current directory)"
49                },
50                "include": {
51                    "type": "string",
52                    "description": "Glob pattern to filter files (e.g. \"*.rs\", \"*.py\")"
53                },
54                "context": {
55                    "type": "integer",
56                    "description": "Number of context lines to show before and after each match"
57                },
58                "no_ignore": {
59                    "type": "boolean",
60                    "description": "Also search files skipped by .gitignore/.ignore, node_modules and target (default false)"
61                }
62            },
63            "required": ["pattern"]
64        })
65    }
66
67    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
68        let request = request(&params, &ctx)?;
69        run_search(request, ctx.capabilities.launch_cancel.clone(), TIMEOUT).await
70    }
71}
72
73/// The call's parameters, resolved against the session.
74fn request(params: &Value, ctx: &ToolContext) -> Result<content::ContentRequest> {
75    let regex = params["pattern"]
76        .as_str()
77        .ok_or_else(|| RuntimeError::Tool("Missing pattern parameter".to_string()))?;
78    let root = resolve_path_in(
79        params["path"].as_str().unwrap_or("."),
80        ctx.capabilities.cwd.as_deref(),
81    );
82    let matcher = pattern::content_matcher(regex).map_err(RuntimeError::Tool)?;
83    let include = match params["include"].as_str().map(str::trim) {
84        Some(glob) if !glob.is_empty() => {
85            Some(pattern::include_filter(glob).map_err(RuntimeError::Tool)?)
86        }
87        _ => None,
88    };
89    let context = params["context"]
90        .as_u64()
91        .map(|n| usize::try_from(n).unwrap_or(usize::MAX).min(MAX_CONTEXT));
92    Ok(content::ContentRequest {
93        root,
94        matcher,
95        include,
96        context,
97        no_ignore: params["no_ignore"].as_bool().unwrap_or(false),
98        cwd: ctx.capabilities.cwd.clone(),
99        cap: ctx.limits.max_tool_buffer,
100    })
101}
102
103/// Runs the search on the blocking pool and renders the result. `turn`
104/// cancelled (or this future dropped) stops it with partial results.
105async fn run_search(
106    request: content::ContentRequest,
107    turn: Option<crate::CancellationToken>,
108    timeout: Duration,
109) -> Result<String> {
110    let cap = request.cap;
111    let outcome = search::run_blocking(timeout, turn, move |control| {
112        content::search(&request, control, search::open_regular)
113    })
114    .await?
115    .map_err(RuntimeError::Tool)?;
116    content::render(outcome, cap, timeout)
117}
118
119#[cfg(test)]
120mod tests {
121    use super::super::test_helpers::create_tool_context;
122    use super::*;
123    use crate::tools::Tool;
124    use serde_json::json;
125    use std::path::Path;
126
127    fn write(root: &Path, rel: &str, body: &str) {
128        let path = rel.split('/').fold(root.to_path_buf(), |p, c| p.join(c));
129        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
130        std::fs::write(path, body).unwrap();
131    }
132
133    #[test]
134    fn test_grep_tool_schema() {
135        let tool = GrepTool;
136        assert_eq!(tool.name(), "grep");
137        assert!(tool.description().contains("no_ignore"));
138        assert!(tool.description().contains(".gitignore"));
139
140        let params = tool.parameters();
141        assert_eq!(params["type"], "object");
142        assert_eq!(params["required"], json!(["pattern"]));
143        for (name, ty) in [
144            ("pattern", "string"),
145            ("path", "string"),
146            ("include", "string"),
147            ("context", "integer"),
148            ("no_ignore", "boolean"),
149        ] {
150            assert_eq!(params["properties"][name]["type"], ty, "{name}");
151        }
152    }
153
154    /// A file as the path: `N:text` lines, exactly as `grep -rn` printed.
155    #[tokio::test]
156    async fn test_grep_tool_execution() {
157        let dir = tempfile::tempdir().unwrap();
158        let file = dir.path().join("a.txt");
159        std::fs::write(&file, "hello world\nfoo bar\nhello again").unwrap();
160
161        let params = json!({ "pattern": "hello", "path": file.to_string_lossy() });
162        let result = GrepTool
163            .execute(params, create_tool_context())
164            .await
165            .unwrap();
166        assert_eq!(result, "1:hello world\n3:hello again\n");
167    }
168
169    /// The default path is the session cwd joined with `.`, and paths keep
170    /// that form (`<cwd>/./src/a.txt`), as `grep -rn` printed them.
171    #[tokio::test]
172    async fn default_path_is_the_session_cwd() {
173        let dir = tempfile::tempdir().unwrap();
174        write(dir.path(), "src/a.txt", "x\nneedle\n");
175        let mut ctx = create_tool_context();
176        ctx.capabilities.cwd = Some(dir.path().to_path_buf());
177
178        let result = GrepTool
179            .execute(json!({ "pattern": "needle" }), ctx)
180            .await
181            .unwrap();
182        let shown = dir.path().join(".").join("src").join("a.txt");
183        assert_eq!(result, format!("{}:2:needle\n", shown.display()));
184    }
185
186    #[tokio::test]
187    async fn invalid_regex_is_a_tool_error() {
188        let dir = tempfile::tempdir().unwrap();
189        let params = json!({ "pattern": "fn main() {", "path": dir.path().to_string_lossy() });
190        let err = GrepTool
191            .execute(params, create_tool_context())
192            .await
193            .unwrap_err()
194            .to_string();
195        assert!(err.contains("invalid regex"), "{err}");
196        assert!(err.contains("Hint:"), "{err}");
197    }
198
199    /// A missing path is an error, never "No matches found.".
200    #[tokio::test]
201    async fn missing_path_is_a_tool_error() {
202        let dir = tempfile::tempdir().unwrap();
203        let missing = dir.path().join("nope");
204        let params = json!({ "pattern": "x", "path": missing.to_string_lossy() });
205        let err = GrepTool
206            .execute(params, create_tool_context())
207            .await
208            .unwrap_err()
209            .to_string();
210        assert!(err.contains("Path not found"), "{err}");
211        assert!(!err.contains("No matches"), "{err}");
212    }
213
214    #[tokio::test]
215    async fn no_ignore_and_include_reach_the_engine() {
216        let dir = tempfile::tempdir().unwrap();
217        let root = dir.path();
218        write(root, "node_modules/m.rs", "hit\n");
219        write(root, "src/a.rs", "hit\n");
220        write(root, "src/b.txt", "hit\n");
221        let path = root.to_string_lossy();
222
223        let params = json!({ "pattern": "hit", "path": path, "include": "*.rs" });
224        let result = GrepTool
225            .execute(params, create_tool_context())
226            .await
227            .unwrap();
228        let a = root.join("src").join("a.rs").display().to_string();
229        assert_eq!(result, format!("{a}:1:hit\n"));
230
231        let params =
232            json!({ "pattern": "hit", "path": path, "include": "*.rs", "no_ignore": true });
233        let result = GrepTool
234            .execute(params, create_tool_context())
235            .await
236            .unwrap();
237        let m = root.join("node_modules").join("m.rs").display().to_string();
238        assert_eq!(result, format!("{m}:1:hit\n{a}:1:hit\n"));
239    }
240
241    #[tokio::test]
242    async fn output_is_capped_at_max_tool_buffer() {
243        let dir = tempfile::tempdir().unwrap();
244        std::fs::write(dir.path().join("a.txt"), "hit\n".repeat(1000)).unwrap();
245        let mut ctx = create_tool_context();
246        ctx.limits.max_tool_buffer = 100;
247        let params =
248            json!({ "pattern": "hit", "path": dir.path().join("a.txt").to_string_lossy() });
249        let result = GrepTool.execute(params, ctx).await.unwrap();
250        let (body, footer) = result.split_once("\n\n").unwrap();
251        assert!(body.len() < 100 && body.ends_with(":hit"), "{body}");
252        assert!(
253            body.len() > 80,
254            "stopped well short of the cap: {}",
255            body.len()
256        );
257        assert_eq!(footer, "... (output truncated, more than 100 total bytes)");
258    }
259
260    /// The cut falls on a whole line, then the footer.
261    #[tokio::test]
262    async fn capped_output_ends_on_a_whole_line_with_the_footer() {
263        let dir = tempfile::tempdir().unwrap();
264        let file = dir.path().join("a.txt");
265        std::fs::write(&file, "aaaa\nbbbb\ncccc\n").unwrap();
266        let mut ctx = create_tool_context();
267        ctx.limits.max_tool_buffer = 16;
268        let params = json!({ "pattern": "[a-c]", "path": file.to_string_lossy() });
269        let result = GrepTool.execute(params, ctx).await.unwrap();
270        assert_eq!(
271            result,
272            "1:aaaa\n2:bbbb\n\n... (output truncated, more than 16 total bytes)"
273        );
274    }
275
276    /// A cancelled turn stops the search: nothing found yet is an error.
277    #[tokio::test]
278    async fn turn_cancellation_reaches_the_search() {
279        let dir = tempfile::tempdir().unwrap();
280        write(dir.path(), "a.txt", "hit\n");
281        let turn = crate::CancellationToken::new();
282        turn.cancel();
283        let mut ctx = create_tool_context();
284        ctx.capabilities.launch_cancel = Some(turn);
285        let params = json!({ "pattern": "hit", "path": dir.path().to_string_lossy() });
286        let err = GrepTool.execute(params, ctx).await.unwrap_err().to_string();
287        assert!(err.contains("Grep was cancelled"), "{err}");
288    }
289
290    /// The deadline reaches the blocking search: with no time at all the
291    /// call is a timeout error, not "No matches found.".
292    #[tokio::test]
293    async fn deadline_reaches_the_search() {
294        let dir = tempfile::tempdir().unwrap();
295        write(dir.path(), "a.txt", "hit\n");
296        let params = json!({ "pattern": "hit", "path": dir.path().to_string_lossy() });
297        let request = request(&params, &create_tool_context()).unwrap();
298        let err = run_search(request, None, Duration::ZERO)
299            .await
300            .unwrap_err()
301            .to_string();
302        assert!(err.contains("timed out"), "{err}");
303    }
304
305    // ── Rendering (scope §8: what a result says) ─────────────────────────
306
307    /// A tool error's own message (Display adds "Tool execution failed: ").
308    fn tool_msg(e: RuntimeError) -> String {
309        match e {
310            RuntimeError::Tool(msg) => msg,
311            other => other.to_string(),
312        }
313    }
314
315    const CAP: usize = 256 * 1024;
316
317    /// What one search found: `text`, with every file read.
318    fn outcome(text: &str) -> content::ContentOutcome {
319        content::ContentOutcome {
320            text: text.to_string(),
321            searched: 1,
322            ..Default::default()
323        }
324    }
325
326    /// Nothing found and nothing failed is exactly "No matches found.".
327    #[test]
328    fn nothing_found_is_no_matches() {
329        assert_eq!(
330            content::render(outcome(""), CAP, TIMEOUT).unwrap(),
331            "No matches found."
332        );
333    }
334
335    /// Errors with nothing read are a tool error carrying the first error,
336    /// never "No matches found.".
337    #[test]
338    fn errors_without_output_are_an_error() {
339        let mut failed = outcome("");
340        failed.searched = 0;
341        failed.errors.add("/p/nope: No such file or directory");
342        let err = content::render(failed, CAP, TIMEOUT)
343            .map_err(tool_msg)
344            .unwrap_err();
345        assert_eq!(err, "/p/nope: No such file or directory");
346        assert!(!err.contains("No matches"), "{err}");
347        assert!(!err.contains("invalid regex"), "{err}");
348    }
349
350    /// Matches that come with errors keep the matches and add one note with
351    /// the error count and the first error.
352    #[test]
353    fn matches_with_errors_keep_the_matches_and_add_a_note() {
354        let mut out = outcome("/p/x:1:alpha\n");
355        out.errors.add("/p/a: Permission denied");
356        out.errors.add("/p/b: Permission denied");
357        assert_eq!(
358            content::render(out, CAP, TIMEOUT).unwrap(),
359            "/p/x:1:alpha\n\n[2 errors while searching, results may be incomplete. First: /p/a: Permission denied]"
360        );
361    }
362
363    #[test]
364    fn a_timeout_keeps_partial_output_with_a_note() {
365        let mut partial = outcome("/p/x:1:alpha\n");
366        partial.stop = Some(search::Stop::TimedOut);
367        assert_eq!(
368            content::render(partial, CAP, TIMEOUT).unwrap(),
369            "/p/x:1:alpha\n\n... (grep timed out after 15s; results are partial, more may be missing)"
370        );
371
372        let mut nothing = outcome("");
373        nothing.stop = Some(search::Stop::TimedOut);
374        let err = content::render(nothing, CAP, TIMEOUT)
375            .map_err(tool_msg)
376            .unwrap_err();
377        assert!(
378            err.starts_with("Grep timed out after 15s before finding a match"),
379            "{err}"
380        );
381    }
382
383    // ── The dialect end to end (scope §8 rows 1–8, 13, 17) ──────────────
384
385    const TEXT: &str = "alpha\nbeta\ngamma\nalpha|beta\nfoo(bar\nfoo{x\ncolor\ncolour\naaa\nxx\n123\naa\nword here\nfn main() {\n";
386
387    /// A dir holding `t.txt` with [`TEXT`].
388    fn text_dir() -> tempfile::TempDir {
389        let dir = tempfile::tempdir().unwrap();
390        write(dir.path(), "t.txt", TEXT);
391        dir
392    }
393
394    async fn grep_in(path: &Path, pattern: &str) -> Result<String> {
395        let params = json!({ "pattern": pattern, "path": path });
396        GrepTool.execute(params, create_tool_context()).await
397    }
398
399    /// The matched text of each `N:text` line (a file root has no path).
400    fn lines_of(out: &str) -> Vec<&str> {
401        out.lines()
402            .filter_map(|l| l.split_once(':').map(|(_, text)| text))
403            .collect()
404    }
405
406    /// Row 1, the reproduction: `alpha|beta` was BRE, so it matched nothing.
407    #[tokio::test]
408    async fn alternation_matches_both_lines() {
409        let dir = text_dir();
410        let out = grep_in(&dir.path().join("t.txt"), "alpha|beta")
411            .await
412            .unwrap();
413        assert_eq!(lines_of(&out), ["alpha", "beta", "alpha|beta"], "{out}");
414    }
415
416    /// `+`, `?`, `{2}`, groups and POSIX classes.
417    #[tokio::test]
418    async fn quantifiers_groups_and_classes_work() {
419        let dir = text_dir();
420        let file = dir.path().join("t.txt");
421        for (pattern, want) in [
422            ("^a+$", &["aaa", "aa"][..]),
423            ("colou?r", &["color", "colour"][..]),
424            ("^x{2}$", &["xx"][..]),
425            ("^(al|be)", &["alpha", "beta", "alpha|beta"][..]),
426            ("^[[:digit:]]+$", &["123"][..]),
427        ] {
428            let out = grep_in(&file, pattern).await.unwrap();
429            assert_eq!(lines_of(&out), want, "{pattern}: {out}");
430        }
431    }
432
433    /// Row 3: `\(` is a literal paren (it was an invalid BRE, reported as
434    /// "No matches found.").
435    #[tokio::test]
436    async fn escaped_paren_is_literal() {
437        let dir = text_dir();
438        let out = grep_in(&dir.path().join("t.txt"), "foo\\(").await.unwrap();
439        assert_eq!(lines_of(&out), ["foo(bar"], "{out}");
440    }
441
442    /// An invalid pattern is a tool error with the parser's message and a
443    /// hint.
444    #[tokio::test]
445    async fn invalid_pattern_is_an_error_not_no_matches() {
446        let dir = text_dir();
447        let err = grep_in(&dir.path().join("t.txt"), "foo(")
448            .await
449            .map_err(tool_msg)
450            .unwrap_err();
451        assert!(err.starts_with("invalid regex: "), "{err}");
452        assert!(err.contains("unclosed group"), "{err}");
453        assert!(err.contains("Hint: the pattern is a Rust regex"), "{err}");
454        assert!(!err.contains("No matches"), "{err}");
455    }
456
457    /// A genuine miss under a directory is "No matches found." plus the note
458    /// that ignored files weren't searched.
459    #[tokio::test]
460    async fn genuine_no_match_says_no_matches_found() {
461        let dir = text_dir();
462        assert_eq!(
463            grep_in(dir.path(), "zebra").await.unwrap(),
464            format!("No matches found.\n\n{}", search::IGNORED_NOTE)
465        );
466    }
467
468    /// A file the ignore rules exclude (a `.env`, typically) is missed, the
469    /// miss says how to include it, and `no_ignore` finds it.
470    #[tokio::test]
471    async fn an_ignored_file_is_missed_with_a_note_and_no_ignore_finds_it() {
472        let dir = tempfile::tempdir().unwrap();
473        write(dir.path(), ".ignore", ".env\n");
474        write(dir.path(), ".env", "API_TOKEN=x\n");
475        let missed = grep_in(dir.path(), "API_TOKEN").await.unwrap();
476        assert!(missed.starts_with("No matches found."), "{missed}");
477        assert!(missed.contains("no_ignore: true"), "{missed}");
478        let params = json!({ "pattern": "API_TOKEN", "path": dir.path(), "no_ignore": true });
479        let found = GrepTool
480            .execute(params, create_tool_context())
481            .await
482            .unwrap();
483        assert!(found.contains("API_TOKEN=x"), "{found}");
484    }
485
486    /// No note when nothing was ignored: `no_ignore` set, or one file searched.
487    #[tokio::test]
488    async fn no_ignored_note_with_no_ignore_or_a_file_root() {
489        let dir = text_dir();
490        let params = json!({ "pattern": "zebra", "path": dir.path(), "no_ignore": true });
491        assert_eq!(
492            GrepTool
493                .execute(params, create_tool_context())
494                .await
495                .unwrap(),
496            "No matches found."
497        );
498        assert_eq!(
499            grep_in(&dir.path().join("t.txt"), "zebra").await.unwrap(),
500            "No matches found."
501        );
502    }
503
504    /// Rows 2 and 4–8 as Rust regex has them (Phase 1 pinned GNU `grep -E`
505    /// here, where most of these differ).
506    #[tokio::test]
507    async fn rust_regex_edge_cases_are_pinned() {
508        let dir = text_dir();
509        let file = dir.path().join("t.txt");
510        // Row 2: `\|` is a literal pipe, not alternation.
511        let out = grep_in(&file, "alpha\\|beta").await.unwrap();
512        assert_eq!(lines_of(&out), ["alpha|beta"], "{out}");
513        // Row 6: `\d` is a digit class and `(?i)` a flag.
514        let out = grep_in(&file, "\\d+").await.unwrap();
515        assert_eq!(lines_of(&out), ["123"], "{out}");
516        let out = grep_in(&file, "(?i)ALPHA").await.unwrap();
517        assert_eq!(lines_of(&out), ["alpha", "alpha|beta"], "{out}");
518        // Row 7: word boundaries and `\w`/`\s`.
519        let out = grep_in(&file, "\\bword\\b").await.unwrap();
520        assert_eq!(lines_of(&out), ["word here"], "{out}");
521        let out = grep_in(&file, "\\w+\\s").await.unwrap();
522        assert!(lines_of(&out).contains(&"word here"), "{out}");
523        // Rows 4, 5 and 8: an unescaped `{`, a leading `*`, back-references
524        // and lookaround don't parse: a tool error, never a silent miss.
525        for pattern in ["fn main() {", "foo{", "*foo", "^(a)\\1$", "(?=x)"] {
526            let err = grep_in(&file, pattern).await.map_err(tool_msg).unwrap_err();
527            assert!(err.starts_with("invalid regex: "), "{pattern}: {err}");
528        }
529    }
530
531    /// Row 13: binary matches are surfaced, alone or next to text matches.
532    #[tokio::test]
533    async fn binary_file_matches_are_surfaced() {
534        let only = tempfile::tempdir().unwrap();
535        write(only.path(), "blob.bin", "abc\0alpha\n");
536        let out = grep_in(only.path(), "alpha").await.unwrap();
537        assert!(
538            out.starts_with("[binary file matches, lines not shown: "),
539            "{out}"
540        );
541        assert!(out.contains("blob.bin"), "{out}");
542
543        let mixed = tempfile::tempdir().unwrap();
544        write(mixed.path(), "blob.bin", "abc\0alpha\n");
545        write(mixed.path(), "t.txt", "alpha\n");
546        let out = grep_in(mixed.path(), "alpha").await.unwrap();
547        assert!(out.contains("t.txt:1:alpha"), "{out}");
548        assert!(
549            out.contains("[binary file matches, lines not shown: ") && out.contains("blob.bin"),
550            "{out}"
551        );
552        assert!(!out.contains("Binary file"), "{out}");
553    }
554
555    /// Row 17: an unreadable file next to matches gives the matches plus a
556    /// note. Skipped when permissions don't apply (root).
557    #[cfg(unix)]
558    #[tokio::test]
559    async fn unreadable_file_beside_matches_gives_matches_and_a_note() {
560        use std::os::unix::fs::PermissionsExt;
561        let dir = tempfile::tempdir().unwrap();
562        write(dir.path(), "t.txt", "alpha\n");
563        write(dir.path(), "locked.txt", "alpha\n");
564        let locked = dir.path().join("locked.txt");
565        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
566        if std::fs::read(&locked).is_ok() {
567            eprintln!("skipped: permissions are not enforced for this user");
568            return;
569        }
570        let out = grep_in(dir.path(), "alpha").await.unwrap();
571        assert!(out.contains("t.txt:1:alpha"), "{out}");
572        let note = format!(
573            "[1 error while searching, results may be incomplete. First: {}: Permission denied",
574            locked.display()
575        );
576        assert!(out.contains(&note), "{out}");
577    }
578}