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, };
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 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}