capo-agent 0.5.0

Coding-agent library built on motosan-agent-loop. Composable, embeddable.
Documentation
#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]

use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;

use globset::Glob;
use ignore::WalkBuilder;
use motosan_agent_tool::{Tool, ToolContext, ToolDef, ToolResult};
use serde_json::{json, Value};

use crate::tools::ls::resolve_in_cwd;
use crate::tools::ToolCtx;

const MAX_RESULTS: usize = 1000;

pub struct FindTool {
    ctx: Arc<ToolCtx>,
}

impl FindTool {
    pub fn new(ctx: Arc<ToolCtx>) -> Self {
        Self { ctx }
    }
}

impl Tool for FindTool {
    fn def(&self) -> ToolDef {
        ToolDef {
            name: "find".to_string(),
            description: "List files or directories under a path, optionally filtered by a glob pattern. Respects .gitignore.".to_string(),
            input_schema: json!({
                "type": "object",
                "properties": {
                    "pattern": { "type": "string", "description": "Glob matched against the file name, e.g. `*.rs`. Defaults to all." },
                    "path": { "type": "string", "description": "Root directory (absolute or cwd-relative). Defaults to cwd." },
                    "type": { "type": "string", "enum": ["file", "dir", "any"], "description": "Entry kind filter. Defaults to `any`." }
                }
            }),
        }
    }

    fn call(
        &self,
        args: Value,
        _ctx: &ToolContext,
    ) -> Pin<Box<dyn Future<Output = ToolResult> + Send + '_>> {
        let ctx = Arc::clone(&self.ctx);
        Box::pin(async move {
            let rel = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
            let root = resolve_in_cwd(&ctx.cwd, rel);
            if !root.starts_with(&ctx.cwd) {
                return ToolResult::error(format!(
                    "path {} is outside the working directory",
                    root.display()
                ));
            }
            let kind = args
                .get("type")
                .and_then(|v| v.as_str())
                .unwrap_or("any")
                .to_string();
            let matcher = match args.get("pattern").and_then(|v| v.as_str()) {
                Some(p) => match Glob::new(p) {
                    Ok(g) => Some(g.compile_matcher()),
                    Err(e) => return ToolResult::error(format!("invalid glob `{p}`: {e}")),
                },
                None => None,
            };

            let cwd = ctx.cwd.clone();
            let listing = tokio::task::spawn_blocking(move || {
                let mut out: Vec<String> = Vec::new();
                for entry in WalkBuilder::new(&root).build().flatten() {
                    if out.len() >= MAX_RESULTS {
                        out.push(format!("... (truncated at {MAX_RESULTS} entries)"));
                        break;
                    }
                    let path = entry.path();
                    if path == root {
                        continue;
                    }
                    let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
                    match kind.as_str() {
                        "file" if is_dir => continue,
                        "dir" if !is_dir => continue,
                        _ => {}
                    }
                    if let Some(m) = &matcher {
                        let name = path.file_name().unwrap_or_default();
                        if !m.is_match(name) {
                            continue;
                        }
                    }
                    let display = path.strip_prefix(&cwd).unwrap_or(path);
                    out.push(display.display().to_string());
                }
                out
            })
            .await;

            match listing {
                Ok(mut lines) => {
                    lines.sort();
                    ToolResult::text(lines.join("\n"))
                }
                Err(e) => ToolResult::error(format!("find walk failed: {e}")),
            }
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::permissions::NoOpPermissionGate;
    use tempfile::tempdir;
    use tokio::sync::mpsc;

    fn test_ctx(cwd: &std::path::Path) -> Arc<ToolCtx> {
        let (tx, _rx) = mpsc::channel(8);
        Arc::new(ToolCtx::new(cwd, Arc::new(NoOpPermissionGate), tx))
    }

    #[tokio::test]
    async fn finds_files_by_glob() {
        let dir = tempdir().expect("tempdir");
        tokio::fs::write(dir.path().join("a.rs"), "x")
            .await
            .unwrap();
        tokio::fs::write(dir.path().join("b.rs"), "x")
            .await
            .unwrap();
        tokio::fs::write(dir.path().join("c.txt"), "x")
            .await
            .unwrap();

        let tool = FindTool::new(test_ctx(dir.path()));
        let result = tool
            .call(json!({ "pattern": "*.rs" }), &ToolContext::default())
            .await;
        let text = result.as_text().unwrap_or_default();
        assert!(text.contains("a.rs"));
        assert!(text.contains("b.rs"));
        assert!(!text.contains("c.txt"));
    }

    #[tokio::test]
    async fn type_dir_lists_only_directories() {
        let dir = tempdir().expect("tempdir");
        tokio::fs::write(dir.path().join("file.txt"), "x")
            .await
            .unwrap();
        tokio::fs::create_dir(dir.path().join("subdir"))
            .await
            .unwrap();

        let tool = FindTool::new(test_ctx(dir.path()));
        let result = tool
            .call(json!({ "type": "dir" }), &ToolContext::default())
            .await;
        let text = result.as_text().unwrap_or_default();
        assert!(text.contains("subdir"));
        assert!(!text.contains("file.txt"));
    }

    #[tokio::test]
    async fn respects_gitignore() {
        let dir = tempdir().expect("tempdir");
        tokio::fs::create_dir(dir.path().join(".git"))
            .await
            .unwrap();
        tokio::fs::write(dir.path().join(".gitignore"), "ignored.txt\n")
            .await
            .unwrap();
        tokio::fs::write(dir.path().join("ignored.txt"), "x")
            .await
            .unwrap();
        tokio::fs::write(dir.path().join("kept.txt"), "x")
            .await
            .unwrap();

        let tool = FindTool::new(test_ctx(dir.path()));
        let result = tool.call(json!({}), &ToolContext::default()).await;
        let text = result.as_text().unwrap_or_default();
        assert!(text.contains("kept.txt"));
        assert!(
            !text.contains("ignored.txt"),
            "gitignored file leaked: {text}"
        );
    }

    #[tokio::test]
    async fn rejects_path_outside_cwd() {
        let dir = tempdir().expect("tempdir");
        let tool = FindTool::new(test_ctx(dir.path()));
        let result = tool
            .call(json!({ "path": "../.." }), &ToolContext::default())
            .await;
        assert!(result.is_error);
    }

    #[tokio::test]
    async fn invalid_glob_errors() {
        let dir = tempdir().expect("tempdir");
        let tool = FindTool::new(test_ctx(dir.path()));
        let result = tool
            .call(json!({ "pattern": "[" }), &ToolContext::default())
            .await;
        assert!(result.is_error);
    }
}