use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use tokio::fs;
use uuid::Uuid;
use chrono::Utc;
use super::{Tool, ToolContext, ToolResult, ToolError};
use super::permission::{PermissionRequest, RiskLevel, create_permission_request};
pub struct WriteTool;
#[derive(Debug, Deserialize)]
struct WriteParams {
#[serde(rename = "filePath")]
file_path: String,
content: String,
#[serde(default)]
create_backup: Option<bool>,
#[serde(default)]
force_overwrite: Option<bool>,
}
#[async_trait]
impl Tool for WriteTool {
fn id(&self) -> &str {
"write"
}
fn description(&self) -> &str {
"Write content to a file with atomic operations and backup support"
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"filePath": {
"type": "string",
"description": "The absolute path to the file to write (must be absolute, not relative)"
},
"content": {
"type": "string",
"description": "The content to write to the file"
},
"createBackup": {
"type": "boolean",
"description": "Create a backup of existing file before overwriting",
"default": true
},
"forceOverwrite": {
"type": "boolean",
"description": "Force overwrite without confirmation for existing files",
"default": false
}
},
"required": ["filePath", "content"]
})
}
async fn execute(
&self,
args: Value,
ctx: ToolContext,
) -> Result<ToolResult, ToolError> {
let params: WriteParams = serde_json::from_value(args)
.map_err(|e| ToolError::InvalidParameters(e.to_string()))?;
let path = if PathBuf::from(¶ms.file_path).is_absolute() {
PathBuf::from(¶ms.file_path)
} else {
return Err(ToolError::InvalidParameters(
"filePath must be absolute, not relative".to_string()
));
};
let file_exists = path.exists();
let is_new_file = !file_exists;
self.validate_write_operation(&path, ¶ms, &ctx).await?;
let risk_level = if is_new_file {
RiskLevel::Low
} else {
RiskLevel::Medium
};
let permission_request = create_permission_request(
Uuid::new_v4().to_string(),
ctx.session_id.clone(),
if is_new_file {
format!("Create new file: {}", path.display())
} else {
format!("Overwrite existing file: {}", path.display())
},
risk_level,
json!({
"filePath": path.to_string_lossy(),
"content": params.content.chars().take(200).collect::<String>(),
"contentLength": params.content.len(),
"isNewFile": is_new_file,
"createBackup": params.create_backup.unwrap_or(true)
}),
);
let backup_path = if file_exists && params.create_backup.unwrap_or(true) {
Some(self.create_backup(&path).await?)
} else {
None
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await.map_err(|e| {
ToolError::ExecutionFailed(format!("Failed to create parent directories: {}", e))
})?;
}
let write_result = self.atomic_write(&path, ¶ms.content).await;
match write_result {
Ok(()) => {
let line_count = params.content.lines().count();
let byte_count = params.content.len();
let relative_path = path
.strip_prefix(&ctx.working_directory)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
let metadata = json!({
"path": path.to_string_lossy(),
"relative_path": relative_path,
"lines_written": line_count,
"bytes_written": byte_count,
"was_new_file": is_new_file,
"backup_created": backup_path.is_some(),
"backup_path": backup_path.as_ref().map(|p| p.to_string_lossy().to_string()),
"timestamp": Utc::now().to_rfc3339(),
"content_preview": params.content.lines().take(3).collect::<Vec<_>>().join("\n")
});
let action = if is_new_file { "Created" } else { "Updated" };
Ok(ToolResult {
title: relative_path,
metadata,
output: format!(
"{} file with {} bytes ({} lines){}.",
action,
byte_count,
line_count,
if backup_path.is_some() { " (backup created)" } else { "" }
),
})
}
Err(e) => {
if let Some(backup) = &backup_path {
if let Err(restore_err) = fs::rename(backup, &path).await {
tracing::warn!("Failed to restore backup after write failure: {}", restore_err);
}
}
Err(e)
}
}
}
}
impl WriteTool {
async fn validate_write_operation(
&self,
path: &Path,
params: &WriteParams,
ctx: &ToolContext,
) -> Result<(), ToolError> {
if !self.is_path_allowed(path, &ctx.working_directory) {
return Err(ToolError::PermissionDenied(format!(
"Writing outside of working directory is not allowed: {}",
path.display()
)));
}
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
match ext.to_lowercase().as_str() {
"exe" | "bat" | "cmd" | "com" | "scr" | "msi" => {
return Err(ToolError::PermissionDenied(
"Writing executable files is not allowed for security reasons".to_string()
));
}
_ => {}
}
}
if params.content.len() > 50_000_000 { return Err(ToolError::InvalidParameters(
"Content too large (>50MB). Consider breaking into smaller files.".to_string()
));
}
if !params.content.is_ascii() && params.content.chars().any(|c| c.is_control() && c != '\n' && c != '\r' && c != '\t') {
return Err(ToolError::InvalidParameters(
"Content contains invalid control characters".to_string()
));
}
Ok(())
}
fn is_path_allowed(&self, target_path: &Path, working_dir: &Path) -> bool {
target_path.starts_with(working_dir) || target_path == working_dir
}
async fn create_backup(&self, original_path: &Path) -> Result<PathBuf, ToolError> {
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
let backup_name = format!(
"{}.backup.{}",
original_path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown"),
timestamp
);
let backup_path = original_path.parent()
.unwrap_or(Path::new("."))
.join(backup_name);
fs::copy(original_path, &backup_path).await.map_err(|e| {
ToolError::ExecutionFailed(format!("Failed to create backup: {}", e))
})?;
Ok(backup_path)
}
async fn atomic_write(&self, target_path: &Path, content: &str) -> Result<(), ToolError> {
let temp_name = format!(
".{}.tmp.{}",
target_path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown"),
Uuid::new_v4().simple()
);
let temp_path = target_path.parent()
.unwrap_or(Path::new("."))
.join(temp_name);
fs::write(&temp_path, content).await.map_err(|e| {
ToolError::ExecutionFailed(format!("Failed to write temporary file: {}", e))
})?;
fs::rename(&temp_path, target_path).await.map_err(|e| {
if let Err(cleanup_err) = std::fs::remove_file(&temp_path) {
tracing::warn!("Failed to clean up temporary file {}: {}", temp_path.display(), cleanup_err);
}
ToolError::ExecutionFailed(format!("Failed to move temporary file to target: {}", e))
})?;
Ok(())
}
}