use crate::tools::types::{Tool, ToolContext, ToolOutput};
use anyhow::Result;
use async_trait::async_trait;
pub struct WriteTool;
#[async_trait]
impl Tool for WriteTool {
fn name(&self) -> &str {
"write"
}
fn description(&self) -> &str {
"Write content to a file. Creates the file and parent directories if they don't exist."
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {
"file_path": {
"type": "string",
"description": "Required. Path to the file to write. Always provide this exact field name: 'file_path'."
},
"content": {
"type": "string",
"description": "Required. Full content to write to the file. Always provide this exact field name: 'content'."
}
},
"required": ["file_path", "content"],
"examples": [
{
"file_path": "notes.txt",
"content": "hello world"
}
]
})
}
async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
let file_path = match args.get("file_path").and_then(|v| v.as_str()) {
Some(p) => p,
None => return Ok(ToolOutput::error("file_path parameter is required")),
};
let content = match args.get("content").and_then(|v| v.as_str()) {
Some(c) => c,
None => return Ok(ToolOutput::error("content parameter is required")),
};
let target = if std::path::Path::new(file_path).is_absolute() {
std::path::PathBuf::from(file_path)
} else {
ctx.workspace.join(file_path)
};
if let Some(parent) = target.parent() {
if let Err(e) = tokio::fs::create_dir_all(parent).await {
return Ok(ToolOutput::error(format!(
"Failed to create parent directories for {}: {}",
target.display(),
e
)));
}
}
let resolved = match ctx.resolve_path_for_write(file_path) {
Ok(p) => p,
Err(e) => return Ok(ToolOutput::error(format!("Failed to resolve path: {}", e))),
};
let before_content = if resolved.exists() {
tokio::fs::read_to_string(&resolved).await.ok()
} else {
None
};
match tokio::fs::write(&resolved, content).await {
Ok(()) => {
let lines = content.lines().count();
let bytes = content.len();
let mut metadata = serde_json::Map::new();
metadata.insert("file_path".to_string(), serde_json::json!(file_path));
metadata.insert("after".to_string(), serde_json::json!(content));
if let Some(before) = before_content {
metadata.insert("before".to_string(), serde_json::json!(before));
}
Ok(ToolOutput::success(format!(
"Wrote {} bytes ({} lines) to {}",
bytes,
lines,
resolved.display()
))
.with_metadata(serde_json::Value::Object(metadata)))
}
Err(e) => Ok(ToolOutput::error(format!(
"Failed to write file {}: {}",
resolved.display(),
e
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_write_new_file() {
let temp = tempfile::tempdir().unwrap();
let tool = WriteTool;
let ctx = ToolContext::new(temp.path().to_path_buf());
let result = tool
.execute(
&serde_json::json!({"file_path": "new.txt", "content": "hello world"}),
&ctx,
)
.await
.unwrap();
assert!(result.success);
let content = std::fs::read_to_string(temp.path().join("new.txt")).unwrap();
assert_eq!(content, "hello world");
}
#[tokio::test]
async fn test_write_creates_parent_dirs() {
let temp = tempfile::tempdir().unwrap();
let tool = WriteTool;
let ctx = ToolContext::new(temp.path().to_path_buf());
let result = tool
.execute(
&serde_json::json!({"file_path": "sub/dir/file.txt", "content": "nested"}),
&ctx,
)
.await
.unwrap();
assert!(result.success);
let content = std::fs::read_to_string(temp.path().join("sub/dir/file.txt")).unwrap();
assert_eq!(content, "nested");
}
#[tokio::test]
async fn test_write_overwrite() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("existing.txt"), "old").unwrap();
let tool = WriteTool;
let ctx = ToolContext::new(temp.path().to_path_buf());
let result = tool
.execute(
&serde_json::json!({"file_path": "existing.txt", "content": "new"}),
&ctx,
)
.await
.unwrap();
assert!(result.success);
let content = std::fs::read_to_string(temp.path().join("existing.txt")).unwrap();
assert_eq!(content, "new");
}
#[tokio::test]
async fn test_write_missing_params() {
let tool = WriteTool;
let ctx = ToolContext::new(std::path::PathBuf::from("/tmp"));
let result = tool.execute(&serde_json::json!({}), &ctx).await.unwrap();
assert!(!result.success);
let result = tool
.execute(&serde_json::json!({"file_path": "x"}), &ctx)
.await
.unwrap();
assert!(!result.success);
}
#[test]
fn test_write_schema_is_canonical() {
let tool = WriteTool;
let params = tool.parameters();
assert_eq!(params["additionalProperties"], false);
assert_eq!(
params["required"],
serde_json::json!(["file_path", "content"])
);
let examples = params["examples"].as_array().unwrap();
assert_eq!(examples[0]["file_path"], "notes.txt");
assert!(examples[0].get("path").is_none());
}
}