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