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)]
16#[schemars(deny_unknown_fields)]
17pub struct GrepInput {
18 pub pattern: String,
20 #[serde(default)]
22 pub path: Option<String>,
23 #[serde(default)]
25 pub glob: Option<String>,
26 #[serde(default, rename = "type")]
28 pub file_type: Option<String>,
29 #[serde(default)]
31 pub output_mode: Option<String>,
32 #[serde(default, rename = "-i")]
34 pub case_insensitive: Option<bool>,
35 #[serde(default, rename = "-n")]
37 pub line_numbers: Option<bool>,
38 #[serde(default, rename = "-A")]
40 pub after_context: Option<u32>,
41 #[serde(default, rename = "-B")]
43 pub before_context: Option<u32>,
44 #[serde(default, rename = "-C")]
46 pub context: Option<u32>,
47 #[serde(default)]
49 pub multiline: Option<bool>,
50 #[serde(default)]
52 pub head_limit: Option<usize>,
53 #[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 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}