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 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}