1use std::sync::Arc;
9
10use async_trait::async_trait;
11use serde::{Deserialize, Serialize};
12use serde_json::{json, Value};
13use tokio::fs;
14use tokio::io::AsyncWriteExt;
15
16use super::context::{ToolContext, ToolEvent, ToolOperation};
17use super::{FileTool, ToolErrorCode, ToolOutput};
18use crate::error::NikaError;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct WriteParams {
27 pub file_path: String,
29
30 pub content: String,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct WriteResult {
37 pub path: String,
39
40 pub bytes_written: usize,
42
43 pub lines_written: usize,
45}
46
47const MAX_WRITE_SIZE: usize = 10 * 1024 * 1024;
49
50pub struct WriteTool {
63 ctx: Arc<ToolContext>,
64}
65
66impl WriteTool {
67 pub fn new(ctx: Arc<ToolContext>) -> Self {
69 Self { ctx }
70 }
71
72 pub async fn execute(&self, params: WriteParams) -> Result<WriteResult, NikaError> {
74 let path = self.ctx.validate_path(¶ms.file_path)?;
76
77 self.ctx.check_permission(ToolOperation::Write)?;
79
80 if params.content.len() > MAX_WRITE_SIZE {
82 return Err(NikaError::ToolError {
83 code: ToolErrorCode::WriteFailed.code(),
84 message: format!(
85 "Content size ({} bytes) exceeds maximum write size ({} bytes)",
86 params.content.len(),
87 MAX_WRITE_SIZE
88 ),
89 });
90 }
91
92 if path.exists() {
94 return Err(NikaError::ToolError {
95 code: ToolErrorCode::FileAlreadyExists.code(),
96 message: format!(
97 "File already exists: {}. Use the Edit tool to modify existing files.",
98 params.file_path
99 ),
100 });
101 }
102
103 if let Some(parent) = path.parent() {
105 fs::create_dir_all(parent)
106 .await
107 .map_err(|e| NikaError::ToolError {
108 code: ToolErrorCode::WriteFailed.code(),
109 message: format!("Failed to create parent directories: {}", e),
110 })?;
111 }
112
113 let temp_path = path.with_extension("tmp.nika");
115
116 let mut file = fs::File::create(&temp_path)
118 .await
119 .map_err(|e| NikaError::ToolError {
120 code: ToolErrorCode::WriteFailed.code(),
121 message: format!("Failed to create temp file: {}", e),
122 })?;
123
124 file.write_all(params.content.as_bytes())
125 .await
126 .map_err(|e| NikaError::ToolError {
127 code: ToolErrorCode::WriteFailed.code(),
128 message: format!("Failed to write content: {}", e),
129 })?;
130
131 file.flush().await.map_err(|e| NikaError::ToolError {
132 code: ToolErrorCode::WriteFailed.code(),
133 message: format!("Failed to flush file: {}", e),
134 })?;
135
136 file.sync_all().await.map_err(|e| NikaError::ToolError {
138 code: ToolErrorCode::WriteFailed.code(),
139 message: format!("Failed to sync file: {}", e),
140 })?;
141
142 if let Err(e) = fs::rename(&temp_path, &path).await {
144 let temp_clone = temp_path.clone();
146 tokio::spawn(async move {
147 let _ = fs::remove_file(temp_clone).await;
148 });
149 return Err(NikaError::ToolError {
150 code: ToolErrorCode::WriteFailed.code(),
151 message: format!("Failed to finalize file: {}", e),
152 });
153 }
154
155 let bytes_written = params.content.len();
156 let lines_written = params.content.lines().count();
157
158 self.ctx
160 .emit(ToolEvent::FileWritten {
161 path: params.file_path.clone(),
162 bytes: bytes_written,
163 })
164 .await;
165
166 Ok(WriteResult {
167 path: params.file_path,
168 bytes_written,
169 lines_written,
170 })
171 }
172}
173
174#[async_trait]
175impl FileTool for WriteTool {
176 fn name(&self) -> &'static str {
177 "write"
178 }
179
180 fn description(&self) -> &'static str {
181 "Create a new file with the specified content. Fails if the file already exists \
182 (use Edit for modifications). Creates parent directories if needed. \
183 Must use absolute paths within the working directory."
184 }
185
186 fn parameters_schema(&self) -> Value {
187 json!({
188 "type": "object",
189 "properties": {
190 "file_path": {
191 "type": "string",
192 "description": "Absolute path for the new file"
193 },
194 "content": {
195 "type": "string",
196 "description": "Content to write to the file"
197 }
198 },
199 "required": ["file_path", "content"],
200 "additionalProperties": false
201 })
202 }
203
204 async fn call(&self, params: Value) -> Result<ToolOutput, NikaError> {
205 let params: WriteParams =
206 serde_json::from_value(params).map_err(|e| NikaError::ToolError {
207 code: ToolErrorCode::WriteFailed.code(),
208 message: format!("Invalid parameters: {}", e),
209 })?;
210
211 let result = self.execute(params).await?;
212
213 Ok(ToolOutput::success_with_data(
214 format!(
215 "Created file: {} ({} bytes, {} lines)",
216 result.path, result.bytes_written, result.lines_written
217 ),
218 serde_json::to_value(&result).unwrap_or_default(),
219 ))
220 }
221}
222
223#[cfg(test)]
228mod tests {
229 use super::*;
230 use crate::tools::context::testing::setup_test;
231
232 #[tokio::test]
233 async fn test_write_new_file() {
234 let (temp_dir, ctx) = setup_test().await;
235 let file_path = temp_dir
236 .path()
237 .join("new_file.txt")
238 .to_string_lossy()
239 .to_string();
240
241 let tool = WriteTool::new(ctx);
242 let result = tool
243 .execute(WriteParams {
244 file_path: file_path.clone(),
245 content: "Hello, World!\nLine 2".to_string(),
246 })
247 .await
248 .unwrap();
249
250 assert_eq!(result.bytes_written, 20);
251 assert_eq!(result.lines_written, 2);
252
253 let content = fs::read_to_string(&file_path).await.unwrap();
255 assert_eq!(content, "Hello, World!\nLine 2");
256 }
257
258 #[tokio::test]
259 async fn test_write_creates_parent_dirs() {
260 let (temp_dir, ctx) = setup_test().await;
261 let file_path = temp_dir
262 .path()
263 .join("nested/deep/dir/file.txt")
264 .to_string_lossy()
265 .to_string();
266
267 let tool = WriteTool::new(ctx);
268 let result = tool
269 .execute(WriteParams {
270 file_path: file_path.clone(),
271 content: "content".to_string(),
272 })
273 .await;
274
275 assert!(result.is_ok());
276 assert!(std::path::Path::new(&file_path).exists());
277 }
278
279 #[tokio::test]
280 async fn test_write_fails_if_exists() {
281 let (temp_dir, ctx) = setup_test().await;
282 let file_path = temp_dir
283 .path()
284 .join("existing.txt")
285 .to_string_lossy()
286 .to_string();
287
288 fs::write(&file_path, "existing content").await.unwrap();
290
291 let tool = WriteTool::new(ctx);
292 let result = tool
293 .execute(WriteParams {
294 file_path,
295 content: "new content".to_string(),
296 })
297 .await;
298
299 assert!(result.is_err());
300 assert!(result.unwrap_err().to_string().contains("already exists"));
301 }
302
303 #[tokio::test]
304 async fn test_write_permission_denied() {
305 let (temp_dir, _) = setup_test().await;
306 let ctx = Arc::new(ToolContext::new(
307 temp_dir.path().to_path_buf(),
308 super::super::context::PermissionMode::Plan,
309 ));
310 let file_path = temp_dir
311 .path()
312 .join("test.txt")
313 .to_string_lossy()
314 .to_string();
315
316 let tool = WriteTool::new(ctx);
317 let result = tool
318 .execute(WriteParams {
319 file_path,
320 content: "content".to_string(),
321 })
322 .await;
323
324 assert!(result.is_err());
325 assert!(result.unwrap_err().to_string().contains("Permission"));
326 }
327
328 #[tokio::test]
329 async fn test_write_rejects_oversized_content() {
330 let (temp_dir, ctx) = setup_test().await;
331 let file_path = temp_dir
332 .path()
333 .join("huge.txt")
334 .to_string_lossy()
335 .to_string();
336
337 let oversized = "x".repeat(11 * 1024 * 1024);
339
340 let tool = WriteTool::new(ctx);
341 let result = tool
342 .execute(WriteParams {
343 file_path,
344 content: oversized,
345 })
346 .await;
347
348 assert!(result.is_err(), "oversized content should be rejected");
349 let err = result.unwrap_err();
350 assert!(
351 err.to_string().contains("exceeds"),
352 "error should mention size limit, got: {}",
353 err
354 );
355 }
356
357 #[tokio::test]
358 async fn test_write_outside_working_dir() {
359 let (_temp_dir, ctx) = setup_test().await;
360
361 let tool = WriteTool::new(ctx);
362 let result = tool
363 .execute(WriteParams {
364 file_path: "/tmp/outside.txt".to_string(),
365 content: "content".to_string(),
366 })
367 .await;
368
369 assert!(result.is_err());
370 assert!(result.unwrap_err().to_string().contains("outside"));
371 }
372
373 #[tokio::test]
374 async fn test_file_tool_trait() {
375 let (temp_dir, ctx) = setup_test().await;
376 let file_path = temp_dir
377 .path()
378 .join("test.txt")
379 .to_string_lossy()
380 .to_string();
381
382 let tool = WriteTool::new(ctx);
383
384 assert_eq!(tool.name(), "write");
385 assert!(tool.description().contains("Create a new file"));
386
387 let result = tool
388 .call(json!({
389 "file_path": file_path,
390 "content": "test content"
391 }))
392 .await
393 .unwrap();
394
395 assert!(!result.is_error);
396 assert!(result.content.contains("Created file"));
397 }
398}