1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
3use globset::{GlobBuilder, GlobSetBuilder};
4use serde::Deserialize;
5use std::path::{Path, PathBuf};
6
7use super::{parameter_schema, search_traversal, workspace_state};
8
9const DEFAULT_GLOB_MATCHES: usize = 100;
10const MAX_GLOB_MATCHES: usize = 200;
11const MAX_GLOB_SCANNED_FILES: usize = 50_000;
12const SEARCH_SCOPE_TOO_BROAD_ERROR: &str =
13 "Search scope too broad. Add path/glob/type or reduce pattern.";
14
15#[derive(Debug, Deserialize, schemars::JsonSchema)]
16#[schemars(deny_unknown_fields)]
17struct GlobArgs {
18 pattern: String,
20 #[serde(default)]
22 #[schemars(with = "String", skip_serializing_if = "Option::is_none")]
23 path: Option<String>,
24 #[serde(default)]
26 #[schemars(
27 schema_with = "parameter_schema::number",
28 skip_serializing_if = "Option::is_none"
29 )]
30 limit: Option<usize>,
31 #[serde(default)]
33 include_ignored: bool,
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
52impl Default for GlobTool {
53 fn default() -> Self {
54 Self::new()
55 }
56}
57
58#[async_trait]
59impl Tool for GlobTool {
60 fn name(&self) -> &str {
61 "Glob"
62 }
63
64 fn description(&self) -> &str {
65 "Fast file pattern matching tool. Directory searches respect repository .gitignore rules, including parent rules up to the Git root; non-repository searches do not apply ignore rules. Hidden files remain visible; global Git ignores, .ignore and .git/info/exclude are not applied. Use it to find candidate files before deeper Read or Grep steps. Avoid unbounded root patterns without narrowing path or pattern."
66 }
67
68 fn classify(&self, _args: &serde_json::Value) -> ToolClass {
69 ToolClass::READONLY_PARALLEL
70 }
71
72 fn parameters_schema(&self) -> serde_json::Value {
73 parameter_schema::for_arguments::<GlobArgs>()
74 }
75
76 async fn invoke(
77 &self,
78 args: serde_json::Value,
79 ctx: ToolCtx,
80 ) -> Result<ToolOutcome, ToolError> {
81 let parsed: GlobArgs = serde_json::from_value(args)
82 .map_err(|e| ToolError::InvalidArguments(format!("Invalid Glob args: {}", e)))?;
83
84 if parsed.include_ignored && parsed.path.is_none() {
85 return Err(ToolError::InvalidArguments(
86 "include_ignored requires an explicit path.".to_string(),
87 ));
88 }
89
90 if parsed.path.is_none() && Self::is_unbounded_pattern(&parsed.pattern) {
91 return Err(ToolError::InvalidArguments(
92 SEARCH_SCOPE_TOO_BROAD_ERROR.to_string(),
93 ));
94 }
95
96 let default_root = workspace_state::workspace_or_process_cwd(ctx.session_id());
97 let root = parsed
98 .path
99 .as_ref()
100 .map(|value| {
101 let path = PathBuf::from(value);
102 if path.is_absolute() {
103 path
104 } else {
105 default_root.join(path)
106 }
107 })
108 .unwrap_or(default_root);
109
110 if !root.exists() || !root.is_dir() {
111 return Err(ToolError::Execution(format!(
112 "Search path is not a directory: {}",
113 root.display()
114 )));
115 }
116
117 let limit = parsed
118 .limit
119 .unwrap_or(DEFAULT_GLOB_MATCHES)
120 .clamp(1, MAX_GLOB_MATCHES);
121
122 let mut glob_builder = GlobSetBuilder::new();
123 let glob = GlobBuilder::new(parsed.pattern.trim())
124 .literal_separator(false)
125 .build()
126 .map_err(|e| ToolError::InvalidArguments(format!("Invalid glob pattern: {}", e)))?;
127 glob_builder.add(glob);
128 let glob_set = glob_builder
129 .build()
130 .map_err(|e| ToolError::Execution(format!("Failed to compile glob: {}", e)))?;
131
132 let mut matches: Vec<(String, u64)> = Vec::new();
133 let mut total_matches = 0usize;
134 let mut scanned_files = 0usize;
135 let mut scan_truncated = false;
136
137 for entry in
138 search_traversal::walk(&root, parsed.include_ignored).filter_map(|entry| entry.ok())
139 {
140 if !entry.file_type().is_some_and(|kind| kind.is_file()) {
141 continue;
142 }
143
144 scanned_files += 1;
145 if scanned_files > MAX_GLOB_SCANNED_FILES {
146 scan_truncated = true;
147 break;
148 }
149
150 let path = entry.path();
151 let relative = path.strip_prefix(&root).unwrap_or(path);
152 if !glob_set.is_match(relative) && !glob_set.is_match(path) {
153 continue;
154 }
155
156 total_matches += 1;
157 let modified = entry
158 .metadata()
159 .ok()
160 .and_then(|m| m.modified().ok())
161 .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
162 .map(|duration| duration.as_secs())
163 .unwrap_or(0);
164 matches.push((
165 bamboo_config::paths::path_to_display_string(Path::new(path)),
166 modified,
167 ));
168 }
169
170 matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
171
172 let mut result_lines: Vec<String> = matches
173 .into_iter()
174 .take(limit)
175 .map(|(path, _)| path)
176 .collect();
177
178 if total_matches > limit {
179 result_lines.push(format!(
180 "[TRUNCATED] Showing first {limit} matches (matched {total_matches}). Refine pattern/path and retry."
181 ));
182 }
183
184 if scan_truncated {
185 result_lines.push(format!(
186 "[PARTIAL] Stopped after scanning {} files. Narrow path/pattern to improve results.",
187 MAX_GLOB_SCANNED_FILES
188 ));
189 }
190
191 Ok(ToolOutcome::Completed(ToolResult {
192 success: true,
193 result: result_lines.join("\n"),
194 display_preference: Some("Collapsible".to_string()),
195 images: Vec::new(),
196 }))
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::{GlobArgs, GlobTool};
203 use bamboo_agent_core::{Tool, ToolCtx, ToolOutcome};
204 use serde_json::json;
205
206 #[test]
207 fn glob_args_preserve_optional_defaults_and_unknown_fields() {
208 for args in [
209 json!({"pattern": "**/*.rs"}),
210 json!({
211 "pattern": "**/*.rs",
212 "path": null,
213 "limit": null,
214 "unknown": true
215 }),
216 ] {
217 let parsed: GlobArgs = serde_json::from_value(args).unwrap();
218 assert_eq!(parsed.pattern, "**/*.rs");
219 assert_eq!(parsed.path, None);
220 assert_eq!(parsed.limit, None);
221 assert!(!parsed.include_ignored);
222 }
223 }
224
225 #[test]
226 fn glob_args_preserve_invalid_argument_rejection() {
227 for invalid in [json!(-1), json!(1.5), json!("1"), json!(true)] {
228 assert!(serde_json::from_value::<GlobArgs>(json!({
229 "pattern": "**/*.rs",
230 "limit": invalid
231 }))
232 .is_err());
233 }
234 for invalid in [json!(null), json!(0), json!("false")] {
235 assert!(serde_json::from_value::<GlobArgs>(json!({
236 "pattern": "**/*.rs",
237 "include_ignored": invalid
238 }))
239 .is_err());
240 }
241 for args in [
242 json!({}),
243 json!({"pattern": null}),
244 json!({"pattern": "**/*.rs", "path": 1}),
245 ] {
246 assert!(serde_json::from_value::<GlobArgs>(args).is_err());
247 }
248 }
249
250 fn result_lines(result: &bamboo_agent_core::ToolResult) -> Vec<&str> {
251 result
252 .result
253 .lines()
254 .filter(|line| !line.is_empty())
255 .collect()
256 }
257
258 #[tokio::test]
259 async fn glob_rejects_unbounded_default_root_pattern() {
260 let tool = GlobTool::new();
261 let error = tool
262 .invoke(
263 json!({
264 "pattern": "**/*"
265 }),
266 ToolCtx::none("t"),
267 )
268 .await
269 .expect_err("unbounded root glob should fail");
270 assert!(error
271 .to_string()
272 .contains(super::SEARCH_SCOPE_TOO_BROAD_ERROR));
273 }
274
275 #[tokio::test]
276 async fn glob_truncates_to_max_matches_with_notice() {
277 let dir = tempfile::tempdir().unwrap();
278 std::fs::create_dir(dir.path().join(".git")).unwrap();
279 tokio::fs::write(dir.path().join(".gitignore"), "*.txt\n")
280 .await
281 .unwrap();
282 for idx in 0..520 {
283 let file = dir.path().join(format!("f-{idx}.txt"));
284 tokio::fs::write(file, "x").await.unwrap();
285 }
286
287 let tool = GlobTool::new();
288 let out = tool
289 .invoke(
290 json!({
291 "pattern": "**/*.txt",
292 "path": dir.path(),
293 "limit": 120,
294 "include_ignored": true
295 }),
296 ToolCtx::none("t"),
297 )
298 .await
299 .unwrap();
300 let ToolOutcome::Completed(result) = out else {
301 panic!("expected Completed")
302 };
303
304 let lines = result_lines(&result);
305 assert_eq!(lines.len(), 121);
306 assert!(lines
307 .last()
308 .copied()
309 .unwrap_or_default()
310 .contains("[TRUNCATED]"));
311 }
312
313 #[tokio::test]
314 async fn glob_keeps_modification_time_order_and_name_tie_breaking() {
315 let dir = tempfile::tempdir().unwrap();
316 let older = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
317 let newer = older + std::time::Duration::from_secs(20);
318 for (name, time) in [
319 ("z-old.txt", older),
320 ("b-new.txt", newer),
321 ("a-new.txt", newer),
322 ] {
323 let file = std::fs::File::create(dir.path().join(name)).unwrap();
324 file.set_modified(time).unwrap();
325 }
326
327 let ToolOutcome::Completed(result) = GlobTool::new()
328 .invoke(
329 json!({"pattern": "*.txt", "path": dir.path()}),
330 ToolCtx::none("t"),
331 )
332 .await
333 .unwrap()
334 else {
335 panic!("expected Completed")
336 };
337 let names: Vec<_> = result_lines(&result)
338 .into_iter()
339 .map(|path| {
340 std::path::Path::new(path)
341 .file_name()
342 .unwrap()
343 .to_str()
344 .unwrap()
345 })
346 .collect();
347 assert_eq!(names, ["a-new.txt", "b-new.txt", "z-old.txt"]);
348 }
349
350 #[tokio::test]
351 async fn glob_skips_heavy_default_directories() {
352 let dir = tempfile::tempdir().unwrap();
353 let kept = dir.path().join("src").join("keep.txt");
354 let skipped = dir.path().join("node_modules").join("skip.txt");
355 let worktree = dir
356 .path()
357 .join(".bamboo/worktree/child")
358 .join("duplicate.txt");
359 tokio::fs::create_dir_all(kept.parent().unwrap())
360 .await
361 .unwrap();
362 tokio::fs::create_dir_all(skipped.parent().unwrap())
363 .await
364 .unwrap();
365 tokio::fs::create_dir_all(worktree.parent().unwrap())
366 .await
367 .unwrap();
368 tokio::fs::write(&kept, "ok").await.unwrap();
369 tokio::fs::write(&skipped, "skip").await.unwrap();
370 tokio::fs::write(&worktree, "duplicate").await.unwrap();
371
372 let tool = GlobTool::new();
373 let out = tool
374 .invoke(
375 json!({
376 "pattern": "**/*.txt",
377 "path": dir.path(),
378 "limit": 50
379 }),
380 ToolCtx::none("t"),
381 )
382 .await
383 .unwrap();
384 let ToolOutcome::Completed(result) = out else {
385 panic!("expected Completed")
386 };
387
388 assert!(result.result.contains("keep.txt"));
389 assert!(!result.result.contains("node_modules"));
390 assert!(!result.result.contains("duplicate.txt"));
391 }
392}