Skip to main content

microclaw_engine/tools/
grep.rs

1use 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: &regex::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            // Skip hidden directories and common non-code dirs
130            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: &regex::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(()), // Skip binary / unreadable files
160    };
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        // Should have file:line format
200        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        // Only search .txt files
221        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        // Should NOT match the .rs file
231        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        // Should only find in visible.txt
283        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}