1use anyhow::{Context, Result};
2use regex::Regex;
3use serde_json::Value;
4use std::fs;
5use std::path::Path;
6
7use super::Tool;
8
9const MAX_RESULTS: usize = 100;
10
11pub struct GrepTool;
12
13impl Tool for GrepTool {
14 fn name(&self) -> &str {
15 "grep"
16 }
17
18 fn description(&self) -> &str {
19 "Search file contents using regex patterns. Returns matching lines with file paths and line numbers. More precise than search_files."
20 }
21
22 fn input_schema(&self) -> Value {
23 serde_json::json!({
24 "type": "object",
25 "properties": {
26 "pattern": {
27 "type": "string",
28 "description": "Regex pattern to search for"
29 },
30 "path": {
31 "type": "string",
32 "description": "Directory to search in"
33 },
34 "include": {
35 "type": "string",
36 "description": "File glob filter (e.g. '*.rs', '*.{ts,tsx}')"
37 }
38 },
39 "required": ["pattern", "path"]
40 })
41 }
42
43 fn execute(&self, input: Value) -> Result<String> {
44 let pattern = input["pattern"]
45 .as_str()
46 .context("Missing required parameter 'pattern'")?;
47 let path = input["path"]
48 .as_str()
49 .context("Missing required parameter 'path'")?;
50 let include = input["include"].as_str().unwrap_or("");
51 tracing::debug!("grep: '{}' in {}", pattern, path);
52
53 let re = Regex::new(pattern).with_context(|| format!("invalid regex: {}", pattern))?;
54
55 let mut results = Vec::new();
56 grep_recursive(Path::new(path), &re, include, &mut results);
57
58 if results.is_empty() {
59 Ok(format!("No matches for '{}' in '{}'", pattern, path))
60 } else {
61 let truncated = results.len() >= MAX_RESULTS;
62 let mut output = results.join("\n");
63 if truncated {
64 output.push_str(&format!("\n... (truncated at {} matches)", MAX_RESULTS));
65 }
66 Ok(output)
67 }
68 }
69}
70
71fn grep_recursive(dir: &Path, re: &Regex, include: &str, results: &mut Vec<String>) {
72 if results.len() >= MAX_RESULTS {
73 return;
74 }
75
76 let entries = match fs::read_dir(dir) {
77 Ok(e) => e,
78 Err(_) => return,
79 };
80
81 for entry in entries {
82 if results.len() >= MAX_RESULTS {
83 return;
84 }
85
86 let entry = match entry {
87 Ok(e) => e,
88 Err(_) => continue,
89 };
90
91 let path = entry.path();
92 let metadata = match entry.metadata() {
93 Ok(m) => m,
94 Err(_) => continue,
95 };
96
97 if metadata.is_dir() {
98 let name = path.file_name().unwrap_or_default().to_string_lossy();
99 if name.starts_with('.')
100 || name == "target"
101 || name == "node_modules"
102 || name == "__pycache__"
103 || name == ".git"
104 {
105 continue;
106 }
107 grep_recursive(&path, re, include, results);
108 } else if metadata.is_file() {
109 let name = path
110 .file_name()
111 .unwrap_or_default()
112 .to_string_lossy()
113 .to_string();
114 if !include.is_empty() && !matches_include(&name, include) {
115 continue;
116 }
117
118 let content = match fs::read_to_string(&path) {
119 Ok(c) => c,
120 Err(_) => continue,
121 };
122
123 for (i, line) in content.lines().enumerate() {
124 if results.len() >= MAX_RESULTS {
125 return;
126 }
127 if re.is_match(line) {
128 results.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
129 }
130 }
131 }
132 }
133}
134
135fn matches_include(filename: &str, include: &str) -> bool {
136 if let Some(ext_pat) = include.strip_prefix("*.") {
137 if ext_pat.starts_with('{') && ext_pat.ends_with('}') {
138 let inner = &ext_pat[1..ext_pat.len() - 1];
139 return inner
140 .split(',')
141 .any(|ext| filename.ends_with(&format!(".{}", ext.trim())));
142 }
143 return filename.ends_with(&format!(".{}", ext_pat));
144 }
145 filename == include
146}