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, 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#[derive(Debug, Clone)]
17pub struct SearchFiles {
18    pub root: PathBuf,
19    pub max_results: usize,
20}
21
22impl SearchFiles {
23    pub fn new(root: impl Into<PathBuf>) -> Self {
24        Self {
25            root: root.into(),
26            max_results: DEFAULT_MAX_RESULTS,
27        }
28    }
29}
30
31#[async_trait]
32impl Tool for SearchFiles {
33    fn spec(&self) -> ToolSpec {
34        ToolSpec {
35            name: "search_files".into(),
36            description:
37                "Find lines containing a pattern (literal substring or regex) across files in the workspace. Returns up to N matches as 'path:line: text'."
38                    .into(),
39            parameters: json!({
40                "type": "object",
41                "properties": {
42                    "pattern":   { "type": "string", "description": "Pattern to search for. Literal substring by default; use `regex: true` for regex mode." },
43                    "path":      { "type": "string", "description": "Optional subdirectory (workspace-relative) to scope the search to. Defaults to workspace root." },
44                    "max_results": { "type": "integer", "description": "Cap on results (default 50, max 200)." },
45                    "regex":     { "type": "boolean", "description": "If true, interpret `pattern` as a regular expression (Rust regex crate syntax). Default false (literal substring)." },
46                    "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." }
47                },
48                "required": ["pattern"]
49            }),
50        }
51    }
52
53    fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
54        crate::tools::ToolSideEffect::ReadOnly
55    }
56
57    async fn execute(&self, args: Value) -> Result<String> {
58        let pattern = args["pattern"].as_str().ok_or_else(|| Error::BadToolArgs {
59            name: "search_files".into(),
60            message: "missing `pattern`".into(),
61        })?;
62        if pattern.is_empty() {
63            return Err(Error::BadToolArgs {
64                name: "search_files".into(),
65                message: "`pattern` must not be empty".into(),
66            });
67        }
68
69        let use_regex = args.get("regex").and_then(|v| v.as_bool()).unwrap_or(false);
70        let case_insensitive = args
71            .get("case_insensitive")
72            .and_then(|v| v.as_bool())
73            .unwrap_or(false);
74        let re_opt: Option<Regex> = if use_regex {
75            let regex = if case_insensitive {
76                regex::RegexBuilder::new(pattern)
77                    .case_insensitive(true)
78                    .build()
79            } else {
80                Regex::new(pattern)
81            };
82            Some(regex.map_err(|e| Error::BadToolArgs {
83                name: "search_files".into(),
84                message: format!("invalid regex: {e}"),
85            })?)
86        } else {
87            None
88        };
89
90        let scope = match args.get("path").and_then(|v| v.as_str()) {
91            Some(p) => resolve_within(&self.root, p).map_err(|e| Error::BadToolArgs {
92                name: "search_files".into(),
93                message: format!("path: {e}"),
94            })?,
95            None => self.root.clone(),
96        };
97
98        let cap = args
99            .get("max_results")
100            .and_then(|v| v.as_u64())
101            .map(|n| (n as usize).min(200))
102            .unwrap_or(self.max_results);
103
104        let mut hits: Vec<String> = Vec::new();
105        'outer: for entry in WalkDir::new(&scope)
106            .into_iter()
107            .filter_map(|e| e.ok())
108            .filter(|e| e.file_type().is_file())
109        {
110            let path = entry.path();
111            // Skip obvious binaries / large files by name. Cheap heuristic.
112            if path
113                .extension()
114                .and_then(|e| e.to_str())
115                .map(|e| {
116                    matches!(
117                        e,
118                        "png" | "jpg" | "jpeg" | "gif" | "pdf" | "zip" | "gz" | "tar" | "bin"
119                    )
120                })
121                .unwrap_or(false)
122            {
123                continue;
124            }
125            let Ok(contents) = std::fs::read_to_string(path) else {
126                continue;
127            };
128            let rel = path.strip_prefix(&self.root).unwrap_or(path);
129            for (line_no, line) in contents.lines().enumerate() {
130                let is_match = match &re_opt {
131                    Some(re) => re.is_match(line),
132                    None => {
133                        if case_insensitive {
134                            line.to_ascii_lowercase()
135                                .contains(&pattern.to_ascii_lowercase())
136                        } else {
137                            line.contains(pattern)
138                        }
139                    }
140                };
141                if is_match {
142                    let truncated = if line.len() > DEFAULT_MAX_LINE_LEN {
143                        let mut end = DEFAULT_MAX_LINE_LEN;
144                        while !line.is_char_boundary(end) {
145                            end -= 1;
146                        }
147                        format!("{}…", &line[..end])
148                    } else {
149                        line.to_string()
150                    };
151                    hits.push(format!("{}:{}: {}", rel.display(), line_no + 1, truncated));
152                    if hits.len() >= cap {
153                        break 'outer;
154                    }
155                }
156            }
157        }
158
159        if hits.is_empty() {
160            Ok(format!("no matches for `{pattern}`"))
161        } else {
162            Ok(hits.join("\n"))
163        }
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use tempfile::TempDir;
171
172    fn write(dir: &TempDir, name: &str, body: &str) {
173        std::fs::write(dir.path().join(name), body).unwrap();
174    }
175
176    #[tokio::test]
177    async fn finds_matches_with_path_and_line_number() {
178        let tmp = TempDir::new().unwrap();
179        write(&tmp, "a.txt", "foo\nbar\nbaz");
180        write(&tmp, "b.txt", "bar quux");
181        let out = SearchFiles::new(tmp.path())
182            .execute(json!({"pattern": "bar"}))
183            .await
184            .unwrap();
185        assert!(out.contains("a.txt:2: bar"));
186        assert!(out.contains("b.txt:1: bar quux"));
187    }
188
189    #[tokio::test]
190    async fn empty_pattern_is_rejected() {
191        let tmp = TempDir::new().unwrap();
192        let err = SearchFiles::new(tmp.path())
193            .execute(json!({"pattern": ""}))
194            .await
195            .unwrap_err();
196        assert!(matches!(err, Error::BadToolArgs { .. }));
197    }
198
199    #[tokio::test]
200    async fn returns_no_match_message_when_empty() {
201        let tmp = TempDir::new().unwrap();
202        write(&tmp, "a.txt", "hello world");
203        let out = SearchFiles::new(tmp.path())
204            .execute(json!({"pattern": "zzzz"}))
205            .await
206            .unwrap();
207        assert!(out.contains("no matches"));
208    }
209
210    #[tokio::test]
211    async fn respects_max_results_cap() {
212        let tmp = TempDir::new().unwrap();
213        let body: String = (0..10).map(|_| "needle\n").collect();
214        write(&tmp, "many.txt", &body);
215        let out = SearchFiles::new(tmp.path())
216            .execute(json!({"pattern": "needle", "max_results": 3}))
217            .await
218            .unwrap();
219        assert_eq!(out.lines().count(), 3);
220    }
221
222    #[tokio::test]
223    async fn path_argument_scopes_search() {
224        let tmp = TempDir::new().unwrap();
225        std::fs::create_dir(tmp.path().join("sub")).unwrap();
226        write(&tmp, "outside.txt", "hit");
227        std::fs::write(tmp.path().join("sub/inside.txt"), "hit").unwrap();
228        let out = SearchFiles::new(tmp.path())
229            .execute(json!({"pattern": "hit", "path": "sub"}))
230            .await
231            .unwrap();
232        assert!(out.contains("inside.txt"));
233        assert!(!out.contains("outside.txt"));
234    }
235    #[tokio::test]
236    async fn regex_mode_matches_pattern() {
237        let tmp = TempDir::new().unwrap();
238        write(&tmp, "lib.rs", "fn foo() {}\nfn bar() {}\nfn foobar() {}");
239        let out = SearchFiles::new(tmp.path())
240            .execute(json!({"pattern": "fn f\\w+", "regex": true}))
241            .await
242            .unwrap();
243        assert!(out.contains("foo"));
244        assert!(out.contains("foobar"));
245        assert!(!out.contains(": fn bar()"));
246    }
247    #[tokio::test]
248    async fn regex_mode_invalid_pattern_is_bad_args() {
249        let tmp = TempDir::new().unwrap();
250        let err = SearchFiles::new(tmp.path())
251            .execute(json!({"pattern": "(unclosed", "regex": true}))
252            .await
253            .unwrap_err();
254        assert!(matches!(err, Error::BadToolArgs { .. }));
255        assert!(format!("{err}").contains("invalid regex"));
256    }
257    #[tokio::test]
258    async fn literal_mode_treats_pattern_as_substring() {
259        let tmp = TempDir::new().unwrap();
260        write(&tmp, "data.txt", "abc\nadc");
261        let out = SearchFiles::new(tmp.path())
262            .execute(json!({"pattern": "a.c"}))
263            .await
264            .unwrap();
265        assert!(out.contains("no matches"));
266    }
267    #[tokio::test]
268    async fn regex_mode_with_path_scope() {
269        let tmp = TempDir::new().unwrap();
270        std::fs::create_dir(tmp.path().join("src")).unwrap();
271        write(&tmp, "outside.txt", "fn main()");
272        std::fs::write(tmp.path().join("src/lib.rs"), "fn helper()\nfn main()").unwrap();
273        let out = SearchFiles::new(tmp.path())
274            .execute(json!({"pattern": "fn \\w+", "regex": true, "path": "src"}))
275            .await
276            .unwrap();
277        assert!(out.contains("helper"));
278        assert!(out.contains("main"));
279        assert!(!out.contains("outside.txt"));
280    }
281
282    // Tests for case_insensitive flag (goal-29)
283    #[tokio::test]
284    async fn literal_mode_case_insensitive_finds_match() {
285        let tmp = TempDir::new().unwrap();
286        write(
287            &tmp,
288            "todo.txt",
289            "TODO: fix this
290todo: done",
291        );
292        let out = SearchFiles::new(tmp.path())
293            .execute(json!({"pattern": "TODO", "case_insensitive": true}))
294            .await
295            .unwrap();
296        assert!(out.contains("TODO: fix this"));
297        assert!(out.contains("todo: done"));
298    }
299
300    #[tokio::test]
301    async fn regex_mode_case_insensitive_finds_match() {
302        let tmp = TempDir::new().unwrap();
303        write(
304            &tmp,
305            "test.txt",
306            "foo123
307bar456",
308        );
309        let out = SearchFiles::new(tmp.path())
310            .execute(json!({"pattern": r"FOO\d+", "regex": true, "case_insensitive": true}))
311            .await
312            .unwrap();
313        assert!(out.contains("foo123"));
314        assert!(!out.contains("bar456"));
315    }
316
317    #[tokio::test]
318    async fn case_sensitive_by_default() {
319        let tmp = TempDir::new().unwrap();
320        write(
321            &tmp,
322            "mixed.txt",
323            "TODO
324todo
325Todo",
326        );
327        // Without case_insensitive, should only find exact match
328        let out = SearchFiles::new(tmp.path())
329            .execute(json!({"pattern": "TODO"}))
330            .await
331            .unwrap();
332        assert!(out.contains("TODO"));
333        assert!(!out.contains("todo"));
334        assert!(!out.contains("Todo"));
335    }
336
337    /// Regression test: long lines containing multi-byte Unicode characters (like the
338    /// box-drawing `─` U+2500, encoded as 3 UTF-8 bytes E2 94 80) must not panic
339    /// when the truncation boundary lands in the middle of a multi-byte sequence.
340    #[tokio::test]
341    async fn long_line_with_multibyte_unicode_does_not_panic() {
342        let tmp = TempDir::new().unwrap();
343
344        // Build a line where the 240-byte boundary lands inside `─` (U+2500, 3 bytes).
345        // "search_target " is 14 ASCII bytes. Then append `─` chars until line > 240 bytes.
346        // `─` takes 3 bytes, so we need (240 - 14) / 3 ≈ 75 chars to land near boundary.
347        let mut long_line = String::from("search_target ");
348        while long_line.len() <= 240 {
349            long_line.push('─');
350        }
351        // Append a suffix so the file has another match-less line too.
352        let body = format!("{long_line}\nno match here\n");
353        write(&tmp, "unicode.txt", &body);
354
355        // Must not panic.
356        let out = SearchFiles::new(tmp.path())
357            .execute(json!({"pattern": "search_target"}))
358            .await
359            .unwrap();
360        assert!(out.contains("unicode.txt:1:"));
361        assert!(out.contains("search_target"));
362    }
363
364    /// Regression test: JSONL files with multi-byte Unicode inside a long JSON line
365    /// (mimicking a session transcript that contains box-drawing characters) must not panic.
366    #[tokio::test]
367    async fn jsonl_line_with_unicode_box_drawing_does_not_panic() {
368        let tmp = TempDir::new().unwrap();
369        // Construct a long JSONL-like line that contains the target pattern AND box-drawing chars.
370        let separator = "─".repeat(60); // 60 × 3 bytes = 180 bytes
371        let content_field =
372            format!("pub struct AgentRuntime{{}}\\n// {separator}─────────────────────────");
373        let line = format!(
374            "{{\"role\":\"tool\",\"content\":\"# range: lines 1-50 of 200\\\\n{content_field}\"}}"
375        );
376        assert!(
377            line.len() > 240,
378            "test line must exceed truncation limit; got {}",
379            line.len()
380        );
381        write(&tmp, "transcript.jsonl", &line);
382
383        let out = SearchFiles::new(tmp.path())
384            .execute(json!({"pattern": "pub struct AgentRuntime"}))
385            .await
386            .unwrap();
387        assert!(out.contains("transcript.jsonl:1:"));
388    }
389}