Skip to main content

nika_engine/tools/
write.rs

1//! Write Tool - Create new files
2//!
3//! Atomic file creation with:
4//! - Permission checking
5//! - Fail if file exists (use Edit for modifications)
6//! - Temp file + rename pattern for safety
7
8use 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// ═══════════════════════════════════════════════════════════════════════════
21// PARAMETERS & RESULT
22// ═══════════════════════════════════════════════════════════════════════════
23
24/// Parameters for the Write tool
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct WriteParams {
27    /// Absolute path for the new file
28    pub file_path: String,
29
30    /// Content to write
31    pub content: String,
32}
33
34/// Result from writing a file
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct WriteResult {
37    /// Path of the created file
38    pub path: String,
39
40    /// Bytes written
41    pub bytes_written: usize,
42
43    /// Lines written
44    pub lines_written: usize,
45}
46
47/// Maximum write size: 10 MB
48const MAX_WRITE_SIZE: usize = 10 * 1024 * 1024;
49
50// ═══════════════════════════════════════════════════════════════════════════
51// WRITE TOOL
52// ═══════════════════════════════════════════════════════════════════════════
53
54/// Write tool for creating new files
55///
56/// # Features
57///
58/// - Atomic write (temp file + rename)
59/// - Fails if file already exists
60/// - Creates parent directories if needed
61/// - Permission checking
62pub struct WriteTool {
63    ctx: Arc<ToolContext>,
64}
65
66impl WriteTool {
67    /// Create a new Write tool
68    pub fn new(ctx: Arc<ToolContext>) -> Self {
69        Self { ctx }
70    }
71
72    /// Execute the write operation
73    pub async fn execute(&self, params: WriteParams) -> Result<WriteResult, NikaError> {
74        // Validate path
75        let path = self.ctx.validate_path(&params.file_path)?;
76
77        // Check permission
78        self.ctx.check_permission(ToolOperation::Write)?;
79
80        // Check content size limit
81        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        // Fail if file already exists (use Edit for modifications)
93        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        // Create parent directories if needed (idempotent, no TOCTOU race)
104        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        // Atomic write: temp file + rename
114        let temp_path = path.with_extension("tmp.nika");
115
116        // Write to temp file
117        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        // Ensure data hits disk before rename (durability)
137        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        // Atomic rename with async cleanup on error
143        if let Err(e) = fs::rename(&temp_path, &path).await {
144            // Async cleanup to avoid blocking the executor
145            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        // Emit event
159        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// ═══════════════════════════════════════════════════════════════════════════
224// TESTS
225// ═══════════════════════════════════════════════════════════════════════════
226
227#[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        // Verify file was created
254        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        // Create the file first
289        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        // 11MB exceeds the 10MB limit
338        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}