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