Skip to main content

recursive/tools/
search.rs

1//! `search_files`: substring/regex search across workspace files.
2
3use 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
16/// Maximum file size Grep will read into memory. Larger files are skipped
17/// (silently, matching the binary-extension skip) to avoid OOM on logs /
18/// data dumps / bundled artifacts. 1 MiB covers virtually all source files.
19const 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    /// Append additional allowed sandbox roots. See
40    /// [`crate::tools::fs::ReadFile::with_extra_roots`].
41    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    /// Attach the shared, session-mutable roots slot. See [`SharedSandboxRoots`].
50    pub fn with_session_roots(mut self, slot: SharedSandboxRoots) -> Self {
51        self.session_roots = Some(slot);
52        self
53    }
54
55    /// Convenience: attach the shared slot only when `Some`.
56    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) // explicit: default, documents symlink-loop safety
161            .into_iter()
162            .filter_map(|e| e.ok())
163            .filter(|e| e.file_type().is_file())
164        {
165            let path = entry.path();
166            // Skip obvious binaries / large files by name. Cheap heuristic.
167            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            // Skip files that would OOM if read wholesale. Source files are well
181            // under 1 MiB; anything larger is a log/data/artifact that grep
182            // shouldn't slurp. Silent skip, matching the binary-extension skip.
183            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        // File larger than MAX_GREP_FILE_BYTES (1 MiB + 1) that WOULD match.
290        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        // Small file that also matches.
294        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    // Tests for case_insensitive flag (goal-29)
364    #[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        // Without case_insensitive, should only find exact match
409        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    /// Regression test: long lines containing multi-byte Unicode characters (like the
419    /// box-drawing `─` U+2500, encoded as 3 UTF-8 bytes E2 94 80) must not panic
420    /// when the truncation boundary lands in the middle of a multi-byte sequence.
421    #[tokio::test]
422    async fn long_line_with_multibyte_unicode_does_not_panic() {
423        let tmp = TempDir::new().unwrap();
424
425        // Build a line where the 240-byte boundary lands inside `─` (U+2500, 3 bytes).
426        // "search_target " is 14 ASCII bytes. Then append `─` chars until line > 240 bytes.
427        // `─` takes 3 bytes, so we need (240 - 14) / 3 ≈ 75 chars to land near boundary.
428        let mut long_line = String::from("search_target ");
429        while long_line.len() <= 240 {
430            long_line.push('─');
431        }
432        // Append a suffix so the file has another match-less line too.
433        let body = format!("{long_line}\nno match here\n");
434        write(&tmp, "unicode.txt", &body);
435
436        // Must not panic.
437        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    /// Regression test: JSONL files with multi-byte Unicode inside a long JSON line
446    /// (mimicking a session transcript that contains box-drawing characters) must not panic.
447    #[tokio::test]
448    async fn jsonl_line_with_unicode_box_drawing_does_not_panic() {
449        let tmp = TempDir::new().unwrap();
450        // Construct a long JSONL-like line that contains the target pattern AND box-drawing chars.
451        let separator = "─".repeat(60); // 60 × 3 bytes = 180 bytes
452        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}