agent_code_lib/tools/
grep.rs1use 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 .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 .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 .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 let mut cmd = Command::new("rg");
166 cmd.arg("--color=never");
167
168 match output_mode {
170 "files_with_matches" => {
171 cmd.arg("--files-with-matches");
172 }
173 "count" => {
174 cmd.arg("--count");
175 }
176 "content" => {
177 if show_line_numbers {
179 cmd.arg("--line-number");
180 }
181 cmd.arg("--no-heading");
182 }
183 _ => {
184 cmd.arg("--files-with-matches");
186 }
187 }
188
189 if case_insensitive {
191 cmd.arg("-i");
192 }
193
194 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 if multiline {
209 cmd.arg("--multiline").arg("--multiline-dotall");
210 }
211
212 if let Some(file_type) = type_filter {
214 cmd.arg("--type").arg(file_type);
215 }
216
217 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 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 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() } 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 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}