microclaw_engine/tools/
write_file.rs1use 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 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 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}