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