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::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;

use motosan_agent_tool::{Tool, ToolContext, ToolDef, ToolResult};
use serde_json::{json, Value};

use crate::tools::ToolCtx;

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

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

impl Tool for LsTool {
    fn def(&self) -> ToolDef {
        ToolDef {
            name: "ls".to_string(),
            description: "List the immediate entries of a directory (non-recursive).".to_string(),
            input_schema: json!({
                "type": "object",
                "properties": {
                    "path": { "type": "string", "description": "Directory path (absolute or cwd-relative). Defaults to cwd." }
                }
            }),
        }
    }

    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 abs = resolve_in_cwd(&ctx.cwd, rel);
            if !abs.starts_with(&ctx.cwd) {
                return ToolResult::error(format!(
                    "path {} is outside the working directory",
                    abs.display()
                ));
            }
            let mut entries = match tokio::fs::read_dir(&abs).await {
                Ok(rd) => rd,
                Err(e) => {
                    return ToolResult::error(format!("failed to list {}: {e}", abs.display()))
                }
            };
            let mut lines: Vec<String> = Vec::new();
            loop {
                match entries.next_entry().await {
                    Ok(Some(entry)) => {
                        let name = entry.file_name().to_string_lossy().to_string();
                        let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
                        lines.push(if is_dir { format!("{name}/") } else { name });
                    }
                    Ok(None) => break,
                    Err(e) => return ToolResult::error(format!("read_dir error: {e}")),
                }
            }
            lines.sort();
            ToolResult::text(lines.join("\n"))
        })
    }
}

/// Join `rel` onto `cwd` and lexically normalize `.`/`..` so an escaping
/// path can be detected with `starts_with`. Does not touch the filesystem.
pub(crate) fn resolve_in_cwd(cwd: &std::path::Path, rel: &str) -> PathBuf {
    let joined = if std::path::Path::new(rel).is_absolute() {
        PathBuf::from(rel)
    } else {
        cwd.join(rel)
    };
    let mut out = PathBuf::new();
    for comp in joined.components() {
        use std::path::Component::*;
        match comp {
            ParentDir => {
                out.pop();
            }
            CurDir => {}
            other => out.push(other.as_os_str()),
        }
    }
    out
}

#[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 lists_directory_entries_sorted_with_dir_marker() {
        let dir = tempdir().expect("tempdir");
        tokio::fs::write(dir.path().join("b.txt"), "x")
            .await
            .unwrap();
        tokio::fs::write(dir.path().join("a.txt"), "x")
            .await
            .unwrap();
        tokio::fs::create_dir(dir.path().join("sub")).await.unwrap();

        let tool = LsTool::new(test_ctx(dir.path()));
        let result = tool.call(json!({}), &ToolContext::default()).await;
        let text = result.as_text().unwrap_or_default();
        assert_eq!(text, "a.txt\nb.txt\nsub/");
    }

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

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