microclaw_engine/tools/
grep.rs1use async_trait::async_trait;
2use serde_json::json;
3use std::path::{Path, PathBuf};
4use tracing::info;
5
6use crate::config::WorkingDirIsolation;
7use microclaw_core::llm_types::ToolDefinition;
8
9use super::{schema_object, Tool, ToolResult};
10
11pub struct GrepTool {
12 working_dir: PathBuf,
13 working_dir_isolation: WorkingDirIsolation,
14}
15
16impl GrepTool {
17 pub fn new(working_dir: &str) -> Self {
18 Self::new_with_isolation(working_dir, WorkingDirIsolation::Shared)
19 }
20
21 pub fn new_with_isolation(
22 working_dir: &str,
23 working_dir_isolation: WorkingDirIsolation,
24 ) -> Self {
25 Self {
26 working_dir: PathBuf::from(working_dir),
27 working_dir_isolation,
28 }
29 }
30}
31
32#[async_trait]
33impl Tool for GrepTool {
34 fn name(&self) -> &str {
35 "grep"
36 }
37
38 fn definition(&self) -> ToolDefinition {
39 ToolDefinition {
40 name: "grep".into(),
41 description: "Search file contents using a regex pattern. Returns matching lines with file paths and line numbers.".into(),
42 input_schema: schema_object(
43 json!({
44 "pattern": {
45 "type": "string",
46 "description": "Regex pattern to search for"
47 },
48 "path": {
49 "type": "string",
50 "description": "File or directory to search in (default: current directory)"
51 },
52 "glob": {
53 "type": "string",
54 "description": "Glob pattern to filter files (e.g., '*.rs')"
55 }
56 }),
57 &["pattern"],
58 ),
59 }
60 }
61
62 async fn execute(&self, input: serde_json::Value) -> ToolResult {
63 let pattern = match input.get("pattern").and_then(|v| v.as_str()) {
64 Some(p) => p,
65 None => return ToolResult::error("Missing 'pattern' parameter".into()),
66 };
67 let path = input.get("path").and_then(|v| v.as_str()).unwrap_or(".");
68 let working_dir =
69 super::resolve_tool_working_dir(&self.working_dir, self.working_dir_isolation, &input);
70 let resolved_path = super::resolve_tool_path(&working_dir, path);
71 let resolved_path_str = resolved_path.to_string_lossy().to_string();
72 if let Err(msg) = crate::internal::tool_runtime::path_guard::check_path(&resolved_path_str)
73 {
74 return ToolResult::error(msg);
75 }
76 let file_glob = input.get("glob").and_then(|v| v.as_str());
77
78 info!("Grep: {} in {}", pattern, resolved_path.display());
79
80 let re = match regex::Regex::new(pattern) {
81 Ok(r) => r,
82 Err(e) => return ToolResult::error(format!("Invalid regex: {e}")),
83 };
84
85 let mut results = Vec::new();
86 let mut file_count = 0;
87
88 if let Err(e) = grep_recursive(
89 &resolved_path,
90 file_glob,
91 &re,
92 &mut results,
93 &mut file_count,
94 ) {
95 return ToolResult::error(format!("Search error: {e}"));
96 }
97
98 if results.is_empty() {
99 ToolResult::success("No matches found.".into())
100 } else {
101 if results.len() > 500 {
102 results.truncate(500);
103 results.push("... (results truncated)".into());
104 }
105 ToolResult::success(results.join("\n"))
106 }
107 }
108}
109
110fn grep_recursive(
111 path: &Path,
112 file_glob: Option<&str>,
113 re: ®ex::Regex,
114 results: &mut Vec<String>,
115 file_count: &mut usize,
116) -> std::io::Result<()> {
117 let metadata = std::fs::metadata(path)?;
118
119 if metadata.is_file() {
120 grep_file(path, re, results)?;
121 } else if metadata.is_dir() {
122 let glob_pattern = file_glob.and_then(|g| glob::Pattern::new(g).ok());
123
124 for entry in std::fs::read_dir(path)? {
125 let entry = entry?;
126 let entry_path = entry.path();
127 let name = entry.file_name().to_string_lossy().to_string();
128
129 if name.starts_with('.') || name == "node_modules" || name == "target" {
131 continue;
132 }
133
134 if entry_path.is_dir() {
135 grep_recursive(&entry_path, file_glob, re, results, file_count)?;
136 } else if entry_path.is_file() {
137 if crate::internal::tool_runtime::path_guard::is_blocked(&entry_path) {
138 continue;
139 }
140 if let Some(ref pat) = glob_pattern {
141 if !pat.matches(&name) {
142 continue;
143 }
144 }
145 *file_count += 1;
146 if *file_count > 10000 {
147 return Ok(());
148 }
149 grep_file(&entry_path, re, results)?;
150 }
151 }
152 }
153 Ok(())
154}
155
156fn grep_file(path: &Path, re: ®ex::Regex, results: &mut Vec<String>) -> std::io::Result<()> {
157 let content = match std::fs::read_to_string(path) {
158 Ok(c) => c,
159 Err(_) => return Ok(()), };
161
162 for (line_num, line) in content.lines().enumerate() {
163 if re.is_match(line) {
164 results.push(format!("{}:{}: {}", path.display(), line_num + 1, line));
165 if results.len() >= 500 {
166 return Ok(());
167 }
168 }
169 }
170 Ok(())
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use serde_json::json;
177
178 fn setup_grep_dir() -> std::path::PathBuf {
179 let dir = std::env::temp_dir().join(format!("microclaw_grep_{}", uuid::Uuid::new_v4()));
180 std::fs::create_dir_all(&dir).unwrap();
181 std::fs::write(
182 dir.join("hello.rs"),
183 "fn main() {\n println!(\"hello\");\n}\n",
184 )
185 .unwrap();
186 std::fs::write(dir.join("world.txt"), "hello world\ngoodbye world\n").unwrap();
187 dir
188 }
189
190 #[tokio::test]
191 async fn test_grep_finds_matches() {
192 let dir = setup_grep_dir();
193 let tool = GrepTool::new(".");
194 let result = tool
195 .execute(json!({"pattern": "hello", "path": dir.to_str().unwrap()}))
196 .await;
197 assert!(!result.is_error);
198 assert!(result.content.contains("hello"));
199 assert!(result.content.contains(":"));
201 let _ = std::fs::remove_dir_all(&dir);
202 }
203
204 #[tokio::test]
205 async fn test_grep_no_matches() {
206 let dir = setup_grep_dir();
207 let tool = GrepTool::new(".");
208 let result = tool
209 .execute(json!({"pattern": "zzzzzzz", "path": dir.to_str().unwrap()}))
210 .await;
211 assert!(!result.is_error);
212 assert!(result.content.contains("No matches"));
213 let _ = std::fs::remove_dir_all(&dir);
214 }
215
216 #[tokio::test]
217 async fn test_grep_with_file_glob() {
218 let dir = setup_grep_dir();
219 let tool = GrepTool::new(".");
220 let result = tool
222 .execute(json!({
223 "pattern": "hello",
224 "path": dir.to_str().unwrap(),
225 "glob": "*.txt"
226 }))
227 .await;
228 assert!(!result.is_error);
229 assert!(result.content.contains("world.txt"));
230 assert!(!result.content.contains("hello.rs"));
232 let _ = std::fs::remove_dir_all(&dir);
233 }
234
235 #[tokio::test]
236 async fn test_grep_invalid_regex() {
237 let tool = GrepTool::new(".");
238 let result = tool
239 .execute(json!({"pattern": "[invalid", "path": "."}))
240 .await;
241 assert!(result.is_error);
242 assert!(result.content.contains("Invalid regex"));
243 }
244
245 #[tokio::test]
246 async fn test_grep_missing_pattern() {
247 let tool = GrepTool::new(".");
248 let result = tool.execute(json!({})).await;
249 assert!(result.is_error);
250 assert!(result.content.contains("Missing 'pattern'"));
251 }
252
253 #[test]
254 fn test_grep_file_function() {
255 let dir = std::env::temp_dir().join(format!("microclaw_gf_{}", uuid::Uuid::new_v4()));
256 std::fs::create_dir_all(&dir).unwrap();
257 let file = dir.join("test.txt");
258 std::fs::write(&file, "foo bar\nbaz qux\nfoo again\n").unwrap();
259
260 let re = regex::Regex::new("foo").unwrap();
261 let mut results = Vec::new();
262 grep_file(&file, &re, &mut results).unwrap();
263 assert_eq!(results.len(), 2);
264 assert!(results[0].contains(":1:"));
265 assert!(results[1].contains(":3:"));
266
267 let _ = std::fs::remove_dir_all(&dir);
268 }
269
270 #[test]
271 fn test_grep_recursive_skips_hidden_dirs() {
272 let dir = std::env::temp_dir().join(format!("microclaw_gr_{}", uuid::Uuid::new_v4()));
273 std::fs::create_dir_all(dir.join(".hidden")).unwrap();
274 std::fs::write(dir.join(".hidden").join("secret.txt"), "match_me").unwrap();
275 std::fs::write(dir.join("visible.txt"), "match_me").unwrap();
276
277 let re = regex::Regex::new("match_me").unwrap();
278 let mut results: Vec<String> = Vec::new();
279 let mut count = 0;
280 grep_recursive(&dir, None, &re, &mut results, &mut count).unwrap();
281
282 assert_eq!(results.len(), 1);
284 assert!(results[0].contains("visible.txt"));
285
286 let _ = std::fs::remove_dir_all(&dir);
287 }
288
289 #[tokio::test]
290 async fn test_grep_defaults_to_working_dir() {
291 let root = std::env::temp_dir().join(format!("microclaw_grep2_{}", uuid::Uuid::new_v4()));
292 let work = root.join("workspace");
293 let shared = work.join("shared");
294 std::fs::create_dir_all(&shared).unwrap();
295 std::fs::write(shared.join("a.txt"), "needle").unwrap();
296
297 let tool = GrepTool::new(work.to_str().unwrap());
298 let result = tool.execute(json!({"pattern":"needle"})).await;
299 assert!(!result.is_error);
300 assert!(result.content.contains("a.txt"));
301
302 let _ = std::fs::remove_dir_all(&root);
303 }
304}