microclaw-engine 0.10.1

Channel-independent Agent Engine and default services for MicroClaw
Documentation
use async_trait::async_trait;
use serde_json::json;
use std::path::PathBuf;
use tracing::info;

use crate::config::WorkingDirIsolation;
use microclaw_core::llm_types::ToolDefinition;

use super::{schema_object, Tool, ToolResult};

pub struct WriteFileTool {
    working_dir: PathBuf,
    working_dir_isolation: WorkingDirIsolation,
    diff_max_lines: usize,
    governance_guard: crate::internal::tool_runtime::path_guard::GovernanceGuard,
}

impl WriteFileTool {
    pub fn new(working_dir: &str) -> Self {
        Self::new_with_isolation(working_dir, WorkingDirIsolation::Shared)
    }

    pub fn new_with_isolation(
        working_dir: &str,
        working_dir_isolation: WorkingDirIsolation,
    ) -> Self {
        Self {
            working_dir: PathBuf::from(working_dir),
            working_dir_isolation,
            diff_max_lines: microclaw_core::diff::DEFAULT_DIFF_MAX_LINES,
            governance_guard: Default::default(),
        }
    }

    pub fn with_diff_max_lines(mut self, max_lines: usize) -> Self {
        self.diff_max_lines = max_lines;
        self
    }

    pub fn with_governance_guard(
        mut self,
        guard: crate::internal::tool_runtime::path_guard::GovernanceGuard,
    ) -> Self {
        self.governance_guard = guard;
        self
    }
}

#[async_trait]
impl Tool for WriteFileTool {
    fn name(&self) -> &str {
        "write_file"
    }

    fn definition(&self) -> ToolDefinition {
        ToolDefinition {
            name: "write_file".into(),
            description: "Write content to a file. Creates the file and any parent directories if they don't exist. Overwrites existing content. Prefer paths relative to the current chat working directory; do not invent machine-specific absolute paths unless the user or a tool already provided them.".into(),
            input_schema: schema_object(
                json!({
                    "path": {
                        "type": "string",
                        "description": "The file path to write to, usually relative to the current chat working directory"
                    },
                    "content": {
                        "type": "string",
                        "description": "The content to write"
                    }
                }),
                &["path", "content"],
            ),
        }
    }

    async fn execute(&self, input: serde_json::Value) -> ToolResult {
        let path = match input.get("path").and_then(|v| v.as_str()) {
            Some(p) => p,
            None => return ToolResult::error("Missing 'path' parameter".into()),
        };
        let working_dir =
            super::resolve_tool_working_dir(&self.working_dir, self.working_dir_isolation, &input);
        let resolved_path = super::resolve_tool_path(&working_dir, path);
        let resolved_path_str = resolved_path.to_string_lossy().to_string();

        if let Err(msg) = crate::internal::tool_runtime::path_guard::check_path(&resolved_path_str)
        {
            return ToolResult::error(msg);
        }
        if let Err(msg) = self.governance_guard.check_write(&resolved_path) {
            return ToolResult::error(msg);
        }

        // Guard: SKILL.md files must go in the dedicated skills directory, not runtime/skills/.
        if resolved_path.file_name().and_then(|f| f.to_str()) == Some("SKILL.md") {
            let path_str = resolved_path.to_string_lossy();
            let normalized = path_str.replace('\\', "/");
            let in_correct_dir = normalized.contains("/skills/");
            let in_runtime_dir = normalized.contains("/runtime/skills/");
            if in_runtime_dir || !in_correct_dir {
                return ToolResult::error(format!(
                    "Wrong directory for skills! SKILL.md files MUST be written to ~/.microclaw/skills/<skill-name>/SKILL.md (or configured skills dir) — \
                    NOT runtime/skills/ or any other location. Use the `sync_skills` tool instead, which handles this automatically. \
                    Attempted path: {}",
                    resolved_path.display()
                ));
            }
        }

        let content = match input.get("content").and_then(|v| v.as_str()) {
            Some(c) => c,
            None => return ToolResult::error("Missing 'content' parameter".into()),
        };

        info!("Writing file: {}", resolved_path.display());

        // Previous content (empty for a new file) so overwrites render as a
        // diff instead of a bare success line. Non-UTF-8 files skip the diff.
        let previous = tokio::fs::read_to_string(&resolved_path)
            .await
            .unwrap_or_default();

        if let Some(parent) = resolved_path.parent() {
            if let Err(e) = tokio::fs::create_dir_all(parent).await {
                return ToolResult::error(format!("Failed to create directories: {e}"));
            }
        }

        match tokio::fs::write(&resolved_path, content).await {
            Ok(()) => {
                let diff =
                    microclaw_core::diff::unified_diff(&previous, content, self.diff_max_lines);
                let mut result = ToolResult::success(match &diff {
                    Some(d) => format!(
                        "Successfully wrote to {} ({})",
                        resolved_path.display(),
                        d.stats()
                    ),
                    None => format!("Successfully wrote to {}", resolved_path.display()),
                });
                if let Some(d) = diff {
                    result =
                        result.with_metadata(microclaw_core::diff::file_diff_metadata(path, &d));
                }
                result
            }
            Err(e) => ToolResult::error(format!("Failed to write file: {e}")),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[tokio::test]
    async fn test_write_file_refuses_governance_files() {
        let dir = std::env::temp_dir().join(format!("microclaw_wf_gov_{}", uuid::Uuid::new_v4()));
        let memory = dir.join("runtime/groups/AGENTS.md");
        let guard = crate::internal::tool_runtime::path_guard::GovernanceGuard::default()
            .protect_data_root(&dir)
            .exempt(dir.join("working_dir"));
        let tool = WriteFileTool::new(dir.join("working_dir").to_str().unwrap())
            .with_governance_guard(guard);
        let result = tool
            .execute(json!({"path": memory.to_str().unwrap(), "content": "injected"}))
            .await;
        assert!(result.is_error);
        assert!(result.content.contains("governance file"));
        assert!(!memory.exists());

        let result = tool
            .execute(json!({"path": "notes.md", "content": "ok"}))
            .await;
        assert!(!result.is_error, "{}", result.content);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_write_file_definition_prefers_relative_paths() {
        let tool = WriteFileTool::new(".");
        let def = tool.definition();
        assert!(def
            .description
            .contains("Prefer paths relative to the current chat working directory"));
        assert_eq!(
            def.input_schema["properties"]["path"]["description"].as_str(),
            Some(
                "The file path to write to, usually relative to the current chat working directory"
            )
        );
    }

    #[tokio::test]
    async fn test_write_file_success() {
        let dir = std::env::temp_dir().join(format!("microclaw_wf_{}", uuid::Uuid::new_v4()));
        std::fs::create_dir_all(&dir).unwrap();
        let file = dir.join("out.txt");

        let tool = WriteFileTool::new(".");
        let result = tool
            .execute(json!({"path": file.to_str().unwrap(), "content": "hello world"}))
            .await;
        assert!(!result.is_error);
        assert!(result.content.contains("Successfully wrote"));

        let content = std::fs::read_to_string(&file).unwrap();
        assert_eq!(content, "hello world");

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn test_write_file_creates_parent_dirs() {
        let dir = std::env::temp_dir().join(format!("microclaw_wf2_{}", uuid::Uuid::new_v4()));
        let file = dir.join("sub").join("dir").join("file.txt");

        let tool = WriteFileTool::new(".");
        let result = tool
            .execute(json!({"path": file.to_str().unwrap(), "content": "nested"}))
            .await;
        assert!(!result.is_error);
        assert_eq!(std::fs::read_to_string(&file).unwrap(), "nested");

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn test_write_file_missing_params() {
        let tool = WriteFileTool::new(".");

        let result = tool.execute(json!({"content": "hello"})).await;
        assert!(result.is_error);
        assert!(result.content.contains("Missing 'path'"));

        let result = tool.execute(json!({"path": "/tmp/x"})).await;
        assert!(result.is_error);
        assert!(result.content.contains("Missing 'content'"));
    }

    #[tokio::test]
    async fn test_write_file_resolves_relative_to_working_dir() {
        let root = std::env::temp_dir().join(format!("microclaw_wf3_{}", uuid::Uuid::new_v4()));
        let work = root.join("workspace");
        std::fs::create_dir_all(&work).unwrap();

        let tool = WriteFileTool::new(work.to_str().unwrap());
        let result = tool
            .execute(json!({"path": "nested/out.txt", "content": "ok"}))
            .await;
        assert!(!result.is_error);
        assert_eq!(
            std::fs::read_to_string(work.join("shared/nested/out.txt")).unwrap(),
            "ok"
        );

        let _ = std::fs::remove_dir_all(&root);
    }
}