use async_trait::async_trait;
use bamboo_agent_core::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
use globset::{GlobBuilder, GlobSetBuilder};
use serde::Deserialize;
use std::path::{Path, PathBuf};
use super::{parameter_schema, search_traversal, workspace_state};
const DEFAULT_GLOB_MATCHES: usize = 100;
const MAX_GLOB_MATCHES: usize = 200;
const MAX_GLOB_SCANNED_FILES: usize = 50_000;
const SEARCH_SCOPE_TOO_BROAD_ERROR: &str =
"Search scope too broad. Add path/glob/type or reduce pattern.";
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[schemars(deny_unknown_fields)]
struct GlobArgs {
pattern: String,
#[serde(default)]
#[schemars(with = "String", skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(default)]
#[schemars(
schema_with = "parameter_schema::number",
skip_serializing_if = "Option::is_none"
)]
limit: Option<usize>,
#[serde(default)]
include_ignored: bool,
}
pub struct GlobTool;
impl GlobTool {
pub fn new() -> Self {
Self
}
fn is_unbounded_pattern(pattern: &str) -> bool {
let normalized = pattern.trim().replace('\\', "/");
matches!(
normalized.as_str(),
"*" | "**" | "**/*" | "**/**" | "./**/*" | ".//**/*"
)
}
}
impl Default for GlobTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for GlobTool {
fn name(&self) -> &str {
"Glob"
}
fn description(&self) -> &str {
"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."
}
fn classify(&self, _args: &serde_json::Value) -> ToolClass {
ToolClass::READONLY_PARALLEL
}
fn parameters_schema(&self) -> serde_json::Value {
parameter_schema::for_arguments::<GlobArgs>()
}
async fn invoke(
&self,
args: serde_json::Value,
ctx: ToolCtx,
) -> Result<ToolOutcome, ToolError> {
let parsed: GlobArgs = serde_json::from_value(args)
.map_err(|e| ToolError::InvalidArguments(format!("Invalid Glob args: {}", e)))?;
if parsed.include_ignored && parsed.path.is_none() {
return Err(ToolError::InvalidArguments(
"include_ignored requires an explicit path.".to_string(),
));
}
if parsed.path.is_none() && Self::is_unbounded_pattern(&parsed.pattern) {
return Err(ToolError::InvalidArguments(
SEARCH_SCOPE_TOO_BROAD_ERROR.to_string(),
));
}
let default_root = workspace_state::workspace_or_process_cwd(ctx.session_id());
let root = parsed
.path
.as_ref()
.map(|value| {
let path = PathBuf::from(value);
if path.is_absolute() {
path
} else {
default_root.join(path)
}
})
.unwrap_or(default_root);
if !root.exists() || !root.is_dir() {
return Err(ToolError::Execution(format!(
"Search path is not a directory: {}",
root.display()
)));
}
let limit = parsed
.limit
.unwrap_or(DEFAULT_GLOB_MATCHES)
.clamp(1, MAX_GLOB_MATCHES);
let mut glob_builder = GlobSetBuilder::new();
let glob = GlobBuilder::new(parsed.pattern.trim())
.literal_separator(false)
.build()
.map_err(|e| ToolError::InvalidArguments(format!("Invalid glob pattern: {}", e)))?;
glob_builder.add(glob);
let glob_set = glob_builder
.build()
.map_err(|e| ToolError::Execution(format!("Failed to compile glob: {}", e)))?;
let mut matches: Vec<(String, u64)> = Vec::new();
let mut total_matches = 0usize;
let mut scanned_files = 0usize;
let mut scan_truncated = false;
for entry in
search_traversal::walk(&root, parsed.include_ignored).filter_map(|entry| entry.ok())
{
if !entry.file_type().is_some_and(|kind| kind.is_file()) {
continue;
}
scanned_files += 1;
if scanned_files > MAX_GLOB_SCANNED_FILES {
scan_truncated = true;
break;
}
let path = entry.path();
let relative = path.strip_prefix(&root).unwrap_or(path);
if !glob_set.is_match(relative) && !glob_set.is_match(path) {
continue;
}
total_matches += 1;
let modified = entry
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|duration| duration.as_secs())
.unwrap_or(0);
matches.push((
bamboo_config::paths::path_to_display_string(Path::new(path)),
modified,
));
}
matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let mut result_lines: Vec<String> = matches
.into_iter()
.take(limit)
.map(|(path, _)| path)
.collect();
if total_matches > limit {
result_lines.push(format!(
"[TRUNCATED] Showing first {limit} matches (matched {total_matches}). Refine pattern/path and retry."
));
}
if scan_truncated {
result_lines.push(format!(
"[PARTIAL] Stopped after scanning {} files. Narrow path/pattern to improve results.",
MAX_GLOB_SCANNED_FILES
));
}
Ok(ToolOutcome::Completed(ToolResult {
success: true,
result: result_lines.join("\n"),
display_preference: Some("Collapsible".to_string()),
images: Vec::new(),
}))
}
}
#[cfg(test)]
mod tests {
use super::{GlobArgs, GlobTool};
use bamboo_agent_core::{Tool, ToolCtx, ToolOutcome};
use serde_json::json;
#[test]
fn glob_args_preserve_optional_defaults_and_unknown_fields() {
for args in [
json!({"pattern": "**/*.rs"}),
json!({
"pattern": "**/*.rs",
"path": null,
"limit": null,
"unknown": true
}),
] {
let parsed: GlobArgs = serde_json::from_value(args).unwrap();
assert_eq!(parsed.pattern, "**/*.rs");
assert_eq!(parsed.path, None);
assert_eq!(parsed.limit, None);
assert!(!parsed.include_ignored);
}
}
#[test]
fn glob_args_preserve_invalid_argument_rejection() {
for invalid in [json!(-1), json!(1.5), json!("1"), json!(true)] {
assert!(serde_json::from_value::<GlobArgs>(json!({
"pattern": "**/*.rs",
"limit": invalid
}))
.is_err());
}
for invalid in [json!(null), json!(0), json!("false")] {
assert!(serde_json::from_value::<GlobArgs>(json!({
"pattern": "**/*.rs",
"include_ignored": invalid
}))
.is_err());
}
for args in [
json!({}),
json!({"pattern": null}),
json!({"pattern": "**/*.rs", "path": 1}),
] {
assert!(serde_json::from_value::<GlobArgs>(args).is_err());
}
}
fn result_lines(result: &bamboo_agent_core::ToolResult) -> Vec<&str> {
result
.result
.lines()
.filter(|line| !line.is_empty())
.collect()
}
#[tokio::test]
async fn glob_rejects_unbounded_default_root_pattern() {
let tool = GlobTool::new();
let error = tool
.invoke(
json!({
"pattern": "**/*"
}),
ToolCtx::none("t"),
)
.await
.expect_err("unbounded root glob should fail");
assert!(error
.to_string()
.contains(super::SEARCH_SCOPE_TOO_BROAD_ERROR));
}
#[tokio::test]
async fn glob_truncates_to_max_matches_with_notice() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
tokio::fs::write(dir.path().join(".gitignore"), "*.txt\n")
.await
.unwrap();
for idx in 0..520 {
let file = dir.path().join(format!("f-{idx}.txt"));
tokio::fs::write(file, "x").await.unwrap();
}
let tool = GlobTool::new();
let out = tool
.invoke(
json!({
"pattern": "**/*.txt",
"path": dir.path(),
"limit": 120,
"include_ignored": true
}),
ToolCtx::none("t"),
)
.await
.unwrap();
let ToolOutcome::Completed(result) = out else {
panic!("expected Completed")
};
let lines = result_lines(&result);
assert_eq!(lines.len(), 121);
assert!(lines
.last()
.copied()
.unwrap_or_default()
.contains("[TRUNCATED]"));
}
#[tokio::test]
async fn glob_keeps_modification_time_order_and_name_tie_breaking() {
let dir = tempfile::tempdir().unwrap();
let older = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
let newer = older + std::time::Duration::from_secs(20);
for (name, time) in [
("z-old.txt", older),
("b-new.txt", newer),
("a-new.txt", newer),
] {
let file = std::fs::File::create(dir.path().join(name)).unwrap();
file.set_modified(time).unwrap();
}
let ToolOutcome::Completed(result) = GlobTool::new()
.invoke(
json!({"pattern": "*.txt", "path": dir.path()}),
ToolCtx::none("t"),
)
.await
.unwrap()
else {
panic!("expected Completed")
};
let names: Vec<_> = result_lines(&result)
.into_iter()
.map(|path| {
std::path::Path::new(path)
.file_name()
.unwrap()
.to_str()
.unwrap()
})
.collect();
assert_eq!(names, ["a-new.txt", "b-new.txt", "z-old.txt"]);
}
#[tokio::test]
async fn glob_skips_heavy_default_directories() {
let dir = tempfile::tempdir().unwrap();
let kept = dir.path().join("src").join("keep.txt");
let skipped = dir.path().join("node_modules").join("skip.txt");
let worktree = dir
.path()
.join(".bamboo/worktree/child")
.join("duplicate.txt");
tokio::fs::create_dir_all(kept.parent().unwrap())
.await
.unwrap();
tokio::fs::create_dir_all(skipped.parent().unwrap())
.await
.unwrap();
tokio::fs::create_dir_all(worktree.parent().unwrap())
.await
.unwrap();
tokio::fs::write(&kept, "ok").await.unwrap();
tokio::fs::write(&skipped, "skip").await.unwrap();
tokio::fs::write(&worktree, "duplicate").await.unwrap();
let tool = GlobTool::new();
let out = tool
.invoke(
json!({
"pattern": "**/*.txt",
"path": dir.path(),
"limit": 50
}),
ToolCtx::none("t"),
)
.await
.unwrap();
let ToolOutcome::Completed(result) = out else {
panic!("expected Completed")
};
assert!(result.result.contains("keep.txt"));
assert!(!result.result.contains("node_modules"));
assert!(!result.result.contains("duplicate.txt"));
}
}