Skip to main content

capo_agent/tools/
grep.rs

1#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
2
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::Arc;
6
7use globset::Glob;
8use ignore::WalkBuilder;
9use motosan_agent_tool::{Tool, ToolContext, ToolDef, ToolResult};
10use regex::RegexBuilder;
11use serde_json::{json, Value};
12
13use crate::tools::ls::resolve_in_cwd;
14use crate::tools::ToolCtx;
15
16const MAX_OUTPUT_BYTES: usize = 30 * 1024;
17const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
18
19pub struct GrepTool {
20    ctx: Arc<ToolCtx>,
21}
22
23impl GrepTool {
24    pub fn new(ctx: Arc<ToolCtx>) -> Self {
25        Self { ctx }
26    }
27}
28
29impl Tool for GrepTool {
30    fn def(&self) -> ToolDef {
31        ToolDef {
32            name: "grep".to_string(),
33            description: "Search file contents with a regex. Respects .gitignore, skips binary and oversized files. Returns `path:line:text` matches.".to_string(),
34            input_schema: json!({
35                "type": "object",
36                "properties": {
37                    "pattern": { "type": "string", "description": "Regular expression to search for." },
38                    "path": { "type": "string", "description": "Root directory (absolute or cwd-relative). Defaults to cwd." },
39                    "glob": { "type": "string", "description": "Optional filename glob filter, e.g. `*.rs`." },
40                    "case_insensitive": { "type": "boolean", "description": "Case-insensitive match. Default false." }
41                },
42                "required": ["pattern"]
43            }),
44        }
45    }
46
47    fn call(
48        &self,
49        args: Value,
50        _ctx: &ToolContext,
51    ) -> Pin<Box<dyn Future<Output = ToolResult> + Send + '_>> {
52        let ctx = Arc::clone(&self.ctx);
53        Box::pin(async move {
54            let pattern = match args.get("pattern").and_then(|v| v.as_str()) {
55                Some(p) => p.to_string(),
56                None => return ToolResult::error("missing 'pattern' argument"),
57            };
58            let case_insensitive = args
59                .get("case_insensitive")
60                .and_then(|v| v.as_bool())
61                .unwrap_or(false);
62            let re = match RegexBuilder::new(&pattern)
63                .case_insensitive(case_insensitive)
64                .build()
65            {
66                Ok(r) => r,
67                Err(e) => return ToolResult::error(format!("invalid regex `{pattern}`: {e}")),
68            };
69            let rel = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
70            let root = resolve_in_cwd(&ctx.cwd, rel);
71            if !root.starts_with(&ctx.cwd) {
72                return ToolResult::error(format!(
73                    "path {} is outside the working directory",
74                    root.display()
75                ));
76            }
77            let glob = match args.get("glob").and_then(|v| v.as_str()) {
78                Some(g) => match Glob::new(g) {
79                    Ok(g) => Some(g.compile_matcher()),
80                    Err(e) => return ToolResult::error(format!("invalid glob `{g}`: {e}")),
81                },
82                None => None,
83            };
84
85            let cwd = ctx.cwd.clone();
86            let search = tokio::task::spawn_blocking(move || {
87                let mut out = String::new();
88                let mut truncated = false;
89                'walk: for entry in WalkBuilder::new(&root).build().flatten() {
90                    if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
91                        continue;
92                    }
93                    let path = entry.path();
94                    if let Some(m) = &glob {
95                        if !m.is_match(path.file_name().unwrap_or_default()) {
96                            continue;
97                        }
98                    }
99                    let meta = match std::fs::metadata(path) {
100                        Ok(m) => m,
101                        Err(_) => continue,
102                    };
103                    if meta.len() > MAX_FILE_BYTES {
104                        continue;
105                    }
106                    let bytes = match std::fs::read(path) {
107                        Ok(b) => b,
108                        Err(_) => continue,
109                    };
110                    let text = match String::from_utf8(bytes) {
111                        Ok(t) => t,
112                        Err(_) => continue, // binary — skip
113                    };
114                    let rel = path.strip_prefix(&cwd).unwrap_or(path);
115                    for (lineno, line) in text.lines().enumerate() {
116                        if re.is_match(line) {
117                            let entry = format!("{}:{}:{}\n", rel.display(), lineno + 1, line);
118                            if out.len() + entry.len() > MAX_OUTPUT_BYTES {
119                                truncated = true;
120                                break 'walk;
121                            }
122                            out.push_str(&entry);
123                        }
124                    }
125                }
126                if truncated {
127                    out.push_str("... (output truncated at 30 KB)\n");
128                }
129                out
130            })
131            .await;
132
133            match search {
134                Ok(out) if out.is_empty() => ToolResult::text("(no matches)"),
135                Ok(out) => ToolResult::text(out),
136                Err(e) => ToolResult::error(format!("grep failed: {e}")),
137            }
138        })
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::permissions::NoOpPermissionGate;
146    use tempfile::tempdir;
147    use tokio::sync::mpsc;
148
149    fn test_ctx(cwd: &std::path::Path) -> Arc<ToolCtx> {
150        let (tx, _rx) = mpsc::channel(8);
151        Arc::new(ToolCtx::new(cwd, Arc::new(NoOpPermissionGate), tx))
152    }
153
154    #[tokio::test]
155    async fn finds_matching_lines() {
156        let dir = tempdir().expect("tempdir");
157        tokio::fs::write(dir.path().join("a.txt"), "alpha\nbeta\ngamma\n")
158            .await
159            .unwrap();
160        let tool = GrepTool::new(test_ctx(dir.path()));
161        let result = tool
162            .call(json!({ "pattern": "be.a" }), &ToolContext::default())
163            .await;
164        let text = result.as_text().unwrap_or_default();
165        assert!(text.contains("a.txt:2:beta"), "got: {text}");
166        assert!(!text.contains("alpha"));
167    }
168
169    #[tokio::test]
170    async fn no_matches_reports_cleanly() {
171        let dir = tempdir().expect("tempdir");
172        tokio::fs::write(dir.path().join("a.txt"), "alpha\n")
173            .await
174            .unwrap();
175        let tool = GrepTool::new(test_ctx(dir.path()));
176        let result = tool
177            .call(json!({ "pattern": "zzz" }), &ToolContext::default())
178            .await;
179        assert_eq!(result.as_text().unwrap_or_default(), "(no matches)");
180    }
181
182    #[tokio::test]
183    async fn case_insensitive_flag_works() {
184        let dir = tempdir().expect("tempdir");
185        tokio::fs::write(dir.path().join("a.txt"), "Hello\n")
186            .await
187            .unwrap();
188        let tool = GrepTool::new(test_ctx(dir.path()));
189        let result = tool
190            .call(
191                json!({ "pattern": "hello", "case_insensitive": true }),
192                &ToolContext::default(),
193            )
194            .await;
195        assert!(result.as_text().unwrap_or_default().contains("Hello"));
196    }
197
198    #[tokio::test]
199    async fn glob_filter_restricts_files() {
200        let dir = tempdir().expect("tempdir");
201        tokio::fs::write(dir.path().join("a.rs"), "match\n")
202            .await
203            .unwrap();
204        tokio::fs::write(dir.path().join("b.txt"), "match\n")
205            .await
206            .unwrap();
207        let tool = GrepTool::new(test_ctx(dir.path()));
208        let result = tool
209            .call(
210                json!({ "pattern": "match", "glob": "*.rs" }),
211                &ToolContext::default(),
212            )
213            .await;
214        let text = result.as_text().unwrap_or_default();
215        assert!(text.contains("a.rs"));
216        assert!(!text.contains("b.txt"));
217    }
218
219    #[tokio::test]
220    async fn skips_binary_files() {
221        let dir = tempdir().expect("tempdir");
222        tokio::fs::write(dir.path().join("bin"), [0xff_u8, 0x00, 0xfe])
223            .await
224            .unwrap();
225        tokio::fs::write(dir.path().join("txt.txt"), "needle\n")
226            .await
227            .unwrap();
228        let tool = GrepTool::new(test_ctx(dir.path()));
229        let result = tool
230            .call(json!({ "pattern": "needle" }), &ToolContext::default())
231            .await;
232        // Should match only the text file, not panic on the binary one.
233        assert!(result.as_text().unwrap_or_default().contains("txt.txt"));
234    }
235
236    #[tokio::test]
237    async fn invalid_regex_errors() {
238        let dir = tempdir().expect("tempdir");
239        let tool = GrepTool::new(test_ctx(dir.path()));
240        let result = tool
241            .call(json!({ "pattern": "(" }), &ToolContext::default())
242            .await;
243        assert!(result.is_error);
244    }
245}