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 is_readonly(&self) -> bool {
54        true
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                        format!("{}…", &line[..DEFAULT_MAX_LINE_LEN])
144                    } else {
145                        line.to_string()
146                    };
147                    hits.push(format!("{}:{}: {}", rel.display(), line_no + 1, truncated));
148                    if hits.len() >= cap {
149                        break 'outer;
150                    }
151                }
152            }
153        }
154
155        if hits.is_empty() {
156            Ok(format!("no matches for `{pattern}`"))
157        } else {
158            Ok(hits.join("\n"))
159        }
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use tempfile::TempDir;
167
168    fn write(dir: &TempDir, name: &str, body: &str) {
169        std::fs::write(dir.path().join(name), body).unwrap();
170    }
171
172    #[tokio::test]
173    async fn finds_matches_with_path_and_line_number() {
174        let tmp = TempDir::new().unwrap();
175        write(&tmp, "a.txt", "foo\nbar\nbaz");
176        write(&tmp, "b.txt", "bar quux");
177        let out = SearchFiles::new(tmp.path())
178            .execute(json!({"pattern": "bar"}))
179            .await
180            .unwrap();
181        assert!(out.contains("a.txt:2: bar"));
182        assert!(out.contains("b.txt:1: bar quux"));
183    }
184
185    #[tokio::test]
186    async fn empty_pattern_is_rejected() {
187        let tmp = TempDir::new().unwrap();
188        let err = SearchFiles::new(tmp.path())
189            .execute(json!({"pattern": ""}))
190            .await
191            .unwrap_err();
192        assert!(matches!(err, Error::BadToolArgs { .. }));
193    }
194
195    #[tokio::test]
196    async fn returns_no_match_message_when_empty() {
197        let tmp = TempDir::new().unwrap();
198        write(&tmp, "a.txt", "hello world");
199        let out = SearchFiles::new(tmp.path())
200            .execute(json!({"pattern": "zzzz"}))
201            .await
202            .unwrap();
203        assert!(out.contains("no matches"));
204    }
205
206    #[tokio::test]
207    async fn respects_max_results_cap() {
208        let tmp = TempDir::new().unwrap();
209        let body: String = (0..10).map(|_| "needle\n").collect();
210        write(&tmp, "many.txt", &body);
211        let out = SearchFiles::new(tmp.path())
212            .execute(json!({"pattern": "needle", "max_results": 3}))
213            .await
214            .unwrap();
215        assert_eq!(out.lines().count(), 3);
216    }
217
218    #[tokio::test]
219    async fn path_argument_scopes_search() {
220        let tmp = TempDir::new().unwrap();
221        std::fs::create_dir(tmp.path().join("sub")).unwrap();
222        write(&tmp, "outside.txt", "hit");
223        std::fs::write(tmp.path().join("sub/inside.txt"), "hit").unwrap();
224        let out = SearchFiles::new(tmp.path())
225            .execute(json!({"pattern": "hit", "path": "sub"}))
226            .await
227            .unwrap();
228        assert!(out.contains("inside.txt"));
229        assert!(!out.contains("outside.txt"));
230    }
231    #[tokio::test]
232    async fn regex_mode_matches_pattern() {
233        let tmp = TempDir::new().unwrap();
234        write(&tmp, "lib.rs", "fn foo() {}\nfn bar() {}\nfn foobar() {}");
235        let out = SearchFiles::new(tmp.path())
236            .execute(json!({"pattern": "fn f\\w+", "regex": true}))
237            .await
238            .unwrap();
239        assert!(out.contains("foo"));
240        assert!(out.contains("foobar"));
241        assert!(!out.contains(": fn bar()"));
242    }
243    #[tokio::test]
244    async fn regex_mode_invalid_pattern_is_bad_args() {
245        let tmp = TempDir::new().unwrap();
246        let err = SearchFiles::new(tmp.path())
247            .execute(json!({"pattern": "(unclosed", "regex": true}))
248            .await
249            .unwrap_err();
250        assert!(matches!(err, Error::BadToolArgs { .. }));
251        assert!(format!("{err}").contains("invalid regex"));
252    }
253    #[tokio::test]
254    async fn literal_mode_treats_pattern_as_substring() {
255        let tmp = TempDir::new().unwrap();
256        write(&tmp, "data.txt", "abc\nadc");
257        let out = SearchFiles::new(tmp.path())
258            .execute(json!({"pattern": "a.c"}))
259            .await
260            .unwrap();
261        assert!(out.contains("no matches"));
262    }
263    #[tokio::test]
264    async fn regex_mode_with_path_scope() {
265        let tmp = TempDir::new().unwrap();
266        std::fs::create_dir(tmp.path().join("src")).unwrap();
267        write(&tmp, "outside.txt", "fn main()");
268        std::fs::write(tmp.path().join("src/lib.rs"), "fn helper()\nfn main()").unwrap();
269        let out = SearchFiles::new(tmp.path())
270            .execute(json!({"pattern": "fn \\w+", "regex": true, "path": "src"}))
271            .await
272            .unwrap();
273        assert!(out.contains("helper"));
274        assert!(out.contains("main"));
275        assert!(!out.contains("outside.txt"));
276    }
277
278    // Tests for case_insensitive flag (goal-29)
279    #[tokio::test]
280    async fn literal_mode_case_insensitive_finds_match() {
281        let tmp = TempDir::new().unwrap();
282        write(
283            &tmp,
284            "todo.txt",
285            "TODO: fix this
286todo: done",
287        );
288        let out = SearchFiles::new(tmp.path())
289            .execute(json!({"pattern": "TODO", "case_insensitive": true}))
290            .await
291            .unwrap();
292        assert!(out.contains("TODO: fix this"));
293        assert!(out.contains("todo: done"));
294    }
295
296    #[tokio::test]
297    async fn regex_mode_case_insensitive_finds_match() {
298        let tmp = TempDir::new().unwrap();
299        write(
300            &tmp,
301            "test.txt",
302            "foo123
303bar456",
304        );
305        let out = SearchFiles::new(tmp.path())
306            .execute(json!({"pattern": r"FOO\d+", "regex": true, "case_insensitive": true}))
307            .await
308            .unwrap();
309        assert!(out.contains("foo123"));
310        assert!(!out.contains("bar456"));
311    }
312
313    #[tokio::test]
314    async fn case_sensitive_by_default() {
315        let tmp = TempDir::new().unwrap();
316        write(
317            &tmp,
318            "mixed.txt",
319            "TODO
320todo
321Todo",
322        );
323        // Without case_insensitive, should only find exact match
324        let out = SearchFiles::new(tmp.path())
325            .execute(json!({"pattern": "TODO"}))
326            .await
327            .unwrap();
328        assert!(out.contains("TODO"));
329        assert!(!out.contains("todo"));
330        assert!(!out.contains("Todo"));
331    }
332}