capo-agent 0.6.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 regex::RegexBuilder;
use serde_json::{json, Value};

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

const MAX_OUTPUT_BYTES: usize = 30 * 1024;
const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;

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

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

impl Tool for GrepTool {
    fn def(&self) -> ToolDef {
        ToolDef {
            name: "grep".to_string(),
            description: "Search file contents with a regex. Respects .gitignore, skips binary and oversized files. Returns `path:line:text` matches.".to_string(),
            input_schema: json!({
                "type": "object",
                "properties": {
                    "pattern": { "type": "string", "description": "Regular expression to search for." },
                    "path": { "type": "string", "description": "Root directory (absolute or cwd-relative). Defaults to cwd." },
                    "glob": { "type": "string", "description": "Optional filename glob filter, e.g. `*.rs`." },
                    "case_insensitive": { "type": "boolean", "description": "Case-insensitive match. Default false." }
                },
                "required": ["pattern"]
            }),
        }
    }

    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 pattern = match args.get("pattern").and_then(|v| v.as_str()) {
                Some(p) => p.to_string(),
                None => return ToolResult::error("missing 'pattern' argument"),
            };
            let case_insensitive = args
                .get("case_insensitive")
                .and_then(|v| v.as_bool())
                .unwrap_or(false);
            let re = match RegexBuilder::new(&pattern)
                .case_insensitive(case_insensitive)
                .build()
            {
                Ok(r) => r,
                Err(e) => return ToolResult::error(format!("invalid regex `{pattern}`: {e}")),
            };
            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 glob = match args.get("glob").and_then(|v| v.as_str()) {
                Some(g) => match Glob::new(g) {
                    Ok(g) => Some(g.compile_matcher()),
                    Err(e) => return ToolResult::error(format!("invalid glob `{g}`: {e}")),
                },
                None => None,
            };

            let cwd = ctx.cwd.clone();
            let search = tokio::task::spawn_blocking(move || {
                let mut out = String::new();
                let mut truncated = false;
                'walk: for entry in WalkBuilder::new(&root).build().flatten() {
                    if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
                        continue;
                    }
                    let path = entry.path();
                    if let Some(m) = &glob {
                        if !m.is_match(path.file_name().unwrap_or_default()) {
                            continue;
                        }
                    }
                    let meta = match std::fs::metadata(path) {
                        Ok(m) => m,
                        Err(_) => continue,
                    };
                    if meta.len() > MAX_FILE_BYTES {
                        continue;
                    }
                    let bytes = match std::fs::read(path) {
                        Ok(b) => b,
                        Err(_) => continue,
                    };
                    let text = match String::from_utf8(bytes) {
                        Ok(t) => t,
                        Err(_) => continue, // binary — skip
                    };
                    let rel = path.strip_prefix(&cwd).unwrap_or(path);
                    for (lineno, line) in text.lines().enumerate() {
                        if re.is_match(line) {
                            let entry = format!("{}:{}:{}\n", rel.display(), lineno + 1, line);
                            if out.len() + entry.len() > MAX_OUTPUT_BYTES {
                                truncated = true;
                                break 'walk;
                            }
                            out.push_str(&entry);
                        }
                    }
                }
                if truncated {
                    out.push_str("... (output truncated at 30 KB)\n");
                }
                out
            })
            .await;

            match search {
                Ok(out) if out.is_empty() => ToolResult::text("(no matches)"),
                Ok(out) => ToolResult::text(out),
                Err(e) => ToolResult::error(format!("grep 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_matching_lines() {
        let dir = tempdir().expect("tempdir");
        tokio::fs::write(dir.path().join("a.txt"), "alpha\nbeta\ngamma\n")
            .await
            .unwrap();
        let tool = GrepTool::new(test_ctx(dir.path()));
        let result = tool
            .call(json!({ "pattern": "be.a" }), &ToolContext::default())
            .await;
        let text = result.as_text().unwrap_or_default();
        assert!(text.contains("a.txt:2:beta"), "got: {text}");
        assert!(!text.contains("alpha"));
    }

    #[tokio::test]
    async fn no_matches_reports_cleanly() {
        let dir = tempdir().expect("tempdir");
        tokio::fs::write(dir.path().join("a.txt"), "alpha\n")
            .await
            .unwrap();
        let tool = GrepTool::new(test_ctx(dir.path()));
        let result = tool
            .call(json!({ "pattern": "zzz" }), &ToolContext::default())
            .await;
        assert_eq!(result.as_text().unwrap_or_default(), "(no matches)");
    }

    #[tokio::test]
    async fn case_insensitive_flag_works() {
        let dir = tempdir().expect("tempdir");
        tokio::fs::write(dir.path().join("a.txt"), "Hello\n")
            .await
            .unwrap();
        let tool = GrepTool::new(test_ctx(dir.path()));
        let result = tool
            .call(
                json!({ "pattern": "hello", "case_insensitive": true }),
                &ToolContext::default(),
            )
            .await;
        assert!(result.as_text().unwrap_or_default().contains("Hello"));
    }

    #[tokio::test]
    async fn glob_filter_restricts_files() {
        let dir = tempdir().expect("tempdir");
        tokio::fs::write(dir.path().join("a.rs"), "match\n")
            .await
            .unwrap();
        tokio::fs::write(dir.path().join("b.txt"), "match\n")
            .await
            .unwrap();
        let tool = GrepTool::new(test_ctx(dir.path()));
        let result = tool
            .call(
                json!({ "pattern": "match", "glob": "*.rs" }),
                &ToolContext::default(),
            )
            .await;
        let text = result.as_text().unwrap_or_default();
        assert!(text.contains("a.rs"));
        assert!(!text.contains("b.txt"));
    }

    #[tokio::test]
    async fn skips_binary_files() {
        let dir = tempdir().expect("tempdir");
        tokio::fs::write(dir.path().join("bin"), [0xff_u8, 0x00, 0xfe])
            .await
            .unwrap();
        tokio::fs::write(dir.path().join("txt.txt"), "needle\n")
            .await
            .unwrap();
        let tool = GrepTool::new(test_ctx(dir.path()));
        let result = tool
            .call(json!({ "pattern": "needle" }), &ToolContext::default())
            .await;
        // Should match only the text file, not panic on the binary one.
        assert!(result.as_text().unwrap_or_default().contains("txt.txt"));
    }

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