1use async_trait::async_trait;
4use regex::Regex;
5use serde_json::{json, Value};
6use std::path::PathBuf;
7use walkdir::WalkDir;
8
9use super::{resolve_within_any, AccessTier, SharedSandboxRoots, Tool};
10use crate::error::{Error, Result};
11use crate::llm::ToolSpec;
12
13const DEFAULT_MAX_RESULTS: usize = 50;
14const DEFAULT_MAX_LINE_LEN: usize = 240;
15
16const MAX_GREP_FILE_BYTES: u64 = 1024 * 1024;
20
21#[derive(Debug, Clone)]
22pub struct SearchFiles {
23 pub root: PathBuf,
24 pub extra_roots: Vec<(PathBuf, AccessTier)>,
25 pub session_roots: Option<SharedSandboxRoots>,
26 pub max_results: usize,
27}
28
29impl SearchFiles {
30 pub fn new(root: impl Into<PathBuf>) -> Self {
31 Self {
32 root: root.into(),
33 extra_roots: Vec::new(),
34 session_roots: None,
35 max_results: DEFAULT_MAX_RESULTS,
36 }
37 }
38
39 pub fn with_extra_roots(
42 mut self,
43 extra: impl IntoIterator<Item = (PathBuf, AccessTier)>,
44 ) -> Self {
45 self.extra_roots.extend(extra);
46 self
47 }
48
49 pub fn with_session_roots(mut self, slot: SharedSandboxRoots) -> Self {
51 self.session_roots = Some(slot);
52 self
53 }
54
55 pub fn with_session_roots_opt(mut self, slot: Option<SharedSandboxRoots>) -> Self {
57 if let Some(s) = slot {
58 self.session_roots = Some(s);
59 }
60 self
61 }
62
63 fn relativise(&self, path: &std::path::Path) -> std::path::PathBuf {
64 if let Ok(rel) = path.strip_prefix(&self.root) {
65 return rel.to_path_buf();
66 }
67 path.to_path_buf()
68 }
69
70 fn all_roots(&self) -> Vec<(PathBuf, AccessTier)> {
71 let mut v: Vec<(PathBuf, AccessTier)> = Vec::with_capacity(self.extra_roots.len() + 1);
72 v.push((self.root.clone(), AccessTier::ReadWrite));
73 v.extend(self.extra_roots.iter().cloned());
74 if let Some(slot) = &self.session_roots {
75 if let Ok(roots) = slot.read() {
76 v.extend(roots.iter().cloned());
77 }
78 }
79 v
80 }
81}
82
83#[async_trait]
84impl Tool for SearchFiles {
85 fn spec(&self) -> ToolSpec {
86 ToolSpec {
87 name: "Grep".into(),
88 description:
89 "Find lines containing a pattern (literal substring or regex) across files in the workspace. Returns up to N matches as 'path:line: text'."
90 .into(),
91 parameters: json!({
92 "type": "object",
93 "properties": {
94 "pattern": { "type": "string", "description": "Pattern to search for. Literal substring by default; use `regex: true` for regex mode." },
95 "path": { "type": "string", "description": "Optional subdirectory (workspace-relative) to scope the search to. Defaults to workspace root." },
96 "max_results": { "type": "integer", "description": "Cap on results (default 50, max 200)." },
97 "regex": { "type": "boolean", "description": "If true, interpret `pattern` as a regular expression (Rust regex crate syntax). Default false (literal substring)." },
98 "case_insensitive": { "type": "boolean", "description": "If true, matching ignores ASCII case. Works in both literal and regex modes; in regex mode this is equivalent to wrapping the pattern in `(?i:...)`. Default false." }
99 },
100 "required": ["pattern"]
101 }),
102 }
103 }
104
105 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
106 crate::tools::ToolSideEffect::ReadOnly
107 }
108
109 async fn execute(&self, args: Value) -> Result<String> {
110 let pattern = args["pattern"].as_str().ok_or_else(|| Error::BadToolArgs {
111 name: "Grep".into(),
112 message: "missing `pattern`".into(),
113 })?;
114 if pattern.is_empty() {
115 return Err(Error::BadToolArgs {
116 name: "Grep".into(),
117 message: "`pattern` must not be empty".into(),
118 });
119 }
120
121 let use_regex = args.get("regex").and_then(|v| v.as_bool()).unwrap_or(false);
122 let case_insensitive = args
123 .get("case_insensitive")
124 .and_then(|v| v.as_bool())
125 .unwrap_or(false);
126 let re_opt: Option<Regex> = if use_regex {
127 let regex = if case_insensitive {
128 regex::RegexBuilder::new(pattern)
129 .case_insensitive(true)
130 .build()
131 } else {
132 Regex::new(pattern)
133 };
134 Some(regex.map_err(|e| Error::BadToolArgs {
135 name: "Grep".into(),
136 message: format!("invalid regex: {e}"),
137 })?)
138 } else {
139 None
140 };
141
142 let scope = match args.get("path").and_then(|v| v.as_str()) {
143 Some(p) => {
144 resolve_within_any(&self.all_roots(), p, false).map_err(|e| Error::BadToolArgs {
145 name: "Grep".into(),
146 message: format!("path: {e}"),
147 })?
148 }
149 None => self.root.clone(),
150 };
151
152 let cap = args
153 .get("max_results")
154 .and_then(|v| v.as_u64())
155 .map(|n| (n as usize).min(200))
156 .unwrap_or(self.max_results);
157
158 let mut hits: Vec<String> = Vec::new();
159 'outer: for entry in WalkDir::new(&scope)
160 .follow_links(false) .into_iter()
162 .filter_map(|e| e.ok())
163 .filter(|e| e.file_type().is_file())
164 {
165 let path = entry.path();
166 if path
168 .extension()
169 .and_then(|e| e.to_str())
170 .map(|e| {
171 matches!(
172 e,
173 "png" | "jpg" | "jpeg" | "gif" | "pdf" | "zip" | "gz" | "tar" | "bin"
174 )
175 })
176 .unwrap_or(false)
177 {
178 continue;
179 }
180 let Ok(meta) = std::fs::metadata(path) else {
184 continue;
185 };
186 if meta.len() > MAX_GREP_FILE_BYTES {
187 continue;
188 }
189 let Ok(contents) = std::fs::read_to_string(path) else {
190 continue;
191 };
192 let rel = self.relativise(path);
193 for (line_no, line) in contents.lines().enumerate() {
194 let is_match = match &re_opt {
195 Some(re) => re.is_match(line),
196 None => {
197 if case_insensitive {
198 line.to_ascii_lowercase()
199 .contains(&pattern.to_ascii_lowercase())
200 } else {
201 line.contains(pattern)
202 }
203 }
204 };
205 if is_match {
206 let truncated = if line.len() > DEFAULT_MAX_LINE_LEN {
207 let mut end = DEFAULT_MAX_LINE_LEN;
208 while !line.is_char_boundary(end) {
209 end -= 1;
210 }
211 format!("{}…", &line[..end])
212 } else {
213 line.to_string()
214 };
215 hits.push(format!("{}:{}: {}", rel.display(), line_no + 1, truncated));
216 if hits.len() >= cap {
217 break 'outer;
218 }
219 }
220 }
221 }
222
223 if hits.is_empty() {
224 Ok(format!("no matches for `{pattern}`"))
225 } else {
226 Ok(hits.join("\n"))
227 }
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use tempfile::TempDir;
235
236 fn write(dir: &TempDir, name: &str, body: &str) {
237 std::fs::write(dir.path().join(name), body).unwrap();
238 }
239
240 #[tokio::test]
241 async fn finds_matches_with_path_and_line_number() {
242 let tmp = TempDir::new().unwrap();
243 write(&tmp, "a.txt", "foo\nbar\nbaz");
244 write(&tmp, "b.txt", "bar quux");
245 let out = SearchFiles::new(tmp.path())
246 .execute(json!({"pattern": "bar"}))
247 .await
248 .unwrap();
249 assert!(out.contains("a.txt:2: bar"));
250 assert!(out.contains("b.txt:1: bar quux"));
251 }
252
253 #[tokio::test]
254 async fn empty_pattern_is_rejected() {
255 let tmp = TempDir::new().unwrap();
256 let err = SearchFiles::new(tmp.path())
257 .execute(json!({"pattern": ""}))
258 .await
259 .unwrap_err();
260 assert!(matches!(err, Error::BadToolArgs { .. }));
261 }
262
263 #[tokio::test]
264 async fn returns_no_match_message_when_empty() {
265 let tmp = TempDir::new().unwrap();
266 write(&tmp, "a.txt", "hello world");
267 let out = SearchFiles::new(tmp.path())
268 .execute(json!({"pattern": "zzzz"}))
269 .await
270 .unwrap();
271 assert!(out.contains("no matches"));
272 }
273
274 #[tokio::test]
275 async fn respects_max_results_cap() {
276 let tmp = TempDir::new().unwrap();
277 let body: String = (0..10).map(|_| "needle\n").collect();
278 write(&tmp, "many.txt", &body);
279 let out = SearchFiles::new(tmp.path())
280 .execute(json!({"pattern": "needle", "max_results": 3}))
281 .await
282 .unwrap();
283 assert_eq!(out.lines().count(), 3);
284 }
285
286 #[tokio::test]
287 async fn grep_skips_files_larger_than_cap() {
288 let tmp = TempDir::new().unwrap();
289 let big = format!("{}\nmatch\n", "a".repeat(MAX_GREP_FILE_BYTES as usize));
291 assert!(big.len() as u64 > MAX_GREP_FILE_BYTES);
292 write(&tmp, "big.log", &big);
293 write(&tmp, "small.txt", "match\n");
295 let out = SearchFiles::new(tmp.path())
296 .execute(json!({"pattern": "match"}))
297 .await
298 .unwrap();
299 assert!(out.contains("small.txt:1: match"));
300 assert!(!out.contains("big.log"));
301 }
302
303 #[tokio::test]
304 async fn path_argument_scopes_search() {
305 let tmp = TempDir::new().unwrap();
306 std::fs::create_dir(tmp.path().join("sub")).unwrap();
307 write(&tmp, "outside.txt", "hit");
308 std::fs::write(tmp.path().join("sub/inside.txt"), "hit").unwrap();
309 let out = SearchFiles::new(tmp.path())
310 .execute(json!({"pattern": "hit", "path": "sub"}))
311 .await
312 .unwrap();
313 assert!(out.contains("inside.txt"));
314 assert!(!out.contains("outside.txt"));
315 }
316 #[tokio::test]
317 async fn regex_mode_matches_pattern() {
318 let tmp = TempDir::new().unwrap();
319 write(&tmp, "lib.rs", "fn foo() {}\nfn bar() {}\nfn foobar() {}");
320 let out = SearchFiles::new(tmp.path())
321 .execute(json!({"pattern": "fn f\\w+", "regex": true}))
322 .await
323 .unwrap();
324 assert!(out.contains("foo"));
325 assert!(out.contains("foobar"));
326 assert!(!out.contains(": fn bar()"));
327 }
328 #[tokio::test]
329 async fn regex_mode_invalid_pattern_is_bad_args() {
330 let tmp = TempDir::new().unwrap();
331 let err = SearchFiles::new(tmp.path())
332 .execute(json!({"pattern": "(unclosed", "regex": true}))
333 .await
334 .unwrap_err();
335 assert!(matches!(err, Error::BadToolArgs { .. }));
336 assert!(format!("{err}").contains("invalid regex"));
337 }
338 #[tokio::test]
339 async fn literal_mode_treats_pattern_as_substring() {
340 let tmp = TempDir::new().unwrap();
341 write(&tmp, "data.txt", "abc\nadc");
342 let out = SearchFiles::new(tmp.path())
343 .execute(json!({"pattern": "a.c"}))
344 .await
345 .unwrap();
346 assert!(out.contains("no matches"));
347 }
348 #[tokio::test]
349 async fn regex_mode_with_path_scope() {
350 let tmp = TempDir::new().unwrap();
351 std::fs::create_dir(tmp.path().join("src")).unwrap();
352 write(&tmp, "outside.txt", "fn main()");
353 std::fs::write(tmp.path().join("src/lib.rs"), "fn helper()\nfn main()").unwrap();
354 let out = SearchFiles::new(tmp.path())
355 .execute(json!({"pattern": "fn \\w+", "regex": true, "path": "src"}))
356 .await
357 .unwrap();
358 assert!(out.contains("helper"));
359 assert!(out.contains("main"));
360 assert!(!out.contains("outside.txt"));
361 }
362
363 #[tokio::test]
365 async fn literal_mode_case_insensitive_finds_match() {
366 let tmp = TempDir::new().unwrap();
367 write(
368 &tmp,
369 "todo.txt",
370 "TODO: fix this
371todo: done",
372 );
373 let out = SearchFiles::new(tmp.path())
374 .execute(json!({"pattern": "TODO", "case_insensitive": true}))
375 .await
376 .unwrap();
377 assert!(out.contains("TODO: fix this"));
378 assert!(out.contains("todo: done"));
379 }
380
381 #[tokio::test]
382 async fn regex_mode_case_insensitive_finds_match() {
383 let tmp = TempDir::new().unwrap();
384 write(
385 &tmp,
386 "test.txt",
387 "foo123
388bar456",
389 );
390 let out = SearchFiles::new(tmp.path())
391 .execute(json!({"pattern": r"FOO\d+", "regex": true, "case_insensitive": true}))
392 .await
393 .unwrap();
394 assert!(out.contains("foo123"));
395 assert!(!out.contains("bar456"));
396 }
397
398 #[tokio::test]
399 async fn case_sensitive_by_default() {
400 let tmp = TempDir::new().unwrap();
401 write(
402 &tmp,
403 "mixed.txt",
404 "TODO
405todo
406Todo",
407 );
408 let out = SearchFiles::new(tmp.path())
410 .execute(json!({"pattern": "TODO"}))
411 .await
412 .unwrap();
413 assert!(out.contains("TODO"));
414 assert!(!out.contains("todo"));
415 assert!(!out.contains("Todo"));
416 }
417
418 #[tokio::test]
422 async fn long_line_with_multibyte_unicode_does_not_panic() {
423 let tmp = TempDir::new().unwrap();
424
425 let mut long_line = String::from("search_target ");
429 while long_line.len() <= 240 {
430 long_line.push('─');
431 }
432 let body = format!("{long_line}\nno match here\n");
434 write(&tmp, "unicode.txt", &body);
435
436 let out = SearchFiles::new(tmp.path())
438 .execute(json!({"pattern": "search_target"}))
439 .await
440 .unwrap();
441 assert!(out.contains("unicode.txt:1:"));
442 assert!(out.contains("search_target"));
443 }
444
445 #[tokio::test]
448 async fn jsonl_line_with_unicode_box_drawing_does_not_panic() {
449 let tmp = TempDir::new().unwrap();
450 let separator = "─".repeat(60); let content_field =
453 format!("pub struct AgentRuntime{{}}\\n// {separator}─────────────────────────");
454 let line = format!(
455 "{{\"role\":\"tool\",\"content\":\"# range: lines 1-50 of 200\\\\n{content_field}\"}}"
456 );
457 assert!(
458 line.len() > 240,
459 "test line must exceed truncation limit; got {}",
460 line.len()
461 );
462 write(&tmp, "transcript.jsonl", &line);
463
464 let out = SearchFiles::new(tmp.path())
465 .execute(json!({"pattern": "pub struct AgentRuntime"}))
466 .await
467 .unwrap();
468 assert!(out.contains("transcript.jsonl:1:"));
469 }
470}