//! File write tool with path validation use async_trait::async_trait; use serde_json::{json, Value}; use zclaw_types::{Result, ZclawError}; use std::fs; use std::io::Write; use crate::tool::{Tool, ToolContext}; use super::path_validator::PathValidator; pub struct FileWriteTool; impl FileWriteTool { pub fn new() -> Self { Self } } #[async_trait] impl Tool for FileWriteTool { fn name(&self) -> &str { "file_write" } fn description(&self) -> &str { "Write content to a file on the filesystem. The file must be within allowed paths." } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "path": { "type": "string", "description": "The path to the file to write" }, "content": { "type": "string", "description": "The content to write to the file" }, "mode": { "type": "string", "description": "Write mode: 'create' (fail if exists), 'overwrite' (replace), 'append' (add to end)", "enum": ["create", "overwrite", "append"], "default": "create" }, "encoding": { "type": "string", "description": "Content encoding (default: utf-8)", "enum": ["utf-8", "base64"] } }, "required": ["path", "content"] }) } async fn execute(&self, input: Value, context: &ToolContext) -> Result { let path = input["path"].as_str() .ok_or_else(|| ZclawError::InvalidInput("Missing 'path' parameter".into()))?; let content = input["content"].as_str() .ok_or_else(|| ZclawError::InvalidInput("Missing 'content' parameter".into()))?; let mode = input["mode"].as_str().unwrap_or("create"); let encoding = input["encoding"].as_str().unwrap_or("utf-8"); // Validate path using context's path validator or create default let validator = context.path_validator.as_ref() .map(|v| v.clone()) .unwrap_or_else(|| { // Create default validator with workspace as allowed path let mut validator = PathValidator::new(); if let Some(ref workspace) = context.working_directory { validator = validator.with_workspace(std::path::PathBuf::from(workspace)); } validator }); // Validate path for write access let validated_path = validator.validate_write(path)?; // Decode content based on encoding let bytes = match encoding { "base64" => { use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; BASE64.decode(content) .map_err(|e| ZclawError::InvalidInput(format!("Invalid base64 content: {}", e)))? } _ => content.as_bytes().to_vec() }; // Check if file exists and handle mode let file_exists = validated_path.exists(); if file_exists && mode == "create" { return Err(ZclawError::InvalidInput(format!( "File already exists: {}", validated_path.display() ))); } // Write file let mut file = match mode { "append" => { fs::OpenOptions::new() .create(true) .append(true) .open(&validated_path) .map_err(|e| ZclawError::ToolError(format!("Failed to open file for append: {}", e)))? } _ => { // create or overwrite fs::File::create(&validated_path) .map_err(|e| ZclawError::ToolError(format!("Failed to create file: {}", e)))? } }; file.write_all(&bytes) .map_err(|e| ZclawError::ToolError(format!("Failed to write file: {}", e)))?; Ok(json!({ "success": true, "bytes_written": bytes.len(), "path": validated_path.to_string_lossy(), "mode": mode })) } } impl Default for FileWriteTool { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; use crate::tool::builtin::PathValidator; fn create_test_context_with_tempdir(dir: &std::path::Path) -> ToolContext { // Use canonicalized path to handle Windows extended-length paths let workspace = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()); let path_validator = Some(PathValidator::new().with_workspace(workspace)); ToolContext { agent_id: zclaw_types::AgentId::new(), working_directory: None, session_id: None, skill_executor: None, path_validator, } } #[tokio::test] async fn test_write_new_file() { let dir = tempdir().unwrap(); let path = dir.path().join("test.txt").to_str().unwrap().to_string(); let input = json!({ "path": path, "content": "Hello, World!" }); let context = create_test_context_with_tempdir(dir.path()); let tool = FileWriteTool::new(); let result = tool.execute(input, &context).await.unwrap(); assert!(result["success"].as_bool().unwrap()); assert_eq!(result["bytes_written"].as_u64().unwrap(), 13); } #[tokio::test] async fn test_create_mode_fails_on_existing() { let dir = tempdir().unwrap(); let path = dir.path().join("existing.txt"); fs::write(&path, "existing content").unwrap(); let input = json!({ "path": path.to_str().unwrap(), "content": "new content", "mode": "create" }); let context = create_test_context_with_tempdir(dir.path()); let tool = FileWriteTool::new(); let result = tool.execute(input, &context).await; assert!(result.is_err()); } #[tokio::test] async fn test_overwrite_mode() { let dir = tempdir().unwrap(); let path = dir.path().join("test.txt"); fs::write(&path, "old content").unwrap(); let input = json!({ "path": path.to_str().unwrap(), "content": "new content", "mode": "overwrite" }); let context = create_test_context_with_tempdir(dir.path()); let tool = FileWriteTool::new(); let result = tool.execute(input, &context).await.unwrap(); assert!(result["success"].as_bool().unwrap()); let content = fs::read_to_string(&path).unwrap(); assert_eq!(content, "new content"); } }