1use 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 pub pattern: String,
19 #[serde(default)]
21 pub path: Option<String>,
22 #[serde(default)]
24 pub glob: Option<String>,
25 #[serde(default, rename = "type")]
27 pub file_type: Option<String>,
28 #[serde(default)]
30 pub output_mode: Option<String>,
31 #[serde(default, rename = "-i")]
33 pub case_insensitive: Option<bool>,
34 #[serde(default, rename = "-n")]
36 pub line_numbers: Option<bool>,
37 #[serde(default, rename = "-A")]
39 pub after_context: Option<u32>,
40 #[serde(default, rename = "-B")]
42 pub before_context: Option<u32>,
43 #[serde(default, rename = "-C")]
45 pub context: Option<u32>,
46 #[serde(default)]
48 pub multiline: Option<bool>,
49 #[serde(default)]
51 pub head_limit: Option<usize>,
52 #[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 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}