use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tokio::fs;
use tokio::io::AsyncWriteExt;
use super::context::{ToolContext, ToolEvent, ToolOperation};
use super::{FileTool, ToolErrorCode, ToolOutput};
use crate::error::NikaError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WriteParams {
pub file_path: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WriteResult {
pub path: String,
pub bytes_written: usize,
pub lines_written: usize,
}
pub struct WriteTool {
ctx: Arc<ToolContext>,
}
impl WriteTool {
pub fn new(ctx: Arc<ToolContext>) -> Self {
Self { ctx }
}
pub async fn execute(&self, params: WriteParams) -> Result<WriteResult, NikaError> {
let path = self.ctx.validate_path(¶ms.file_path)?;
self.ctx.check_permission(ToolOperation::Write)?;
if path.exists() {
return Err(NikaError::ToolError {
code: ToolErrorCode::FileAlreadyExists.code(),
message: format!(
"File already exists: {}. Use the Edit tool to modify existing files.",
params.file_path
),
});
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.await
.map_err(|e| NikaError::ToolError {
code: ToolErrorCode::WriteFailed.code(),
message: format!("Failed to create parent directories: {}", e),
})?;
}
let temp_path = path.with_extension("tmp.nika");
let mut file = fs::File::create(&temp_path)
.await
.map_err(|e| NikaError::ToolError {
code: ToolErrorCode::WriteFailed.code(),
message: format!("Failed to create temp file: {}", e),
})?;
file.write_all(params.content.as_bytes())
.await
.map_err(|e| NikaError::ToolError {
code: ToolErrorCode::WriteFailed.code(),
message: format!("Failed to write content: {}", e),
})?;
file.flush().await.map_err(|e| NikaError::ToolError {
code: ToolErrorCode::WriteFailed.code(),
message: format!("Failed to flush file: {}", e),
})?;
file.sync_all().await.map_err(|e| NikaError::ToolError {
code: ToolErrorCode::WriteFailed.code(),
message: format!("Failed to sync file: {}", e),
})?;
if let Err(e) = fs::rename(&temp_path, &path).await {
let temp_clone = temp_path.clone();
tokio::spawn(async move {
let _ = fs::remove_file(temp_clone).await;
});
return Err(NikaError::ToolError {
code: ToolErrorCode::WriteFailed.code(),
message: format!("Failed to finalize file: {}", e),
});
}
let bytes_written = params.content.len();
let lines_written = params.content.lines().count();
self.ctx
.emit(ToolEvent::FileWritten {
path: params.file_path.clone(),
bytes: bytes_written,
})
.await;
Ok(WriteResult {
path: params.file_path,
bytes_written,
lines_written,
})
}
}
#[async_trait]
impl FileTool for WriteTool {
fn name(&self) -> &'static str {
"write"
}
fn description(&self) -> &'static str {
"Create a new file with the specified content. Fails if the file already exists \
(use Edit for modifications). Creates parent directories if needed. \
Must use absolute paths within the working directory."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute path for the new file"
},
"content": {
"type": "string",
"description": "Content to write to the file"
}
},
"required": ["file_path", "content"]
})
}
async fn call(&self, params: Value) -> Result<ToolOutput, NikaError> {
let params: WriteParams =
serde_json::from_value(params).map_err(|e| NikaError::ToolError {
code: ToolErrorCode::WriteFailed.code(),
message: format!("Invalid parameters: {}", e),
})?;
let result = self.execute(params).await?;
Ok(ToolOutput::success_with_data(
format!(
"Created file: {} ({} bytes, {} lines)",
result.path, result.bytes_written, result.lines_written
),
serde_json::to_value(&result).unwrap_or_default(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
async fn setup_test() -> (TempDir, Arc<ToolContext>) {
let temp_dir = TempDir::new().unwrap();
let ctx = Arc::new(ToolContext::new(
temp_dir.path().to_path_buf(),
super::super::context::PermissionMode::YoloMode,
));
(temp_dir, ctx)
}
#[tokio::test]
async fn test_write_new_file() {
let (temp_dir, ctx) = setup_test().await;
let file_path = temp_dir
.path()
.join("new_file.txt")
.to_string_lossy()
.to_string();
let tool = WriteTool::new(ctx);
let result = tool
.execute(WriteParams {
file_path: file_path.clone(),
content: "Hello, World!\nLine 2".to_string(),
})
.await
.unwrap();
assert_eq!(result.bytes_written, 20);
assert_eq!(result.lines_written, 2);
let content = fs::read_to_string(&file_path).await.unwrap();
assert_eq!(content, "Hello, World!\nLine 2");
}
#[tokio::test]
async fn test_write_creates_parent_dirs() {
let (temp_dir, ctx) = setup_test().await;
let file_path = temp_dir
.path()
.join("nested/deep/dir/file.txt")
.to_string_lossy()
.to_string();
let tool = WriteTool::new(ctx);
let result = tool
.execute(WriteParams {
file_path: file_path.clone(),
content: "content".to_string(),
})
.await;
assert!(result.is_ok());
assert!(std::path::Path::new(&file_path).exists());
}
#[tokio::test]
async fn test_write_fails_if_exists() {
let (temp_dir, ctx) = setup_test().await;
let file_path = temp_dir
.path()
.join("existing.txt")
.to_string_lossy()
.to_string();
fs::write(&file_path, "existing content").await.unwrap();
let tool = WriteTool::new(ctx);
let result = tool
.execute(WriteParams {
file_path,
content: "new content".to_string(),
})
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("already exists"));
}
#[tokio::test]
async fn test_write_permission_denied() {
let (temp_dir, _) = setup_test().await;
let ctx = Arc::new(ToolContext::new(
temp_dir.path().to_path_buf(),
super::super::context::PermissionMode::Plan,
));
let file_path = temp_dir
.path()
.join("test.txt")
.to_string_lossy()
.to_string();
let tool = WriteTool::new(ctx);
let result = tool
.execute(WriteParams {
file_path,
content: "content".to_string(),
})
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Permission"));
}
#[tokio::test]
async fn test_write_outside_working_dir() {
let (_temp_dir, ctx) = setup_test().await;
let tool = WriteTool::new(ctx);
let result = tool
.execute(WriteParams {
file_path: "/tmp/outside.txt".to_string(),
content: "content".to_string(),
})
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("outside"));
}
#[tokio::test]
async fn test_file_tool_trait() {
let (temp_dir, ctx) = setup_test().await;
let file_path = temp_dir
.path()
.join("test.txt")
.to_string_lossy()
.to_string();
let tool = WriteTool::new(ctx);
assert_eq!(tool.name(), "write");
assert!(tool.description().contains("Create a new file"));
let result = tool
.call(json!({
"file_path": file_path,
"content": "test content"
}))
.await
.unwrap();
assert!(!result.is_error);
assert!(result.content.contains("Created file"));
}
}