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 serde_json::json;
6use std::path::{Path, PathBuf};
7use walkdir::WalkDir;
8
9use super::workspace_state;
10
11const DEFAULT_GLOB_MATCHES: usize = 100;
12const MAX_GLOB_MATCHES: usize = 200;
13const MAX_GLOB_SCANNED_FILES: usize = 50_000;
14const SKIP_DIRS: [&str; 8] = [
15    ".git",
16    "node_modules",
17    "target",
18    "dist",
19    "build",
20    ".next",
21    ".cache",
22    "coverage",
23];
24const SEARCH_SCOPE_TOO_BROAD_ERROR: &str =
25    "Search scope too broad. Add path/glob/type or reduce pattern.";
26
27#[derive(Debug, Deserialize)]
28struct GlobArgs {
29    pattern: String,
30    #[serde(default)]
31    path: Option<String>,
32    #[serde(default)]
33    limit: Option<usize>,
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    fn should_skip_dir(path: &Path) -> bool {
52        path.file_name()
53            .and_then(|name| name.to_str())
54            .map(|name| SKIP_DIRS.contains(&name))
55            .unwrap_or(false)
56    }
57}
58
59impl Default for GlobTool {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65#[async_trait]
66impl Tool for GlobTool {
67    fn name(&self) -> &str {
68        "Glob"
69    }
70
71    fn description(&self) -> &str {
72        "Fast file pattern matching tool. Use it to find candidate files before deeper Read or Grep steps. Avoid unbounded root patterns without narrowing path or pattern."
73    }
74
75    fn classify(&self, _args: &serde_json::Value) -> ToolClass {
76        ToolClass::READONLY_PARALLEL
77    }
78
79    fn parameters_schema(&self) -> serde_json::Value {
80        json!({
81            "type": "object",
82            "properties": {
83                "pattern": {
84                    "type": "string",
85                    "description": "The glob pattern to match files against (for example **/*.rs or src/**/*.ts)"
86                },
87                "path": {
88                    "type": "string",
89                    "description": "The directory to search in. Omit to use the current workspace root."
90                },
91                "limit": {
92                    "type": "number",
93                    "description": "Maximum number of returned matches (default 100, hard cap 200). Use a smaller limit for broad searches."
94                }
95            },
96            "required": ["pattern"],
97            "additionalProperties": false
98        })
99    }
100
101    async fn invoke(
102        &self,
103        args: serde_json::Value,
104        ctx: ToolCtx,
105    ) -> Result<ToolOutcome, ToolError> {
106        let parsed: GlobArgs = serde_json::from_value(args)
107            .map_err(|e| ToolError::InvalidArguments(format!("Invalid Glob args: {}", e)))?;
108
109        if parsed.path.is_none() && Self::is_unbounded_pattern(&parsed.pattern) {
110            return Err(ToolError::InvalidArguments(
111                SEARCH_SCOPE_TOO_BROAD_ERROR.to_string(),
112            ));
113        }
114
115        let default_root = workspace_state::workspace_or_process_cwd(ctx.session_id());
116        let root = parsed
117            .path
118            .as_ref()
119            .map(|value| {
120                let path = PathBuf::from(value);
121                if path.is_absolute() {
122                    path
123                } else {
124                    default_root.join(path)
125                }
126            })
127            .unwrap_or(default_root);
128
129        if !root.exists() || !root.is_dir() {
130            return Err(ToolError::Execution(format!(
131                "Search path is not a directory: {}",
132                root.display()
133            )));
134        }
135
136        let limit = parsed
137            .limit
138            .unwrap_or(DEFAULT_GLOB_MATCHES)
139            .clamp(1, MAX_GLOB_MATCHES);
140
141        let mut glob_builder = GlobSetBuilder::new();
142        let glob = GlobBuilder::new(parsed.pattern.trim())
143            .literal_separator(false)
144            .build()
145            .map_err(|e| ToolError::InvalidArguments(format!("Invalid glob pattern: {}", e)))?;
146        glob_builder.add(glob);
147        let glob_set = glob_builder
148            .build()
149            .map_err(|e| ToolError::Execution(format!("Failed to compile glob: {}", e)))?;
150
151        let mut matches: Vec<(String, u64)> = Vec::new();
152        let mut total_matches = 0usize;
153        let mut scanned_files = 0usize;
154        let mut scan_truncated = false;
155
156        for entry in WalkDir::new(&root)
157            .follow_links(false)
158            .into_iter()
159            .filter_entry(|entry| {
160                !entry.file_type().is_dir() || !Self::should_skip_dir(entry.path())
161            })
162            .filter_map(|entry| entry.ok())
163        {
164            if !entry.file_type().is_file() {
165                continue;
166            }
167
168            scanned_files += 1;
169            if scanned_files > MAX_GLOB_SCANNED_FILES {
170                scan_truncated = true;
171                break;
172            }
173
174            let path = entry.path();
175            let relative = path.strip_prefix(&root).unwrap_or(path);
176            if !glob_set.is_match(relative) && !glob_set.is_match(path) {
177                continue;
178            }
179
180            total_matches += 1;
181            let modified = entry
182                .metadata()
183                .ok()
184                .and_then(|m| m.modified().ok())
185                .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
186                .map(|duration| duration.as_secs())
187                .unwrap_or(0);
188            matches.push((
189                bamboo_config::paths::path_to_display_string(Path::new(path)),
190                modified,
191            ));
192        }
193
194        matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
195
196        let mut result_lines: Vec<String> = matches
197            .into_iter()
198            .take(limit)
199            .map(|(path, _)| path)
200            .collect();
201
202        if total_matches > limit {
203            result_lines.push(format!(
204                "[TRUNCATED] Showing first {limit} matches (matched {total_matches}). Refine pattern/path and retry."
205            ));
206        }
207
208        if scan_truncated {
209            result_lines.push(format!(
210                "[PARTIAL] Stopped after scanning {} files. Narrow path/pattern to improve results.",
211                MAX_GLOB_SCANNED_FILES
212            ));
213        }
214
215        Ok(ToolOutcome::Completed(ToolResult {
216            success: true,
217            result: result_lines.join("\n"),
218            display_preference: Some("Collapsible".to_string()),
219            images: Vec::new(),
220        }))
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::GlobTool;
227    use bamboo_agent_core::{Tool, ToolCtx, ToolOutcome};
228    use serde_json::json;
229
230    fn result_lines(result: &bamboo_agent_core::ToolResult) -> Vec<&str> {
231        result
232            .result
233            .lines()
234            .filter(|line| !line.is_empty())
235            .collect()
236    }
237
238    #[tokio::test]
239    async fn glob_rejects_unbounded_default_root_pattern() {
240        let tool = GlobTool::new();
241        let error = tool
242            .invoke(
243                json!({
244                    "pattern": "**/*"
245                }),
246                ToolCtx::none("t"),
247            )
248            .await
249            .expect_err("unbounded root glob should fail");
250        assert!(error
251            .to_string()
252            .contains(super::SEARCH_SCOPE_TOO_BROAD_ERROR));
253    }
254
255    #[tokio::test]
256    async fn glob_truncates_to_max_matches_with_notice() {
257        let dir = tempfile::tempdir().unwrap();
258        for idx in 0..520 {
259            let file = dir.path().join(format!("f-{idx}.txt"));
260            tokio::fs::write(file, "x").await.unwrap();
261        }
262
263        let tool = GlobTool::new();
264        let out = tool
265            .invoke(
266                json!({
267                    "pattern": "**/*.txt",
268                    "path": dir.path(),
269                    "limit": 120
270                }),
271                ToolCtx::none("t"),
272            )
273            .await
274            .unwrap();
275        let ToolOutcome::Completed(result) = out else {
276            panic!("expected Completed")
277        };
278
279        let lines = result_lines(&result);
280        assert_eq!(lines.len(), 121);
281        assert!(lines
282            .last()
283            .copied()
284            .unwrap_or_default()
285            .contains("[TRUNCATED]"));
286    }
287
288    #[tokio::test]
289    async fn glob_skips_heavy_default_directories() {
290        let dir = tempfile::tempdir().unwrap();
291        let kept = dir.path().join("src").join("keep.txt");
292        let skipped = dir.path().join("node_modules").join("skip.txt");
293        tokio::fs::create_dir_all(kept.parent().unwrap())
294            .await
295            .unwrap();
296        tokio::fs::create_dir_all(skipped.parent().unwrap())
297            .await
298            .unwrap();
299        tokio::fs::write(&kept, "ok").await.unwrap();
300        tokio::fs::write(&skipped, "skip").await.unwrap();
301
302        let tool = GlobTool::new();
303        let out = tool
304            .invoke(
305                json!({
306                    "pattern": "**/*.txt",
307                    "path": dir.path(),
308                    "limit": 50
309                }),
310                ToolCtx::none("t"),
311            )
312            .await
313            .unwrap();
314        let ToolOutcome::Completed(result) = out else {
315            panic!("expected Completed")
316        };
317
318        assert!(result.result.contains("keep.txt"));
319        assert!(!result.result.contains("node_modules"));
320    }
321}