bamboo-tools 2026.10.22

Tool execution and integrations for the Bamboo agent framework
Documentation
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 {
    /// The glob pattern to match files against (for example **/*.rs or src/**/*.ts)
    pattern: String,
    /// The directory to search in. Omit to use the current workspace root.
    #[serde(default)]
    #[schemars(with = "String", skip_serializing_if = "Option::is_none")]
    path: Option<String>,
    /// Maximum number of returned matches (default 100, hard cap 200). Use a smaller limit for broad searches.
    #[serde(default)]
    #[schemars(
        schema_with = "parameter_schema::number",
        skip_serializing_if = "Option::is_none"
    )]
    limit: Option<usize>,
    /// Include gitignored files. Requires an explicit path; scan/result limits and fixed directory exclusions still apply.
    #[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"));
    }
}