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