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

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

use crate::tools::write::is_hard_blocked;
use crate::tools::ToolCtx;

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

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

impl Tool for EditTool {
    fn def(&self) -> ToolDef {
        ToolDef {
            name: "edit".into(),
            description: "Exact-string replacement in a file. Use `replace_all=true` to replace every occurrence.".into(),
            input_schema: json!({
                "type": "object",
                "properties": {
                    "path": { "type": "string" },
                    "old_string": { "type": "string" },
                    "new_string": { "type": "string" },
                    "replace_all": { "type": "boolean", "default": false }
                },
                "required": ["path", "old_string", "new_string"]
            }),
        }
    }

    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 path = match args.get("path").and_then(|v| v.as_str()) {
                Some(path) => PathBuf::from(path),
                None => return ToolResult::error("missing 'path'"),
            };
            let old = match args.get("old_string").and_then(|value| value.as_str()) {
                Some(old) => old,
                None => return ToolResult::error("missing 'old_string'"),
            };
            let new = match args.get("new_string").and_then(|value| value.as_str()) {
                Some(new) => new,
                None => return ToolResult::error("missing 'new_string'"),
            };
            let replace_all = args
                .get("replace_all")
                .and_then(|value| value.as_bool())
                .unwrap_or(false);

            if old == new {
                return ToolResult::error("old_string and new_string are identical");
            }
            let abs = if path.is_absolute() {
                path
            } else {
                ctx.cwd.join(&path)
            };
            if is_hard_blocked(&abs) {
                return ToolResult::error(format!("edit blocked: {} is protected", abs.display()));
            }

            let canonical = tokio::fs::canonicalize(&abs)
                .await
                .unwrap_or_else(|_| abs.clone());
            if !ctx.has_been_read(&canonical).await && !ctx.has_been_read(&abs).await {
                return ToolResult::error(format!(
                    "refusing to edit {} without reading it first",
                    abs.display()
                ));
            }

            let original = match tokio::fs::read_to_string(&abs).await {
                Ok(text) => text,
                Err(err) => return ToolResult::error(format!("read failed: {err}")),
            };

            if !original.contains(old) {
                return ToolResult::error("old_string not found in file");
            }

            let count = original.matches(old).count();
            if !replace_all && count > 1 {
                return ToolResult::error(format!(
                    "old_string matches {count} occurrences; pass replace_all=true or add more context"
                ));
            }

            let replaced = if replace_all {
                original.replace(old, new)
            } else {
                original.replacen(old, new, 1)
            };

            if let Err(err) = tokio::fs::write(&abs, &replaced).await {
                return ToolResult::error(format!("write failed: {err}"));
            }
            ctx.mark_read(&canonical).await;

            let diff = TextDiff::from_lines(&original, &replaced);
            let udiff = diff
                .unified_diff()
                .context_radius(3)
                .header(&abs.display().to_string(), &abs.display().to_string())
                .to_string();

            ToolResult::text(format!(
                "{{\"path\":\"{}\",\"replacements\":{},\"diff\":{}}}",
                abs.display(),
                if replace_all { count } else { 1 },
                serde_json::to_string(&udiff).unwrap_or_else(|_| "\"<diff render error>\"".into())
            ))
        })
    }
}

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

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

    #[tokio::test]
    async fn replaces_single_occurrence_when_unique() {
        let dir = tempdir().unwrap();
        let file = dir.path().join("code.rs");
        tokio::fs::write(&file, "fn main() { println!(\"old\"); }")
            .await
            .unwrap();
        let ctx = test_ctx(dir.path());
        let canonical = tokio::fs::canonicalize(&file).await.unwrap();
        ctx.read_files.lock().await.insert(canonical);
        let tool = EditTool::new(ctx);
        let result = tool
            .call(
                json!({ "path": "code.rs", "old_string": "old", "new_string": "new" }),
                &ToolContext::default(),
            )
            .await;

        assert!(!result.is_error, "{result:?}");
        let debug = format!("{result:?}");
        assert!(debug.contains("@@"), "{debug}");
        let body = tokio::fs::read_to_string(&file).await.unwrap();
        assert!(body.contains("new"), "{body}");
    }

    #[tokio::test]
    async fn rejects_ambiguous_old_string_without_replace_all() {
        let dir = tempdir().unwrap();
        let file = dir.path().join("code.rs");
        tokio::fs::write(&file, "x\nx\n").await.unwrap();
        let ctx = test_ctx(dir.path());
        ctx.read_files
            .lock()
            .await
            .insert(tokio::fs::canonicalize(&file).await.unwrap());
        let tool = EditTool::new(ctx);
        let result = tool
            .call(
                json!({ "path": "code.rs", "old_string": "x", "new_string": "y" }),
                &ToolContext::default(),
            )
            .await;
        assert!(
            format!("{result:?}").contains("2 occurrences"),
            "{result:?}"
        );
    }

    #[tokio::test]
    async fn replace_all_true_replaces_every_match() {
        let dir = tempdir().unwrap();
        let file = dir.path().join("code.rs");
        tokio::fs::write(&file, "x\nx\n").await.unwrap();
        let ctx = test_ctx(dir.path());
        ctx.read_files
            .lock()
            .await
            .insert(tokio::fs::canonicalize(&file).await.unwrap());
        let tool = EditTool::new(ctx);
        let result = tool
            .call(
                json!({ "path": "code.rs", "old_string": "x", "new_string": "y", "replace_all": true }),
                &ToolContext::default(),
            )
            .await;

        assert!(!result.is_error, "{result:?}");
        let body = tokio::fs::read_to_string(&file).await.unwrap();
        assert_eq!(body, "y\ny\n");
    }

    #[tokio::test]
    async fn rejects_identical_strings() {
        let dir = tempdir().unwrap();
        let file = dir.path().join("code.rs");
        tokio::fs::write(&file, "x").await.unwrap();
        let ctx = test_ctx(dir.path());
        ctx.read_files
            .lock()
            .await
            .insert(tokio::fs::canonicalize(&file).await.unwrap());
        let tool = EditTool::new(ctx);
        let result = tool
            .call(
                json!({ "path": "code.rs", "old_string": "x", "new_string": "x" }),
                &ToolContext::default(),
            )
            .await;
        assert!(format!("{result:?}").contains("identical"), "{result:?}");
    }

    #[tokio::test]
    async fn rejects_missing_old_string() {
        let dir = tempdir().unwrap();
        let tool = EditTool::new(test_ctx(dir.path()));
        let result = tool
            .call(
                json!({ "path": "code.rs", "new_string": "y" }),
                &ToolContext::default(),
            )
            .await;
        assert!(
            format!("{result:?}").contains("missing 'old_string'"),
            "{result:?}"
        );
    }

    #[tokio::test]
    async fn rejects_edit_without_prior_read() {
        let dir = tempdir().unwrap();
        let file = dir.path().join("code.rs");
        tokio::fs::write(&file, "x").await.unwrap();
        let tool = EditTool::new(test_ctx(dir.path()));
        let result = tool
            .call(
                json!({ "path": "code.rs", "old_string": "x", "new_string": "y" }),
                &ToolContext::default(),
            )
            .await;
        let debug = format!("{result:?}");
        assert!(debug.contains("without reading"), "{debug}");
    }
}