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}
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 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 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}