Skip to main content

claude_agent/tools/
grep.rs

1//! Grep tool - content search with regex using ripgrep.
2
3use std::process::Stdio;
4
5use async_trait::async_trait;
6use schemars::JsonSchema;
7use serde::Deserialize;
8use tokio::process::Command;
9
10use super::SchemaTool;
11use super::context::ExecutionContext;
12use crate::types::ToolResult;
13
14#[derive(Debug, Deserialize, JsonSchema)]
15#[schemars(deny_unknown_fields)]
16pub struct GrepInput {
17    /// The regular expression pattern to search for in file contents
18    pub pattern: String,
19    /// File or directory to search in (rg PATH). Defaults to current working directory.
20    #[serde(default)]
21    pub path: Option<String>,
22    /// Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}") - maps to rg --glob
23    #[serde(default)]
24    pub glob: Option<String>,
25    /// File type to search (rg --type). Common types: js, py, rust, go, java, etc.
26    #[serde(default, rename = "type")]
27    pub file_type: Option<String>,
28    /// Output mode: "files_with_matches" shows only file paths (default), "content" shows matching lines, "count" shows match counts
29    #[serde(default)]
30    pub output_mode: Option<String>,
31    /// Case insensitive search (rg -i)
32    #[serde(default, rename = "-i")]
33    pub case_insensitive: Option<bool>,
34    /// Show line numbers in output (rg -n). Requires output_mode: "content". Defaults to true.
35    #[serde(default, rename = "-n")]
36    pub line_numbers: Option<bool>,
37    /// Number of lines to show after each match (rg -A). Requires output_mode: "content".
38    #[serde(default, rename = "-A")]
39    pub after_context: Option<u32>,
40    /// Number of lines to show before each match (rg -B). Requires output_mode: "content".
41    #[serde(default, rename = "-B")]
42    pub before_context: Option<u32>,
43    /// Number of lines to show before and after each match (rg -C). Requires output_mode: "content".
44    #[serde(default, rename = "-C")]
45    pub context: Option<u32>,
46    /// Enable multiline mode where . matches newlines and patterns can span lines (rg -U --multiline-dotall). Default: false.
47    #[serde(default)]
48    pub multiline: Option<bool>,
49    /// Limit output to first N lines/entries. Works across all output modes. Defaults to 0 (unlimited).
50    #[serde(default)]
51    pub head_limit: Option<usize>,
52    /// Skip first N lines/entries before applying head_limit. Works across all output modes. Defaults to 0.
53    #[serde(default)]
54    pub offset: Option<usize>,
55}
56
57#[derive(Debug, Clone, Copy, Default)]
58pub struct GrepTool;
59
60#[async_trait]
61impl SchemaTool for GrepTool {
62    type Input = GrepInput;
63
64    const NAME: &'static str = "Grep";
65    const DESCRIPTION: &'static str = r#"A powerful search tool built on ripgrep
66
67  Usage:
68  - ALWAYS use Grep for search tasks. NEVER invoke `grep` or `rg` as a Bash command. The Grep tool has been optimized for correct permissions and access.
69  - Supports full regex syntax (e.g., "log.*Error", "function\s+\w+")
70  - Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust")
71  - Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts
72  - Use Task tool for open-ended searches requiring multiple rounds
73  - Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use `interface\{\}` to find `interface{}` in Go code)
74  - Multiline matching: By default patterns match within single lines only. For cross-line patterns like `struct \{[\s\S]*?field`, use `multiline: true`"#;
75
76    async fn handle(&self, input: GrepInput, context: &ExecutionContext) -> ToolResult {
77        let search_path = match context.try_resolve_or_root_for(Self::NAME, input.path.as_deref()) {
78            Ok(path) => path,
79            Err(e) => return e,
80        };
81
82        let mut cmd = Command::new("rg");
83
84        match input.output_mode.as_deref() {
85            Some("content") => {
86                if input.line_numbers.unwrap_or(true) {
87                    cmd.arg("-n");
88                }
89            }
90            Some("files_with_matches") | None => {
91                cmd.arg("-l");
92            }
93            Some("count") => {
94                cmd.arg("-c");
95            }
96            Some(mode) => {
97                return ToolResult::error(format!("Unknown output_mode: {}", mode));
98            }
99        }
100
101        if input.case_insensitive.unwrap_or(false) {
102            cmd.arg("-i");
103        }
104
105        if let Some(c) = input.context {
106            cmd.arg("-C").arg(c.to_string());
107        } else {
108            if let Some(a) = input.after_context {
109                cmd.arg("-A").arg(a.to_string());
110            }
111            if let Some(b) = input.before_context {
112                cmd.arg("-B").arg(b.to_string());
113            }
114        }
115
116        if let Some(t) = &input.file_type {
117            cmd.arg("-t").arg(t);
118        }
119
120        if let Some(g) = &input.glob {
121            cmd.arg("-g").arg(g);
122        }
123
124        if input.multiline.unwrap_or(false) {
125            cmd.arg("-U").arg("--multiline-dotall");
126        }
127
128        cmd.arg(&input.pattern);
129        cmd.arg(&search_path);
130        cmd.stdout(Stdio::piped());
131        cmd.stderr(Stdio::piped());
132
133        let output = match cmd.output().await {
134            Ok(o) => o,
135            Err(e) => {
136                return ToolResult::error(format!(
137                    "Failed to execute ripgrep (is rg installed?): {}",
138                    e
139                ));
140            }
141        };
142
143        let stdout = String::from_utf8_lossy(&output.stdout);
144        let stderr = String::from_utf8_lossy(&output.stderr);
145
146        if !output.status.success() && !stderr.is_empty() {
147            return ToolResult::error(format!("Ripgrep error: {}", stderr));
148        }
149
150        if stdout.is_empty() {
151            return ToolResult::success("No matches found");
152        }
153
154        let result = apply_pagination(&stdout, input.offset, input.head_limit);
155        ToolResult::success(result)
156    }
157}
158
159fn apply_pagination(content: &str, offset: Option<usize>, limit: Option<usize>) -> String {
160    let offset = offset.unwrap_or(0);
161    match limit {
162        Some(limit) => content
163            .lines()
164            .skip(offset)
165            .take(limit)
166            .collect::<Vec<_>>()
167            .join("\n"),
168        None if offset > 0 => content.lines().skip(offset).collect::<Vec<_>>().join("\n"),
169        None => content.to_string(),
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::tools::Tool;
177    use tempfile::tempdir;
178    use tokio::fs;
179
180    #[test]
181    fn test_grep_input_parsing() {
182        let input: GrepInput = serde_json::from_value(serde_json::json!({
183            "pattern": "test",
184            "-i": true
185        }))
186        .unwrap();
187
188        assert_eq!(input.pattern, "test");
189        assert_eq!(input.case_insensitive, Some(true));
190    }
191
192    #[test]
193    fn test_grep_input_all_options() {
194        let input: GrepInput = serde_json::from_value(serde_json::json!({
195            "pattern": "fn main",
196            "path": "src",
197            "glob": "*.rs",
198            "type": "rust",
199            "output_mode": "content",
200            "-i": false,
201            "-n": true,
202            "-A": 2,
203            "-B": 1,
204            "-C": 3
205        }))
206        .unwrap();
207
208        assert_eq!(input.pattern, "fn main");
209        assert_eq!(input.path, Some("src".to_string()));
210        assert_eq!(input.glob, Some("*.rs".to_string()));
211        assert_eq!(input.file_type, Some("rust".to_string()));
212        assert_eq!(input.output_mode, Some("content".to_string()));
213        assert_eq!(input.case_insensitive, Some(false));
214        assert_eq!(input.line_numbers, Some(true));
215        assert_eq!(input.after_context, Some(2));
216        assert_eq!(input.before_context, Some(1));
217        assert_eq!(input.context, Some(3));
218    }
219
220    #[tokio::test]
221    async fn test_grep_basic_search() {
222        let dir = tempdir().unwrap();
223        let root = std::fs::canonicalize(dir.path()).unwrap();
224
225        fs::write(
226            root.join("test.rs"),
227            "fn main() {\n    println!(\"hello\");\n}",
228        )
229        .await
230        .unwrap();
231        fs::write(root.join("lib.rs"), "pub fn helper() {}")
232            .await
233            .unwrap();
234
235        let test_context = super::super::context::ExecutionContext::from_path(&root).unwrap();
236        let tool = GrepTool;
237
238        // Default output_mode is now files_with_matches, so it returns file paths
239        let result = tool
240            .execute(serde_json::json!({"pattern": "fn main"}), &test_context)
241            .await;
242
243        match &result.output {
244            crate::types::ToolOutput::Success(content) => {
245                assert!(content.contains("test.rs"));
246            }
247            crate::types::ToolOutput::Error(e) => {
248                let error_message = e.to_string();
249                if error_message.contains("is rg installed") {
250                    return;
251                }
252                panic!("Unexpected error: {}", error_message);
253            }
254            _ => {}
255        }
256    }
257
258    #[tokio::test]
259    async fn test_grep_no_matches() {
260        let dir = tempdir().unwrap();
261        let root = std::fs::canonicalize(dir.path()).unwrap();
262
263        fs::write(root.join("test.txt"), "hello world")
264            .await
265            .unwrap();
266
267        let test_context = super::super::context::ExecutionContext::from_path(&root).unwrap();
268        let tool = GrepTool;
269
270        let result = tool
271            .execute(
272                serde_json::json!({"pattern": "nonexistent_pattern_xyz"}),
273                &test_context,
274            )
275            .await;
276
277        match &result.output {
278            crate::types::ToolOutput::Success(content) => {
279                assert!(content.contains("No matches"));
280            }
281            crate::types::ToolOutput::Error(e) => {
282                let error_message = e.to_string();
283                if error_message.contains("is rg installed") {
284                    return;
285                }
286                panic!("Unexpected error: {}", error_message);
287            }
288            _ => {}
289        }
290    }
291
292    #[tokio::test]
293    async fn test_grep_case_insensitive() {
294        let dir = tempdir().unwrap();
295        let root = std::fs::canonicalize(dir.path()).unwrap();
296
297        fs::write(root.join("test.txt"), "Hello World\nHELLO WORLD")
298            .await
299            .unwrap();
300
301        let test_context = super::super::context::ExecutionContext::from_path(&root).unwrap();
302        let tool = GrepTool;
303
304        let result = tool
305            .execute(
306                serde_json::json!({"pattern": "hello", "-i": true, "output_mode": "content"}),
307                &test_context,
308            )
309            .await;
310
311        match &result.output {
312            crate::types::ToolOutput::Success(content) => {
313                assert!(content.contains("Hello") || content.contains("HELLO"));
314            }
315            crate::types::ToolOutput::Error(e) => {
316                let error_message = e.to_string();
317                if error_message.contains("is rg installed") {
318                    return;
319                }
320                panic!("Unexpected error: {}", error_message);
321            }
322            _ => {}
323        }
324    }
325
326    #[tokio::test]
327    async fn test_grep_files_with_matches_mode() {
328        let dir = tempdir().unwrap();
329        let root = std::fs::canonicalize(dir.path()).unwrap();
330
331        fs::write(root.join("a.txt"), "pattern here").await.unwrap();
332        fs::write(root.join("b.txt"), "no match").await.unwrap();
333
334        let test_context = super::super::context::ExecutionContext::from_path(&root).unwrap();
335        let tool = GrepTool;
336
337        let result = tool
338            .execute(
339                serde_json::json!({"pattern": "pattern", "output_mode": "files_with_matches"}),
340                &test_context,
341            )
342            .await;
343
344        match &result.output {
345            crate::types::ToolOutput::Success(content) => {
346                assert!(content.contains("a.txt"));
347                assert!(!content.contains("b.txt"));
348            }
349            crate::types::ToolOutput::Error(e) => {
350                let error_message = e.to_string();
351                if error_message.contains("is rg installed") {
352                    return;
353                }
354                panic!("Unexpected error: {}", error_message);
355            }
356            _ => {}
357        }
358    }
359
360    #[tokio::test]
361    async fn test_grep_count_mode() {
362        let dir = tempdir().unwrap();
363        let root = std::fs::canonicalize(dir.path()).unwrap();
364
365        fs::write(root.join("test.txt"), "line1\nline2\nline3")
366            .await
367            .unwrap();
368
369        let test_context = super::super::context::ExecutionContext::from_path(&root).unwrap();
370        let tool = GrepTool;
371
372        let result = tool
373            .execute(
374                serde_json::json!({"pattern": "line", "output_mode": "count"}),
375                &test_context,
376            )
377            .await;
378
379        match &result.output {
380            crate::types::ToolOutput::Success(content) => {
381                assert!(content.contains("3") || content.contains(":3"));
382            }
383            crate::types::ToolOutput::Error(e) => {
384                let error_message = e.to_string();
385                if error_message.contains("is rg installed") {
386                    return;
387                }
388                panic!("Unexpected error: {}", error_message);
389            }
390            _ => {}
391        }
392    }
393
394    #[tokio::test]
395    async fn test_grep_invalid_output_mode() {
396        let dir = tempdir().unwrap();
397        let root = std::fs::canonicalize(dir.path()).unwrap();
398
399        fs::write(root.join("test.txt"), "content").await.unwrap();
400
401        let test_context = super::super::context::ExecutionContext::from_path(&root).unwrap();
402        let tool = GrepTool;
403
404        let result = tool
405            .execute(
406                serde_json::json!({"pattern": "test", "output_mode": "invalid_mode"}),
407                &test_context,
408            )
409            .await;
410
411        match &result.output {
412            crate::types::ToolOutput::Error(e) => {
413                assert!(e.to_string().contains("Unknown output_mode"));
414            }
415            _ => panic!("Expected error for invalid output_mode"),
416        }
417    }
418
419    #[tokio::test]
420    async fn test_grep_with_glob_filter() {
421        let dir = tempdir().unwrap();
422        let root = std::fs::canonicalize(dir.path()).unwrap();
423
424        fs::write(root.join("code.rs"), "fn test() {}")
425            .await
426            .unwrap();
427        fs::write(root.join("doc.md"), "fn test() {}")
428            .await
429            .unwrap();
430
431        let test_context = super::super::context::ExecutionContext::from_path(&root).unwrap();
432        let tool = GrepTool;
433
434        let result = tool
435            .execute(
436                serde_json::json!({"pattern": "fn test", "glob": "*.rs", "output_mode": "files_with_matches"}),
437                &test_context,
438            )
439            .await;
440
441        match &result.output {
442            crate::types::ToolOutput::Success(content) => {
443                assert!(content.contains("code.rs"));
444                assert!(!content.contains("doc.md"));
445            }
446            crate::types::ToolOutput::Error(e) => {
447                let error_message = e.to_string();
448                if error_message.contains("is rg installed") {
449                    return;
450                }
451                panic!("Unexpected error: {}", error_message);
452            }
453            _ => {}
454        }
455    }
456}