Skip to main content

bamboo_tools/tools/
glob.rs

1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
3use globset::{GlobBuilder, GlobSetBuilder};
4use serde::Deserialize;
5use std::path::{Path, PathBuf};
6
7use super::{parameter_schema, search_traversal, workspace_state};
8
9const DEFAULT_GLOB_MATCHES: usize = 100;
10const MAX_GLOB_MATCHES: usize = 200;
11const MAX_GLOB_SCANNED_FILES: usize = 50_000;
12const SEARCH_SCOPE_TOO_BROAD_ERROR: &str =
13    "Search scope too broad. Add path/glob/type or reduce pattern.";
14
15#[derive(Debug, Deserialize, schemars::JsonSchema)]
16#[schemars(deny_unknown_fields)]
17struct GlobArgs {
18    /// The glob pattern to match files against (for example **/*.rs or src/**/*.ts)
19    pattern: String,
20    /// The directory to search in. Omit to use the current workspace root.
21    #[serde(default)]
22    #[schemars(with = "String", skip_serializing_if = "Option::is_none")]
23    path: Option<String>,
24    /// Maximum number of returned matches (default 100, hard cap 200). Use a smaller limit for broad searches.
25    #[serde(default)]
26    #[schemars(
27        schema_with = "parameter_schema::number",
28        skip_serializing_if = "Option::is_none"
29    )]
30    limit: Option<usize>,
31    /// Include gitignored files. Requires an explicit path; scan/result limits and fixed directory exclusions still apply.
32    #[serde(default)]
33    include_ignored: bool,
34}
35
36pub struct GlobTool;
37
38impl GlobTool {
39    pub fn new() -> Self {
40        Self
41    }
42
43    fn is_unbounded_pattern(pattern: &str) -> bool {
44        let normalized = pattern.trim().replace('\\', "/");
45        matches!(
46            normalized.as_str(),
47            "*" | "**" | "**/*" | "**/**" | "./**/*" | ".//**/*"
48        )
49    }
50}
51
52impl Default for GlobTool {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58#[async_trait]
59impl Tool for GlobTool {
60    fn name(&self) -> &str {
61        "Glob"
62    }
63
64    fn description(&self) -> &str {
65        "Fast file pattern matching tool. Directory searches respect repository .gitignore rules, including parent rules up to the Git root; non-repository searches do not apply ignore rules. Hidden files remain visible; global Git ignores, .ignore and .git/info/exclude are not applied. Use it to find candidate files before deeper Read or Grep steps. Avoid unbounded root patterns without narrowing path or pattern."
66    }
67
68    fn classify(&self, _args: &serde_json::Value) -> ToolClass {
69        ToolClass::READONLY_PARALLEL
70    }
71
72    fn parameters_schema(&self) -> serde_json::Value {
73        parameter_schema::for_arguments::<GlobArgs>()
74    }
75
76    async fn invoke(
77        &self,
78        args: serde_json::Value,
79        ctx: ToolCtx,
80    ) -> Result<ToolOutcome, ToolError> {
81        let parsed: GlobArgs = serde_json::from_value(args)
82            .map_err(|e| ToolError::InvalidArguments(format!("Invalid Glob args: {}", e)))?;
83
84        if parsed.include_ignored && parsed.path.is_none() {
85            return Err(ToolError::InvalidArguments(
86                "include_ignored requires an explicit path.".to_string(),
87            ));
88        }
89
90        if parsed.path.is_none() && Self::is_unbounded_pattern(&parsed.pattern) {
91            return Err(ToolError::InvalidArguments(
92                SEARCH_SCOPE_TOO_BROAD_ERROR.to_string(),
93            ));
94        }
95
96        let default_root = workspace_state::workspace_or_process_cwd(ctx.session_id());
97        let root = parsed
98            .path
99            .as_ref()
100            .map(|value| {
101                let path = PathBuf::from(value);
102                if path.is_absolute() {
103                    path
104                } else {
105                    default_root.join(path)
106                }
107            })
108            .unwrap_or(default_root);
109
110        if !root.exists() || !root.is_dir() {
111            return Err(ToolError::Execution(format!(
112                "Search path is not a directory: {}",
113                root.display()
114            )));
115        }
116
117        let limit = parsed
118            .limit
119            .unwrap_or(DEFAULT_GLOB_MATCHES)
120            .clamp(1, MAX_GLOB_MATCHES);
121
122        let mut glob_builder = GlobSetBuilder::new();
123        let glob = GlobBuilder::new(parsed.pattern.trim())
124            .literal_separator(false)
125            .build()
126            .map_err(|e| ToolError::InvalidArguments(format!("Invalid glob pattern: {}", e)))?;
127        glob_builder.add(glob);
128        let glob_set = glob_builder
129            .build()
130            .map_err(|e| ToolError::Execution(format!("Failed to compile glob: {}", e)))?;
131
132        let mut matches: Vec<(String, u64)> = Vec::new();
133        let mut total_matches = 0usize;
134        let mut scanned_files = 0usize;
135        let mut scan_truncated = false;
136
137        for entry in
138            search_traversal::walk(&root, parsed.include_ignored).filter_map(|entry| entry.ok())
139        {
140            if !entry.file_type().is_some_and(|kind| kind.is_file()) {
141                continue;
142            }
143
144            scanned_files += 1;
145            if scanned_files > MAX_GLOB_SCANNED_FILES {
146                scan_truncated = true;
147                break;
148            }
149
150            let path = entry.path();
151            let relative = path.strip_prefix(&root).unwrap_or(path);
152            if !glob_set.is_match(relative) && !glob_set.is_match(path) {
153                continue;
154            }
155
156            total_matches += 1;
157            let modified = entry
158                .metadata()
159                .ok()
160                .and_then(|m| m.modified().ok())
161                .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
162                .map(|duration| duration.as_secs())
163                .unwrap_or(0);
164            matches.push((
165                bamboo_config::paths::path_to_display_string(Path::new(path)),
166                modified,
167            ));
168        }
169
170        matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
171
172        let mut result_lines: Vec<String> = matches
173            .into_iter()
174            .take(limit)
175            .map(|(path, _)| path)
176            .collect();
177
178        if total_matches > limit {
179            result_lines.push(format!(
180                "[TRUNCATED] Showing first {limit} matches (matched {total_matches}). Refine pattern/path and retry."
181            ));
182        }
183
184        if scan_truncated {
185            result_lines.push(format!(
186                "[PARTIAL] Stopped after scanning {} files. Narrow path/pattern to improve results.",
187                MAX_GLOB_SCANNED_FILES
188            ));
189        }
190
191        Ok(ToolOutcome::Completed(ToolResult {
192            success: true,
193            result: result_lines.join("\n"),
194            display_preference: Some("Collapsible".to_string()),
195            images: Vec::new(),
196        }))
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::{GlobArgs, GlobTool};
203    use bamboo_agent_core::{Tool, ToolCtx, ToolOutcome};
204    use serde_json::json;
205
206    #[test]
207    fn glob_args_preserve_optional_defaults_and_unknown_fields() {
208        for args in [
209            json!({"pattern": "**/*.rs"}),
210            json!({
211                "pattern": "**/*.rs",
212                "path": null,
213                "limit": null,
214                "unknown": true
215            }),
216        ] {
217            let parsed: GlobArgs = serde_json::from_value(args).unwrap();
218            assert_eq!(parsed.pattern, "**/*.rs");
219            assert_eq!(parsed.path, None);
220            assert_eq!(parsed.limit, None);
221            assert!(!parsed.include_ignored);
222        }
223    }
224
225    #[test]
226    fn glob_args_preserve_invalid_argument_rejection() {
227        for invalid in [json!(-1), json!(1.5), json!("1"), json!(true)] {
228            assert!(serde_json::from_value::<GlobArgs>(json!({
229                "pattern": "**/*.rs",
230                "limit": invalid
231            }))
232            .is_err());
233        }
234        for invalid in [json!(null), json!(0), json!("false")] {
235            assert!(serde_json::from_value::<GlobArgs>(json!({
236                "pattern": "**/*.rs",
237                "include_ignored": invalid
238            }))
239            .is_err());
240        }
241        for args in [
242            json!({}),
243            json!({"pattern": null}),
244            json!({"pattern": "**/*.rs", "path": 1}),
245        ] {
246            assert!(serde_json::from_value::<GlobArgs>(args).is_err());
247        }
248    }
249
250    fn result_lines(result: &bamboo_agent_core::ToolResult) -> Vec<&str> {
251        result
252            .result
253            .lines()
254            .filter(|line| !line.is_empty())
255            .collect()
256    }
257
258    #[tokio::test]
259    async fn glob_rejects_unbounded_default_root_pattern() {
260        let tool = GlobTool::new();
261        let error = tool
262            .invoke(
263                json!({
264                    "pattern": "**/*"
265                }),
266                ToolCtx::none("t"),
267            )
268            .await
269            .expect_err("unbounded root glob should fail");
270        assert!(error
271            .to_string()
272            .contains(super::SEARCH_SCOPE_TOO_BROAD_ERROR));
273    }
274
275    #[tokio::test]
276    async fn glob_truncates_to_max_matches_with_notice() {
277        let dir = tempfile::tempdir().unwrap();
278        std::fs::create_dir(dir.path().join(".git")).unwrap();
279        tokio::fs::write(dir.path().join(".gitignore"), "*.txt\n")
280            .await
281            .unwrap();
282        for idx in 0..520 {
283            let file = dir.path().join(format!("f-{idx}.txt"));
284            tokio::fs::write(file, "x").await.unwrap();
285        }
286
287        let tool = GlobTool::new();
288        let out = tool
289            .invoke(
290                json!({
291                    "pattern": "**/*.txt",
292                    "path": dir.path(),
293                    "limit": 120,
294                    "include_ignored": true
295                }),
296                ToolCtx::none("t"),
297            )
298            .await
299            .unwrap();
300        let ToolOutcome::Completed(result) = out else {
301            panic!("expected Completed")
302        };
303
304        let lines = result_lines(&result);
305        assert_eq!(lines.len(), 121);
306        assert!(lines
307            .last()
308            .copied()
309            .unwrap_or_default()
310            .contains("[TRUNCATED]"));
311    }
312
313    #[tokio::test]
314    async fn glob_keeps_modification_time_order_and_name_tie_breaking() {
315        let dir = tempfile::tempdir().unwrap();
316        let older = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
317        let newer = older + std::time::Duration::from_secs(20);
318        for (name, time) in [
319            ("z-old.txt", older),
320            ("b-new.txt", newer),
321            ("a-new.txt", newer),
322        ] {
323            let file = std::fs::File::create(dir.path().join(name)).unwrap();
324            file.set_modified(time).unwrap();
325        }
326
327        let ToolOutcome::Completed(result) = GlobTool::new()
328            .invoke(
329                json!({"pattern": "*.txt", "path": dir.path()}),
330                ToolCtx::none("t"),
331            )
332            .await
333            .unwrap()
334        else {
335            panic!("expected Completed")
336        };
337        let names: Vec<_> = result_lines(&result)
338            .into_iter()
339            .map(|path| {
340                std::path::Path::new(path)
341                    .file_name()
342                    .unwrap()
343                    .to_str()
344                    .unwrap()
345            })
346            .collect();
347        assert_eq!(names, ["a-new.txt", "b-new.txt", "z-old.txt"]);
348    }
349
350    #[tokio::test]
351    async fn glob_skips_heavy_default_directories() {
352        let dir = tempfile::tempdir().unwrap();
353        let kept = dir.path().join("src").join("keep.txt");
354        let skipped = dir.path().join("node_modules").join("skip.txt");
355        let worktree = dir
356            .path()
357            .join(".bamboo/worktree/child")
358            .join("duplicate.txt");
359        tokio::fs::create_dir_all(kept.parent().unwrap())
360            .await
361            .unwrap();
362        tokio::fs::create_dir_all(skipped.parent().unwrap())
363            .await
364            .unwrap();
365        tokio::fs::create_dir_all(worktree.parent().unwrap())
366            .await
367            .unwrap();
368        tokio::fs::write(&kept, "ok").await.unwrap();
369        tokio::fs::write(&skipped, "skip").await.unwrap();
370        tokio::fs::write(&worktree, "duplicate").await.unwrap();
371
372        let tool = GlobTool::new();
373        let out = tool
374            .invoke(
375                json!({
376                    "pattern": "**/*.txt",
377                    "path": dir.path(),
378                    "limit": 50
379                }),
380                ToolCtx::none("t"),
381            )
382            .await
383            .unwrap();
384        let ToolOutcome::Completed(result) = out else {
385            panic!("expected Completed")
386        };
387
388        assert!(result.result.contains("keep.txt"));
389        assert!(!result.result.contains("node_modules"));
390        assert!(!result.result.contains("duplicate.txt"));
391    }
392}