Skip to main content

microclaw_engine/tools/
write_file.rs

1use async_trait::async_trait;
2use serde_json::json;
3use std::path::PathBuf;
4use tracing::info;
5
6use crate::config::WorkingDirIsolation;
7use microclaw_core::llm_types::ToolDefinition;
8
9use super::{schema_object, Tool, ToolResult};
10
11pub struct WriteFileTool {
12    working_dir: PathBuf,
13    working_dir_isolation: WorkingDirIsolation,
14    diff_max_lines: usize,
15    governance_guard: crate::internal::tool_runtime::path_guard::GovernanceGuard,
16}
17
18impl WriteFileTool {
19    pub fn new(working_dir: &str) -> Self {
20        Self::new_with_isolation(working_dir, WorkingDirIsolation::Shared)
21    }
22
23    pub fn new_with_isolation(
24        working_dir: &str,
25        working_dir_isolation: WorkingDirIsolation,
26    ) -> Self {
27        Self {
28            working_dir: PathBuf::from(working_dir),
29            working_dir_isolation,
30            diff_max_lines: microclaw_core::diff::DEFAULT_DIFF_MAX_LINES,
31            governance_guard: Default::default(),
32        }
33    }
34
35    pub fn with_diff_max_lines(mut self, max_lines: usize) -> Self {
36        self.diff_max_lines = max_lines;
37        self
38    }
39
40    pub fn with_governance_guard(
41        mut self,
42        guard: crate::internal::tool_runtime::path_guard::GovernanceGuard,
43    ) -> Self {
44        self.governance_guard = guard;
45        self
46    }
47}
48
49#[async_trait]
50impl Tool for WriteFileTool {
51    fn name(&self) -> &str {
52        "write_file"
53    }
54
55    fn definition(&self) -> ToolDefinition {
56        ToolDefinition {
57            name: "write_file".into(),
58            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(),
59            input_schema: schema_object(
60                json!({
61                    "path": {
62                        "type": "string",
63                        "description": "The file path to write to, usually relative to the current chat working directory"
64                    },
65                    "content": {
66                        "type": "string",
67                        "description": "The content to write"
68                    }
69                }),
70                &["path", "content"],
71            ),
72        }
73    }
74
75    async fn execute(&self, input: serde_json::Value) -> ToolResult {
76        let path = match input.get("path").and_then(|v| v.as_str()) {
77            Some(p) => p,
78            None => return ToolResult::error("Missing 'path' parameter".into()),
79        };
80        let working_dir =
81            super::resolve_tool_working_dir(&self.working_dir, self.working_dir_isolation, &input);
82        let resolved_path = super::resolve_tool_path(&working_dir, path);
83        let resolved_path_str = resolved_path.to_string_lossy().to_string();
84
85        if let Err(msg) = crate::internal::tool_runtime::path_guard::check_path(&resolved_path_str)
86        {
87            return ToolResult::error(msg);
88        }
89        if let Err(msg) = self.governance_guard.check_write(&resolved_path) {
90            return ToolResult::error(msg);
91        }
92
93        // Guard: SKILL.md files must go in the dedicated skills directory, not runtime/skills/.
94        if resolved_path.file_name().and_then(|f| f.to_str()) == Some("SKILL.md") {
95            let path_str = resolved_path.to_string_lossy();
96            let normalized = path_str.replace('\\', "/");
97            let in_correct_dir = normalized.contains("/skills/");
98            let in_runtime_dir = normalized.contains("/runtime/skills/");
99            if in_runtime_dir || !in_correct_dir {
100                return ToolResult::error(format!(
101                    "Wrong directory for skills! SKILL.md files MUST be written to ~/.microclaw/skills/<skill-name>/SKILL.md (or configured skills dir) — \
102                    NOT runtime/skills/ or any other location. Use the `sync_skills` tool instead, which handles this automatically. \
103                    Attempted path: {}",
104                    resolved_path.display()
105                ));
106            }
107        }
108
109        let content = match input.get("content").and_then(|v| v.as_str()) {
110            Some(c) => c,
111            None => return ToolResult::error("Missing 'content' parameter".into()),
112        };
113
114        info!("Writing file: {}", resolved_path.display());
115
116        // Previous content (empty for a new file) so overwrites render as a
117        // diff instead of a bare success line. Non-UTF-8 files skip the diff.
118        let previous = tokio::fs::read_to_string(&resolved_path)
119            .await
120            .unwrap_or_default();
121
122        if let Some(parent) = resolved_path.parent() {
123            if let Err(e) = tokio::fs::create_dir_all(parent).await {
124                return ToolResult::error(format!("Failed to create directories: {e}"));
125            }
126        }
127
128        match tokio::fs::write(&resolved_path, content).await {
129            Ok(()) => {
130                let diff =
131                    microclaw_core::diff::unified_diff(&previous, content, self.diff_max_lines);
132                let mut result = ToolResult::success(match &diff {
133                    Some(d) => format!(
134                        "Successfully wrote to {} ({})",
135                        resolved_path.display(),
136                        d.stats()
137                    ),
138                    None => format!("Successfully wrote to {}", resolved_path.display()),
139                });
140                if let Some(d) = diff {
141                    result =
142                        result.with_metadata(microclaw_core::diff::file_diff_metadata(path, &d));
143                }
144                result
145            }
146            Err(e) => ToolResult::error(format!("Failed to write file: {e}")),
147        }
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use serde_json::json;
155
156    #[tokio::test]
157    async fn test_write_file_refuses_governance_files() {
158        let dir = std::env::temp_dir().join(format!("microclaw_wf_gov_{}", uuid::Uuid::new_v4()));
159        let memory = dir.join("runtime/groups/AGENTS.md");
160        let guard = crate::internal::tool_runtime::path_guard::GovernanceGuard::default()
161            .protect_data_root(&dir)
162            .exempt(dir.join("working_dir"));
163        let tool = WriteFileTool::new(dir.join("working_dir").to_str().unwrap())
164            .with_governance_guard(guard);
165        let result = tool
166            .execute(json!({"path": memory.to_str().unwrap(), "content": "injected"}))
167            .await;
168        assert!(result.is_error);
169        assert!(result.content.contains("governance file"));
170        assert!(!memory.exists());
171
172        let result = tool
173            .execute(json!({"path": "notes.md", "content": "ok"}))
174            .await;
175        assert!(!result.is_error, "{}", result.content);
176        let _ = std::fs::remove_dir_all(&dir);
177    }
178
179    #[test]
180    fn test_write_file_definition_prefers_relative_paths() {
181        let tool = WriteFileTool::new(".");
182        let def = tool.definition();
183        assert!(def
184            .description
185            .contains("Prefer paths relative to the current chat working directory"));
186        assert_eq!(
187            def.input_schema["properties"]["path"]["description"].as_str(),
188            Some(
189                "The file path to write to, usually relative to the current chat working directory"
190            )
191        );
192    }
193
194    #[tokio::test]
195    async fn test_write_file_success() {
196        let dir = std::env::temp_dir().join(format!("microclaw_wf_{}", uuid::Uuid::new_v4()));
197        std::fs::create_dir_all(&dir).unwrap();
198        let file = dir.join("out.txt");
199
200        let tool = WriteFileTool::new(".");
201        let result = tool
202            .execute(json!({"path": file.to_str().unwrap(), "content": "hello world"}))
203            .await;
204        assert!(!result.is_error);
205        assert!(result.content.contains("Successfully wrote"));
206
207        let content = std::fs::read_to_string(&file).unwrap();
208        assert_eq!(content, "hello world");
209
210        let _ = std::fs::remove_dir_all(&dir);
211    }
212
213    #[tokio::test]
214    async fn test_write_file_creates_parent_dirs() {
215        let dir = std::env::temp_dir().join(format!("microclaw_wf2_{}", uuid::Uuid::new_v4()));
216        let file = dir.join("sub").join("dir").join("file.txt");
217
218        let tool = WriteFileTool::new(".");
219        let result = tool
220            .execute(json!({"path": file.to_str().unwrap(), "content": "nested"}))
221            .await;
222        assert!(!result.is_error);
223        assert_eq!(std::fs::read_to_string(&file).unwrap(), "nested");
224
225        let _ = std::fs::remove_dir_all(&dir);
226    }
227
228    #[tokio::test]
229    async fn test_write_file_missing_params() {
230        let tool = WriteFileTool::new(".");
231
232        let result = tool.execute(json!({"content": "hello"})).await;
233        assert!(result.is_error);
234        assert!(result.content.contains("Missing 'path'"));
235
236        let result = tool.execute(json!({"path": "/tmp/x"})).await;
237        assert!(result.is_error);
238        assert!(result.content.contains("Missing 'content'"));
239    }
240
241    #[tokio::test]
242    async fn test_write_file_resolves_relative_to_working_dir() {
243        let root = std::env::temp_dir().join(format!("microclaw_wf3_{}", uuid::Uuid::new_v4()));
244        let work = root.join("workspace");
245        std::fs::create_dir_all(&work).unwrap();
246
247        let tool = WriteFileTool::new(work.to_str().unwrap());
248        let result = tool
249            .execute(json!({"path": "nested/out.txt", "content": "ok"}))
250            .await;
251        assert!(!result.is_error);
252        assert_eq!(
253            std::fs::read_to_string(work.join("shared/nested/out.txt")).unwrap(),
254            "ok"
255        );
256
257        let _ = std::fs::remove_dir_all(&root);
258    }
259}