Skip to main content

recursive/tools/
glob.rs

1//! `Glob` tool: find files matching a glob pattern inside the workspace.
2//!
3//! Uses `walkdir` (already in Cargo.toml) to walk the directory tree and
4//! matches entries using a simple built-in glob matcher that supports
5//! `*` (any characters within a single path component), `**` (any number
6//! of path components), and `?` (exactly one character).
7//!
8//! No extra crate dependency is needed.
9
10use async_trait::async_trait;
11use serde_json::{json, Value};
12use std::path::PathBuf;
13use walkdir::WalkDir;
14
15use super::{resolve_within_any, AccessTier, SharedSandboxRoots, Tool};
16use crate::acp::ToolKind;
17use crate::error::{Error, Result};
18use crate::llm::ToolSpec;
19
20const MAX_RESULTS: usize = 200;
21
22/// Match a single path component against a glob segment.
23/// Supports `*` (zero or more chars) and `?` (exactly one char).
24/// Does NOT support `**` at this level — that is handled by the outer
25/// walk logic.
26fn match_segment(pattern: &str, text: &str) -> bool {
27    let p: Vec<char> = pattern.chars().collect();
28    let t: Vec<char> = text.chars().collect();
29    match_seg_inner(&p, &t)
30}
31
32fn match_seg_inner(p: &[char], t: &[char]) -> bool {
33    match (p.first(), t.first()) {
34        (None, None) => true,
35        (Some(&'*'), _) => {
36            // `*` in a segment matches zero or more chars (within the segment)
37            // Try skipping zero, one, ... chars in t.
38            if match_seg_inner(&p[1..], t) {
39                return true;
40            }
41            if !t.is_empty() {
42                return match_seg_inner(p, &t[1..]);
43            }
44            false
45        }
46        (Some(&'?'), Some(_)) => match_seg_inner(&p[1..], &t[1..]),
47        (Some(pc), Some(tc)) if pc == tc => match_seg_inner(&p[1..], &t[1..]),
48        _ => false,
49    }
50}
51
52/// Match a path (given as its components) against a glob pattern split on `/`.
53///
54/// `**` in the pattern matches zero or more path components.
55fn match_path(pattern_parts: &[&str], path_parts: &[&str]) -> bool {
56    match (pattern_parts.first(), path_parts.first()) {
57        (None, None) => true,
58        (None, _) => false,
59        (Some(&"**"), _) => {
60            // `**` matches zero components
61            if match_path(&pattern_parts[1..], path_parts) {
62                return true;
63            }
64            // or consume one component and try again
65            if !path_parts.is_empty() {
66                return match_path(pattern_parts, &path_parts[1..]);
67            }
68            false
69        }
70        (_, None) => false,
71        (Some(pp), Some(tp)) => {
72            if match_segment(pp, tp) {
73                match_path(&pattern_parts[1..], &path_parts[1..])
74            } else {
75                false
76            }
77        }
78    }
79}
80
81/// Test if a workspace-relative path string matches a glob pattern string.
82fn glob_matches(pattern: &str, rel_path: &str) -> bool {
83    // Normalise to forward slashes on all platforms.
84    let rel = rel_path.replace('\\', "/");
85    let pat = pattern.replace('\\', "/");
86    let pattern_parts: Vec<&str> = pat.split('/').collect();
87    let path_parts: Vec<&str> = rel.split('/').collect();
88    match_path(&pattern_parts, &path_parts)
89}
90
91/// The `Glob` tool.
92#[derive(Debug, Clone)]
93pub struct GlobTool {
94    pub root: PathBuf,
95    pub extra_roots: Vec<(PathBuf, AccessTier)>,
96    pub session_roots: Option<SharedSandboxRoots>,
97}
98
99impl GlobTool {
100    pub fn new(root: impl Into<PathBuf>) -> Self {
101        Self {
102            root: root.into(),
103            extra_roots: Vec::new(),
104            session_roots: None,
105        }
106    }
107
108    /// Append additional allowed sandbox roots. See
109    /// [`crate::tools::fs::ReadFile::with_extra_roots`].
110    pub fn with_extra_roots(
111        mut self,
112        extra: impl IntoIterator<Item = (PathBuf, AccessTier)>,
113    ) -> Self {
114        self.extra_roots.extend(extra);
115        self
116    }
117
118    /// Attach the shared, session-mutable roots slot. See [`SharedSandboxRoots`].
119    pub fn with_session_roots(mut self, slot: SharedSandboxRoots) -> Self {
120        self.session_roots = Some(slot);
121        self
122    }
123
124    /// Convenience: attach the shared slot only when `Some`.
125    pub fn with_session_roots_opt(mut self, slot: Option<SharedSandboxRoots>) -> Self {
126        if let Some(s) = slot {
127            self.session_roots = Some(s);
128        }
129        self
130    }
131
132    fn all_roots(&self) -> Vec<(PathBuf, AccessTier)> {
133        let mut v: Vec<(PathBuf, AccessTier)> = Vec::with_capacity(self.extra_roots.len() + 1);
134        v.push((self.root.clone(), AccessTier::ReadWrite));
135        v.extend(self.extra_roots.iter().cloned());
136        if let Some(slot) = &self.session_roots {
137            if let Ok(roots) = slot.read() {
138                v.extend(roots.iter().cloned());
139            }
140        }
141        v
142    }
143
144    /// Render `entry_path` relative to the primary root when possible, else
145    /// relative to any matching extra root, else as-is (absolute). This keeps
146    /// workspace hits workspace-relative while extra-root hits become absolute
147    /// so the agent can feed them back to `Read`.
148    fn relativise(&self, entry_path: &std::path::Path) -> String {
149        if let Ok(rel) = entry_path.strip_prefix(&self.root) {
150            return rel.to_string_lossy().into_owned();
151        }
152        // Hits inside an extra root are reported as absolute paths so the
153        // agent can feed them straight back to `Read` (which accepts
154        // absolute paths under any allowed root).
155        if self
156            .extra_roots
157            .iter()
158            .any(|(extra, _)| entry_path.starts_with(extra))
159        {
160            return entry_path.to_string_lossy().into_owned();
161        }
162        entry_path.to_string_lossy().into_owned()
163    }
164}
165
166#[async_trait]
167impl Tool for GlobTool {
168    fn spec(&self) -> ToolSpec {
169        ToolSpec {
170            name: "Glob".into(),
171            description: "Find files matching a glob pattern (e.g. \"**/*.rs\") inside the workspace. Returns matching paths relative to the workspace root, one per line. Capped at 200 results.".into(),
172            parameters: json!({
173                "type": "object",
174                "properties": {
175                    "pattern": {
176                        "type": "string",
177                        "description": "Glob pattern to match. Supports `*` (within a path component), `**` (any number of components), and `?` (single character). Example: \"src/**/*.rs\""
178                    },
179                    "path": {
180                        "type": "string",
181                        "description": "Optional workspace-relative subdirectory to scope the search. Defaults to the workspace root."
182                    }
183                },
184                "required": ["pattern"]
185            }),
186        }
187    }
188
189    fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
190        crate::tools::ToolSideEffect::ReadOnly
191    }
192
193    fn kind(&self) -> ToolKind {
194        ToolKind::Search
195    }
196
197    async fn execute(&self, args: Value) -> Result<String> {
198        let pattern = args["pattern"].as_str().ok_or_else(|| Error::BadToolArgs {
199            name: "Glob".into(),
200            message: "missing `pattern`".into(),
201        })?;
202
203        if pattern.is_empty() {
204            return Err(Error::BadToolArgs {
205                name: "Glob".into(),
206                message: "`pattern` must not be empty".into(),
207            });
208        }
209
210        let scope = match args.get("path").and_then(|v| v.as_str()) {
211            Some(p) => {
212                resolve_within_any(&self.all_roots(), p, false).map_err(|e| Error::BadToolArgs {
213                    name: "Glob".into(),
214                    message: format!("path: {e}"),
215                })?
216            }
217            None => self.root.clone(),
218        };
219
220        let mut matches: Vec<String> = Vec::new();
221
222        for entry in WalkDir::new(&scope)
223            .into_iter()
224            .filter_map(|e| e.ok())
225            .filter(|e| e.file_type().is_file())
226        {
227            let rel = self.relativise(entry.path());
228
229            if glob_matches(pattern, &rel) {
230                matches.push(rel);
231                if matches.len() >= MAX_RESULTS {
232                    break;
233                }
234            }
235        }
236
237        matches.sort();
238
239        if matches.is_empty() {
240            Ok(format!("no files matching `{pattern}`"))
241        } else {
242            Ok(matches.join("\n"))
243        }
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use tempfile::TempDir;
251
252    fn create_files(dir: &TempDir, names: &[&str]) {
253        for name in names {
254            let path = dir.path().join(name);
255            if let Some(parent) = path.parent() {
256                std::fs::create_dir_all(parent).unwrap();
257            }
258            std::fs::write(path, "x").unwrap();
259        }
260    }
261
262    #[test]
263    fn glob_matches_extension_wildcard() {
264        assert!(glob_matches("**/*.rs", "src/lib.rs"));
265        assert!(glob_matches("**/*.rs", "src/tools/mod.rs"));
266        assert!(!glob_matches("**/*.rs", "src/lib.txt"));
267    }
268
269    #[test]
270    fn glob_matches_single_star() {
271        assert!(glob_matches("src/*.rs", "src/lib.rs"));
272        assert!(!glob_matches("src/*.rs", "src/tools/mod.rs")); // nested, not matching
273    }
274
275    #[test]
276    fn glob_matches_question_mark() {
277        assert!(glob_matches("src/?.rs", "src/a.rs"));
278        assert!(!glob_matches("src/?.rs", "src/ab.rs"));
279    }
280
281    #[test]
282    fn glob_matches_double_star_zero_components() {
283        // `**` can match zero components
284        assert!(glob_matches("**", "foo.rs"));
285        assert!(glob_matches("**/*.rs", "lib.rs"));
286    }
287
288    #[tokio::test]
289    async fn finds_rs_files_by_extension() {
290        let tmp = TempDir::new().unwrap();
291        create_files(&tmp, &["src/a.rs", "src/b.rs", "src/c.txt"]);
292        let tool = GlobTool::new(tmp.path());
293        let out = tool.execute(json!({"pattern": "**/*.rs"})).await.unwrap();
294        assert!(out.contains("a.rs"));
295        assert!(out.contains("b.rs"));
296        assert!(!out.contains("c.txt"));
297    }
298
299    #[tokio::test]
300    async fn scope_by_path_restricts_results() {
301        let tmp = TempDir::new().unwrap();
302        create_files(&tmp, &["src/a.rs", "tests/b.rs"]);
303        let tool = GlobTool::new(tmp.path());
304        let out = tool
305            .execute(json!({"pattern": "**/*.rs", "path": "src"}))
306            .await
307            .unwrap();
308        assert!(out.contains("a.rs"));
309        assert!(!out.contains("b.rs"));
310    }
311
312    #[tokio::test]
313    async fn no_matches_returns_informative_message() {
314        let tmp = TempDir::new().unwrap();
315        create_files(&tmp, &["foo.txt"]);
316        let tool = GlobTool::new(tmp.path());
317        let out = tool.execute(json!({"pattern": "**/*.rs"})).await.unwrap();
318        assert!(out.contains("no files matching"));
319    }
320
321    #[tokio::test]
322    async fn cap_at_200_results() {
323        let tmp = TempDir::new().unwrap();
324        // Create 210 .rs files
325        for i in 0..210usize {
326            std::fs::write(tmp.path().join(format!("f{i}.rs")), "x").unwrap();
327        }
328        let tool = GlobTool::new(tmp.path());
329        let out = tool.execute(json!({"pattern": "*.rs"})).await.unwrap();
330        assert_eq!(out.lines().count(), 200);
331    }
332
333    #[tokio::test]
334    async fn empty_pattern_returns_error() {
335        let tmp = TempDir::new().unwrap();
336        let tool = GlobTool::new(tmp.path());
337        let err = tool.execute(json!({"pattern": ""})).await.unwrap_err();
338        assert!(matches!(err, crate::error::Error::BadToolArgs { .. }));
339    }
340
341    // ── match_segment / match_seg_inner direct tests ─────────────────────────
342
343    #[test]
344    fn match_segment_exact_match_true() {
345        // kills `match_seg_inner -> bool with false`
346        assert!(match_segment("foo", "foo"), "exact match must be true");
347        assert!(
348            match_segment("", ""),
349            "empty vs empty must be true (None,None arm)"
350        );
351    }
352
353    #[test]
354    fn match_segment_literal_mismatch_false() {
355        // kills `match_segment -> bool with true`
356        assert!(
357            !match_segment("foo", "bar"),
358            "literal mismatch must be false"
359        );
360        assert!(
361            !match_segment("a", "b"),
362            "single-char mismatch must be false"
363        );
364    }
365
366    #[test]
367    fn match_segment_star_matches_multi_chars() {
368        // kills `delete match arm (Some(&'*'), _) in match_seg_inner`
369        assert!(
370            match_segment("*", "anything"),
371            "* must match non-empty text"
372        );
373        assert!(
374            match_segment("*", ""),
375            "* must match empty text (zero chars)"
376        );
377        assert!(match_segment("*.rs", "lib.rs"), "*.rs must match lib.rs");
378        assert!(
379            !match_segment("*.rs", "lib.txt"),
380            "*.rs must not match lib.txt"
381        );
382    }
383
384    #[test]
385    fn match_segment_star_consume_chars_delete_not() {
386        // Specifically targets `delete ! in match_seg_inner` line 40.
387        // `*a` must match "ba" — requires * to skip the leading 'b'.
388        // With `delete !`, the `if t.is_empty()` branch never recurses when t
389        // is non-empty, so "ba" would fail to match.
390        assert!(match_segment("*a", "ba"), "*a must match 'ba'");
391        assert!(match_segment("*a", "xxa"), "*a must match 'xxa'");
392        assert!(!match_segment("*a", "bx"), "*a must not match 'bx'");
393    }
394
395    #[test]
396    fn match_segment_question_marks() {
397        // kills `delete match arm (Some(&'?'), Some(_)) in match_seg_inner`
398        assert!(match_segment("?", "a"), "? must match exactly one char");
399        assert!(!match_segment("?", ""), "? must not match empty text");
400        assert!(!match_segment("?", "ab"), "? must not match two chars");
401        assert!(match_segment("a?c", "abc"), "a?c must match 'abc'");
402    }
403
404    #[test]
405    fn match_segment_guard_pc_eq_tc() {
406        // kills `replace match guard pc == tc with true`
407        // and `replace == with != in match_seg_inner`
408        assert!(!match_segment("a", "b"), "non-equal chars must not match");
409        assert!(match_segment("az", "az"), "equal chars must match");
410    }
411
412    // ── match_path / glob_matches direct tests ────────────────────────────────
413
414    #[test]
415    fn glob_matches_exact_literal_path() {
416        // kills `match_path -> bool with false` and `glob_matches -> bool with false`
417        assert!(
418            glob_matches("src/lib.rs", "src/lib.rs"),
419            "exact path must match"
420        );
421    }
422
423    #[test]
424    fn glob_matches_literal_mismatch_false() {
425        // kills `match_path -> bool with true` and `glob_matches -> bool with true`
426        assert!(
427            !glob_matches("src/foo.rs", "src/bar.rs"),
428            "different filename must not match"
429        );
430    }
431
432    #[test]
433    fn match_path_extra_component_false() {
434        // (None, _) arm: pattern exhausted but path has more components
435        assert!(
436            !glob_matches("src/lib.rs", "src/lib.rs/extra"),
437            "exhausted pattern with trailing component must be false"
438        );
439        // (_, None) arm: path exhausted but pattern has more
440        assert!(
441            !glob_matches("src/lib.rs/extra", "src/lib.rs"),
442            "trailing pattern component must be false"
443        );
444    }
445
446    #[test]
447    fn glob_matches_double_star_consumes_components() {
448        // kills `delete ! in match_path` line 64
449        // ** must be able to consume one or more path components
450        assert!(
451            glob_matches("**/*.rs", "a/b/c/lib.rs"),
452            "** must consume multiple components"
453        );
454        assert!(
455            !glob_matches("**/*.rs", "a/b/c/lib.txt"),
456            "** must not match wrong extension"
457        );
458    }
459
460    // ── GlobTool::relativise ──────────────────────────────────────────────────
461
462    #[test]
463    fn relativise_strips_workspace_root() {
464        let tmp = TempDir::new().unwrap();
465        let tool = GlobTool::new(tmp.path());
466        let abs = tmp.path().join("src/lib.rs");
467        let rel = tool.relativise(&abs);
468        assert_eq!(rel, "src/lib.rs", "relativise must strip workspace root");
469        assert!(
470            !rel.starts_with(tmp.path().to_str().unwrap()),
471            "result must not be absolute for workspace files"
472        );
473    }
474
475    // ── GlobTool::all_roots + scope path via execute ───────────────────────────
476
477    #[tokio::test]
478    async fn scope_path_uses_all_roots_correctly() {
479        let tmp = TempDir::new().unwrap();
480        create_files(&tmp, &["sub/a.rs", "sub/b.txt"]);
481        let tool = GlobTool::new(tmp.path());
482        // with path arg → resolve_within_any(all_roots(), ...) must work
483        let out = tool
484            .execute(json!({"pattern": "**/*.rs", "path": "sub"}))
485            .await
486            .unwrap();
487        assert!(out.contains("a.rs"), "sub/a.rs must appear in results");
488        assert!(!out.contains("b.txt"), "b.txt must be filtered by pattern");
489    }
490}