Skip to main content

bamboo_tools/tools/
glob.rs

1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolError, ToolExecutionContext, 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 mutability(&self) -> crate::ToolMutability {
76        crate::ToolMutability::ReadOnly
77    }
78
79    fn concurrency_safe(&self) -> bool {
80        true
81    }
82
83    fn parameters_schema(&self) -> serde_json::Value {
84        json!({
85            "type": "object",
86            "properties": {
87                "pattern": {
88                    "type": "string",
89                    "description": "The glob pattern to match files against (for example **/*.rs or src/**/*.ts)"
90                },
91                "path": {
92                    "type": "string",
93                    "description": "The directory to search in. Omit to use the current workspace root."
94                },
95                "limit": {
96                    "type": "number",
97                    "description": "Maximum number of returned matches (default 100, hard cap 200). Use a smaller limit for broad searches."
98                }
99            },
100            "required": ["pattern"],
101            "additionalProperties": false
102        })
103    }
104
105    async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
106        self.execute_with_context(args, ToolExecutionContext::none("Glob"))
107            .await
108    }
109
110    async fn execute_with_context(
111        &self,
112        args: serde_json::Value,
113        ctx: ToolExecutionContext<'_>,
114    ) -> Result<ToolResult, 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_infrastructure::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(ToolResult {
225            success: true,
226            result: result_lines.join("\n"),
227            display_preference: Some("Collapsible".to_string()),
228        })
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::GlobTool;
235    use bamboo_agent_core::Tool;
236    use serde_json::json;
237
238    fn result_lines(result: &bamboo_agent_core::ToolResult) -> Vec<&str> {
239        result
240            .result
241            .lines()
242            .filter(|line| !line.is_empty())
243            .collect()
244    }
245
246    #[tokio::test]
247    async fn glob_rejects_unbounded_default_root_pattern() {
248        let tool = GlobTool::new();
249        let error = tool
250            .execute(json!({
251                "pattern": "**/*"
252            }))
253            .await
254            .expect_err("unbounded root glob should fail");
255        assert!(error
256            .to_string()
257            .contains(super::SEARCH_SCOPE_TOO_BROAD_ERROR));
258    }
259
260    #[tokio::test]
261    async fn glob_truncates_to_max_matches_with_notice() {
262        let dir = tempfile::tempdir().unwrap();
263        for idx in 0..520 {
264            let file = dir.path().join(format!("f-{idx}.txt"));
265            tokio::fs::write(file, "x").await.unwrap();
266        }
267
268        let tool = GlobTool::new();
269        let result = tool
270            .execute(json!({
271                "pattern": "**/*.txt",
272                "path": dir.path(),
273                "limit": 120
274            }))
275            .await
276            .unwrap();
277
278        let lines = result_lines(&result);
279        assert_eq!(lines.len(), 121);
280        assert!(lines
281            .last()
282            .copied()
283            .unwrap_or_default()
284            .contains("[TRUNCATED]"));
285    }
286
287    #[tokio::test]
288    async fn glob_skips_heavy_default_directories() {
289        let dir = tempfile::tempdir().unwrap();
290        let kept = dir.path().join("src").join("keep.txt");
291        let skipped = dir.path().join("node_modules").join("skip.txt");
292        tokio::fs::create_dir_all(kept.parent().unwrap())
293            .await
294            .unwrap();
295        tokio::fs::create_dir_all(skipped.parent().unwrap())
296            .await
297            .unwrap();
298        tokio::fs::write(&kept, "ok").await.unwrap();
299        tokio::fs::write(&skipped, "skip").await.unwrap();
300
301        let tool = GlobTool::new();
302        let result = tool
303            .execute(json!({
304                "pattern": "**/*.txt",
305                "path": dir.path(),
306                "limit": 50
307            }))
308            .await
309            .unwrap();
310
311        assert!(result.result.contains("keep.txt"));
312        assert!(!result.result.contains("node_modules"));
313    }
314}