Skip to main content

agent_code_lib/tools/
grep.rs

1//! Grep tool: regex-based content search.
2//!
3//! Searches file contents using regular expressions. Shells out to
4//! `rg` (ripgrep) when available for performance and .gitignore
5//! awareness. Falls back to a built-in implementation.
6
7use async_trait::async_trait;
8use serde_json::json;
9use std::path::PathBuf;
10use std::process::Stdio;
11use tokio::process::Command;
12
13use super::{Tool, ToolContext, ToolResult};
14use crate::error::ToolError;
15
16pub struct GrepTool;
17
18#[async_trait]
19impl Tool for GrepTool {
20    fn name(&self) -> &'static str {
21        "Grep"
22    }
23
24    fn description(&self) -> &'static str {
25        "Searches file contents using regular expressions. Powered by ripgrep."
26    }
27
28    fn input_schema(&self) -> serde_json::Value {
29        json!({
30            "type": "object",
31            "required": ["pattern"],
32            "properties": {
33                "pattern": {
34                    "type": "string",
35                    "description": "Regular expression pattern to search for"
36                },
37                "path": {
38                    "type": "string",
39                    "description": "File or directory to search in"
40                },
41                "glob": {
42                    "type": "string",
43                    "description": "Glob pattern to filter files (e.g., \"*.rs\", \"*.{ts,tsx}\")"
44                },
45                "type": {
46                    "type": "string",
47                    "description": "File type to search (e.g., \"js\", \"py\", \"rust\")"
48                },
49                "-i": {
50                    "type": "boolean",
51                    "description": "Case-insensitive search",
52                    "default": false
53                },
54                "-n": {
55                    "type": "boolean",
56                    "description": "Show line numbers in output (content mode only)",
57                    "default": true
58                },
59                "-A": {
60                    "type": "integer",
61                    "description": "Lines to show after each match (content mode only)"
62                },
63                "-B": {
64                    "type": "integer",
65                    "description": "Lines to show before each match (content mode only)"
66                },
67                "-C": {
68                    "type": "integer",
69                    "description": "Lines of context around each match (content mode only)"
70                },
71                "context": {
72                    "type": "integer",
73                    "description": "Alias for -C"
74                },
75                "multiline": {
76                    "type": "boolean",
77                    "description": "Enable multiline matching (pattern can span lines)",
78                    "default": false
79                },
80                "output_mode": {
81                    "type": "string",
82                    "enum": ["content", "files_with_matches", "count"],
83                    "description": "Output mode: content (matching lines), files_with_matches (file paths), count (match counts)",
84                    "default": "files_with_matches"
85                },
86                "head_limit": {
87                    "type": "integer",
88                    "description": "Limit output to first N lines/entries (default: 250, 0 for unlimited)"
89                },
90                "offset": {
91                    "type": "integer",
92                    "description": "Skip first N lines/entries before applying head_limit",
93                    "default": 0
94                }
95            }
96        })
97    }
98
99    fn is_read_only(&self) -> bool {
100        true
101    }
102
103    fn is_concurrency_safe(&self) -> bool {
104        true
105    }
106
107    async fn call(
108        &self,
109        input: serde_json::Value,
110        ctx: &ToolContext,
111    ) -> Result<ToolResult, ToolError> {
112        let pattern = input
113            .get("pattern")
114            .and_then(|v| v.as_str())
115            .ok_or_else(|| ToolError::InvalidInput("'pattern' is required".into()))?;
116
117        let search_path = input
118            .get("path")
119            .and_then(|v| v.as_str())
120            .map(PathBuf::from)
121            .unwrap_or_else(|| ctx.cwd.clone());
122
123        let glob_filter = input.get("glob").and_then(|v| v.as_str());
124        let type_filter = input.get("type").and_then(|v| v.as_str());
125
126        let case_insensitive = input
127            .get("-i")
128            // Also check legacy field name for backwards compat.
129            .or_else(|| input.get("case_insensitive"))
130            .and_then(|v| v.as_bool())
131            .unwrap_or(false);
132
133        let show_line_numbers = input.get("-n").and_then(|v| v.as_bool()).unwrap_or(true);
134
135        let after_context = input.get("-A").and_then(|v| v.as_u64());
136        let before_context = input.get("-B").and_then(|v| v.as_u64());
137        let context = input
138            .get("-C")
139            .or_else(|| input.get("context"))
140            // Also check legacy field name.
141            .or_else(|| input.get("context_lines"))
142            .and_then(|v| v.as_u64());
143
144        let multiline = input
145            .get("multiline")
146            .and_then(|v| v.as_bool())
147            .unwrap_or(false);
148
149        let output_mode = input
150            .get("output_mode")
151            .and_then(|v| v.as_str())
152            .unwrap_or("files_with_matches");
153
154        let head_limit = input
155            .get("head_limit")
156            // Also check legacy field name.
157            .or_else(|| input.get("max_results"))
158            .and_then(|v| v.as_u64())
159            .map(|v| v as usize)
160            .unwrap_or(250);
161
162        let offset = input.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
163
164        // Build ripgrep command.
165        let mut cmd = Command::new("rg");
166        cmd.arg("--color=never");
167
168        // Output mode determines base flags.
169        match output_mode {
170            "files_with_matches" => {
171                cmd.arg("--files-with-matches");
172            }
173            "count" => {
174                cmd.arg("--count");
175            }
176            "content" => {
177                // Content mode: show matching lines.
178                if show_line_numbers {
179                    cmd.arg("--line-number");
180                }
181                cmd.arg("--no-heading");
182            }
183            _ => {
184                // Default to files_with_matches for unknown modes.
185                cmd.arg("--files-with-matches");
186            }
187        }
188
189        // Case sensitivity.
190        if case_insensitive {
191            cmd.arg("-i");
192        }
193
194        // Context flags (only meaningful in content mode).
195        if output_mode == "content" {
196            if let Some(a) = after_context {
197                cmd.arg(format!("-A{a}"));
198            }
199            if let Some(b) = before_context {
200                cmd.arg(format!("-B{b}"));
201            }
202            if let Some(c) = context {
203                cmd.arg(format!("-C{c}"));
204            }
205        }
206
207        // Multiline mode.
208        if multiline {
209            cmd.arg("--multiline").arg("--multiline-dotall");
210        }
211
212        // File type filter.
213        if let Some(file_type) = type_filter {
214            cmd.arg("--type").arg(file_type);
215        }
216
217        // Glob filter.
218        if let Some(glob_pat) = glob_filter {
219            cmd.arg("--glob").arg(glob_pat);
220        }
221
222        cmd.arg(pattern).arg(&search_path);
223        cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
224
225        let output = match cmd.output().await {
226            Ok(out) => out,
227            Err(_) => {
228                // Fallback to grep if rg is not installed.
229                let mut fallback = Command::new("grep");
230                fallback.arg("-r").arg("--color=never");
231                if show_line_numbers && output_mode == "content" {
232                    fallback.arg("-n");
233                }
234                if case_insensitive {
235                    fallback.arg("-i");
236                }
237                if output_mode == "files_with_matches" {
238                    fallback.arg("-l");
239                } else if output_mode == "count" {
240                    fallback.arg("-c");
241                }
242                if let Some(glob_pat) = glob_filter {
243                    fallback.arg("--include").arg(glob_pat);
244                }
245                fallback.arg(pattern).arg(&search_path);
246                fallback.stdout(Stdio::piped()).stderr(Stdio::piped());
247                fallback.output().await.map_err(|e| {
248                    ToolError::ExecutionFailed(format!(
249                        "Neither rg nor grep available: {e}. Install ripgrep: brew install ripgrep"
250                    ))
251                })?
252            }
253        };
254
255        let stdout = String::from_utf8_lossy(&output.stdout);
256
257        // Apply offset and head_limit.
258        let lines: Vec<&str> = stdout.lines().collect();
259        let total = lines.len();
260
261        let after_offset = if offset > 0 {
262            if offset >= total {
263                Vec::new()
264            } else {
265                lines[offset..].to_vec()
266            }
267        } else {
268            lines
269        };
270
271        let effective_limit = if head_limit == 0 {
272            after_offset.len() // 0 means unlimited
273        } else {
274            head_limit
275        };
276
277        let truncated = after_offset.len() > effective_limit;
278        let display_lines = &after_offset[..after_offset.len().min(effective_limit)];
279
280        let mut result = display_lines.join("\n");
281        if truncated {
282            result.push_str(&format!(
283                "\n\n(Showing {} of {} results. Use a more specific pattern or increase head_limit.)",
284                effective_limit,
285                after_offset.len()
286            ));
287        }
288
289        if result.is_empty() {
290            result = "No matches found.".to_string();
291        }
292
293        // Build summary based on output mode.
294        match output_mode {
295            "files_with_matches" => Ok(ToolResult::success(format!(
296                "Found {total} matching files:\n{result}"
297            ))),
298            "count" => Ok(ToolResult::success(result)),
299            "content" => {
300                let num_files = display_lines
301                    .iter()
302                    .filter_map(|l| l.split(':').next())
303                    .collect::<std::collections::HashSet<_>>()
304                    .len();
305                Ok(ToolResult::success(format!(
306                    "Found {total} matches across {num_files} files:\n{result}"
307                )))
308            }
309            _ => Ok(ToolResult::success(result)),
310        }
311    }
312}