bamboo_tools/tools/
glob.rs1use 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}