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);
}
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());
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);
}
}