Skip to main content

aster/tools/file/
write.rs

1//! Write Tool Implementation
2//!
3//! This module implements the `WriteTool` for writing files with:
4//! - File creation and overwriting
5//! - Read-before-overwrite validation
6//! - Directory creation
7//!
8//! Requirements: 4.6
9
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use async_trait::async_trait;
14use tracing::{debug, warn};
15
16use super::{compute_content_hash, FileReadRecord, SharedFileReadHistory};
17use crate::tools::base::{PermissionCheckResult, Tool};
18use crate::tools::context::{ToolContext, ToolOptions, ToolResult};
19use crate::tools::error::ToolError;
20
21/// Maximum file size for writing (50MB)
22pub const MAX_WRITE_SIZE: usize = 50 * 1024 * 1024;
23
24/// Write Tool for writing files
25///
26/// Supports:
27/// - Creating new files
28/// - Overwriting existing files (with read validation)
29/// - Creating parent directories
30///
31/// Requirements: 4.6
32#[derive(Debug)]
33pub struct WriteTool {
34    /// Shared file read history
35    read_history: SharedFileReadHistory,
36    /// Whether to require read before overwrite
37    require_read_before_overwrite: bool,
38}
39
40impl WriteTool {
41    /// Create a new WriteTool with shared history
42    pub fn new(read_history: SharedFileReadHistory) -> Self {
43        Self {
44            read_history,
45            require_read_before_overwrite: true,
46        }
47    }
48
49    /// Set whether to require read before overwrite
50    pub fn with_require_read_before_overwrite(mut self, require: bool) -> Self {
51        self.require_read_before_overwrite = require;
52        self
53    }
54
55    /// Get the shared read history
56    pub fn read_history(&self) -> &SharedFileReadHistory {
57        &self.read_history
58    }
59
60    /// Resolve a path relative to the working directory
61    fn resolve_path(&self, path: &Path, context: &ToolContext) -> PathBuf {
62        if path.is_absolute() {
63            path.to_path_buf()
64        } else {
65            context.working_directory.join(path)
66        }
67    }
68}
69
70// =============================================================================
71// File Writing Implementation (Requirements: 4.6)
72// =============================================================================
73
74impl WriteTool {
75    /// Write content to a file
76    ///
77    /// If the file exists and require_read_before_overwrite is true,
78    /// the file must have been read first.
79    ///
80    /// Requirements: 4.6
81    pub async fn write_file(
82        &self,
83        path: &Path,
84        content: &str,
85        context: &ToolContext,
86    ) -> Result<ToolResult, ToolError> {
87        let full_path = self.resolve_path(path, context);
88
89        // Check content size
90        if content.len() > MAX_WRITE_SIZE {
91            return Err(ToolError::execution_failed(format!(
92                "Content too large: {} bytes (max: {} bytes)",
93                content.len(),
94                MAX_WRITE_SIZE
95            )));
96        }
97
98        // Check if file exists and validate read history
99        if full_path.exists() && self.require_read_before_overwrite {
100            let history = self.read_history.read().unwrap();
101            if !history.has_read(&full_path) {
102                return Err(ToolError::execution_failed(format!(
103                    "File exists but has not been read: {}. \
104                     Read the file first before overwriting.",
105                    full_path.display()
106                )));
107            }
108
109            // Check for external modifications
110            if let Ok(metadata) = fs::metadata(&full_path) {
111                if let Ok(mtime) = metadata.modified() {
112                    if let Some(true) = history.is_file_modified(&full_path, mtime) {
113                        warn!(
114                            "File has been modified externally since last read: {}",
115                            full_path.display()
116                        );
117                        return Err(ToolError::execution_failed(format!(
118                            "File has been modified externally since last read: {}. \
119                             Read the file again before overwriting.",
120                            full_path.display()
121                        )));
122                    }
123                }
124            }
125        }
126
127        // Create parent directories if needed
128        if let Some(parent) = full_path.parent() {
129            if !parent.exists() {
130                fs::create_dir_all(parent)?;
131                debug!("Created parent directories: {}", parent.display());
132            }
133        }
134
135        // Write the file
136        fs::write(&full_path, content)?;
137
138        // Update read history with new content
139        let content_bytes = content.as_bytes();
140        let hash = compute_content_hash(content_bytes);
141        let metadata = fs::metadata(&full_path)?;
142        let mtime = metadata.modified().ok();
143
144        let mut record = FileReadRecord::new(full_path.clone(), hash, metadata.len())
145            .with_line_count(content.lines().count());
146
147        if let Some(mt) = mtime {
148            record = record.with_mtime(mt);
149        }
150
151        self.read_history.write().unwrap().record_read(record);
152
153        debug!(
154            "Wrote file: {} ({} bytes)",
155            full_path.display(),
156            content.len()
157        );
158
159        Ok(ToolResult::success(format!(
160            "Successfully wrote {} bytes to {}",
161            content.len(),
162            full_path.display()
163        ))
164        .with_metadata("path", serde_json::json!(full_path.to_string_lossy()))
165        .with_metadata("size", serde_json::json!(content.len())))
166    }
167
168    /// Check if a file can be written (exists and has been read, or doesn't exist)
169    pub fn can_write(&self, path: &Path, context: &ToolContext) -> bool {
170        let full_path = self.resolve_path(path, context);
171
172        if !full_path.exists() {
173            return true;
174        }
175
176        if !self.require_read_before_overwrite {
177            return true;
178        }
179
180        self.read_history.read().unwrap().has_read(&full_path)
181    }
182}
183
184// =============================================================================
185// Tool Trait Implementation
186// =============================================================================
187
188#[async_trait]
189impl Tool for WriteTool {
190    fn name(&self) -> &str {
191        "write"
192    }
193
194    fn description(&self) -> &str {
195        "Write content to a file. Creates parent directories if needed. \
196         For existing files, the file must be read first before overwriting \
197         to prevent accidental data loss."
198    }
199
200    fn input_schema(&self) -> serde_json::Value {
201        serde_json::json!({
202            "type": "object",
203            "properties": {
204                "path": {
205                    "type": "string",
206                    "description": "Path to the file to write (relative to working directory or absolute)"
207                },
208                "content": {
209                    "type": "string",
210                    "description": "Content to write to the file"
211                }
212            },
213            "required": ["path", "content"]
214        })
215    }
216
217    async fn execute(
218        &self,
219        params: serde_json::Value,
220        context: &ToolContext,
221    ) -> Result<ToolResult, ToolError> {
222        // Check for cancellation
223        if context.is_cancelled() {
224            return Err(ToolError::Cancelled);
225        }
226
227        // Extract path parameter
228        let path_str = params
229            .get("path")
230            .and_then(|v| v.as_str())
231            .ok_or_else(|| ToolError::invalid_params("Missing required parameter: path"))?;
232
233        // Extract content parameter
234        let content = params
235            .get("content")
236            .and_then(|v| v.as_str())
237            .ok_or_else(|| ToolError::invalid_params("Missing required parameter: content"))?;
238
239        let path = Path::new(path_str);
240        self.write_file(path, content, context).await
241    }
242
243    async fn check_permissions(
244        &self,
245        params: &serde_json::Value,
246        context: &ToolContext,
247    ) -> PermissionCheckResult {
248        // Extract path for permission check
249        let path_str = match params.get("path").and_then(|v| v.as_str()) {
250            Some(p) => p,
251            None => return PermissionCheckResult::deny("Missing path parameter"),
252        };
253
254        let path = Path::new(path_str);
255        let full_path = self.resolve_path(path, context);
256
257        // Check if file exists and hasn't been read
258        if full_path.exists() && self.require_read_before_overwrite {
259            let history = self.read_history.read().unwrap();
260            if !history.has_read(&full_path) {
261                return PermissionCheckResult::ask(format!(
262                    "File '{}' exists but has not been read. \
263                     Do you want to overwrite it without reading first?",
264                    full_path.display()
265                ));
266            }
267        }
268
269        debug!("Permission check for write: {}", full_path.display());
270        PermissionCheckResult::allow()
271    }
272
273    fn options(&self) -> ToolOptions {
274        ToolOptions::new()
275            .with_max_retries(1)
276            .with_base_timeout(std::time::Duration::from_secs(30))
277    }
278}
279
280// =============================================================================
281// Unit Tests
282// =============================================================================
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use tempfile::TempDir;
288
289    fn create_test_context(dir: &Path) -> ToolContext {
290        ToolContext::new(dir.to_path_buf())
291            .with_session_id("test-session")
292            .with_user("test-user")
293    }
294
295    fn create_write_tool() -> WriteTool {
296        WriteTool::new(super::super::create_shared_history())
297    }
298
299    #[tokio::test]
300    async fn test_write_new_file() {
301        let temp_dir = TempDir::new().unwrap();
302        let file_path = temp_dir.path().join("new_file.txt");
303
304        let tool = create_write_tool();
305        let context = create_test_context(temp_dir.path());
306
307        let result = tool
308            .write_file(&file_path, "Hello, World!", &context)
309            .await
310            .unwrap();
311
312        assert!(result.is_success());
313        assert!(file_path.exists());
314        assert_eq!(fs::read_to_string(&file_path).unwrap(), "Hello, World!");
315    }
316
317    #[tokio::test]
318    async fn test_write_creates_parent_directories() {
319        let temp_dir = TempDir::new().unwrap();
320        let file_path = temp_dir.path().join("subdir/nested/file.txt");
321
322        let tool = create_write_tool();
323        let context = create_test_context(temp_dir.path());
324
325        let result = tool
326            .write_file(&file_path, "Nested content", &context)
327            .await
328            .unwrap();
329
330        assert!(result.is_success());
331        assert!(file_path.exists());
332        assert_eq!(fs::read_to_string(&file_path).unwrap(), "Nested content");
333    }
334
335    #[tokio::test]
336    async fn test_write_existing_file_without_read() {
337        let temp_dir = TempDir::new().unwrap();
338        let file_path = temp_dir.path().join("existing.txt");
339
340        // Create existing file
341        fs::write(&file_path, "Original content").unwrap();
342
343        let tool = create_write_tool();
344        let context = create_test_context(temp_dir.path());
345
346        // Try to write without reading first
347        let result = tool.write_file(&file_path, "New content", &context).await;
348
349        assert!(result.is_err());
350        // Original content should be preserved
351        assert_eq!(fs::read_to_string(&file_path).unwrap(), "Original content");
352    }
353
354    #[tokio::test]
355    async fn test_write_existing_file_after_read() {
356        let temp_dir = TempDir::new().unwrap();
357        let file_path = temp_dir.path().join("existing.txt");
358
359        // Create existing file
360        fs::write(&file_path, "Original content").unwrap();
361
362        let history = super::super::create_shared_history();
363        let tool = WriteTool::new(history.clone());
364        let context = create_test_context(temp_dir.path());
365
366        // Simulate reading the file first
367        let content = fs::read(&file_path).unwrap();
368        let metadata = fs::metadata(&file_path).unwrap();
369        let hash = compute_content_hash(&content);
370        let mut record = FileReadRecord::new(file_path.clone(), hash, metadata.len());
371        if let Ok(mtime) = metadata.modified() {
372            record = record.with_mtime(mtime);
373        }
374        history.write().unwrap().record_read(record);
375
376        // Now write should succeed
377        let result = tool
378            .write_file(&file_path, "New content", &context)
379            .await
380            .unwrap();
381
382        assert!(result.is_success());
383        assert_eq!(fs::read_to_string(&file_path).unwrap(), "New content");
384    }
385
386    #[tokio::test]
387    async fn test_write_without_read_requirement() {
388        let temp_dir = TempDir::new().unwrap();
389        let file_path = temp_dir.path().join("existing.txt");
390
391        // Create existing file
392        fs::write(&file_path, "Original content").unwrap();
393
394        let tool = create_write_tool().with_require_read_before_overwrite(false);
395        let context = create_test_context(temp_dir.path());
396
397        // Write without reading first should succeed
398        let result = tool
399            .write_file(&file_path, "New content", &context)
400            .await
401            .unwrap();
402
403        assert!(result.is_success());
404        assert_eq!(fs::read_to_string(&file_path).unwrap(), "New content");
405    }
406
407    #[tokio::test]
408    async fn test_write_content_too_large() {
409        let temp_dir = TempDir::new().unwrap();
410        let file_path = temp_dir.path().join("large.txt");
411
412        let tool = create_write_tool();
413        let context = create_test_context(temp_dir.path());
414
415        // Create content larger than MAX_WRITE_SIZE
416        let large_content = "x".repeat(MAX_WRITE_SIZE + 1);
417
418        let result = tool.write_file(&file_path, &large_content, &context).await;
419
420        assert!(result.is_err());
421    }
422
423    #[tokio::test]
424    async fn test_can_write_new_file() {
425        let temp_dir = TempDir::new().unwrap();
426        let file_path = temp_dir.path().join("new.txt");
427
428        let tool = create_write_tool();
429        let context = create_test_context(temp_dir.path());
430
431        assert!(tool.can_write(&file_path, &context));
432    }
433
434    #[tokio::test]
435    async fn test_can_write_existing_file_without_read() {
436        let temp_dir = TempDir::new().unwrap();
437        let file_path = temp_dir.path().join("existing.txt");
438        fs::write(&file_path, "content").unwrap();
439
440        let tool = create_write_tool();
441        let context = create_test_context(temp_dir.path());
442
443        assert!(!tool.can_write(&file_path, &context));
444    }
445
446    #[tokio::test]
447    async fn test_tool_execute() {
448        let temp_dir = TempDir::new().unwrap();
449        let file_path = temp_dir.path().join("test.txt");
450
451        let tool = create_write_tool();
452        let context = create_test_context(temp_dir.path());
453        let params = serde_json::json!({
454            "path": file_path.to_str().unwrap(),
455            "content": "Test content"
456        });
457
458        let result = tool.execute(params, &context).await.unwrap();
459
460        assert!(result.is_success());
461        assert!(file_path.exists());
462    }
463
464    #[tokio::test]
465    async fn test_tool_execute_missing_path() {
466        let temp_dir = TempDir::new().unwrap();
467        let tool = create_write_tool();
468        let context = create_test_context(temp_dir.path());
469        let params = serde_json::json!({
470            "content": "Test content"
471        });
472
473        let result = tool.execute(params, &context).await;
474        assert!(result.is_err());
475        assert!(matches!(result.unwrap_err(), ToolError::InvalidParams(_)));
476    }
477
478    #[tokio::test]
479    async fn test_tool_execute_missing_content() {
480        let temp_dir = TempDir::new().unwrap();
481        let tool = create_write_tool();
482        let context = create_test_context(temp_dir.path());
483        let params = serde_json::json!({
484            "path": "test.txt"
485        });
486
487        let result = tool.execute(params, &context).await;
488        assert!(result.is_err());
489        assert!(matches!(result.unwrap_err(), ToolError::InvalidParams(_)));
490    }
491
492    #[test]
493    fn test_tool_name() {
494        let tool = create_write_tool();
495        assert_eq!(tool.name(), "write");
496    }
497
498    #[test]
499    fn test_tool_description() {
500        let tool = create_write_tool();
501        assert!(!tool.description().is_empty());
502        assert!(tool.description().contains("Write"));
503    }
504
505    #[test]
506    fn test_tool_input_schema() {
507        let tool = create_write_tool();
508        let schema = tool.input_schema();
509        assert_eq!(schema["type"], "object");
510        assert!(schema["properties"]["path"].is_object());
511        assert!(schema["properties"]["content"].is_object());
512    }
513
514    #[tokio::test]
515    async fn test_check_permissions_new_file() {
516        let temp_dir = TempDir::new().unwrap();
517        let tool = create_write_tool();
518        let context = create_test_context(temp_dir.path());
519        let params = serde_json::json!({
520            "path": "new_file.txt",
521            "content": "content"
522        });
523
524        let result = tool.check_permissions(&params, &context).await;
525        assert!(result.is_allowed());
526    }
527
528    #[tokio::test]
529    async fn test_check_permissions_existing_file_not_read() {
530        let temp_dir = TempDir::new().unwrap();
531        let file_path = temp_dir.path().join("existing.txt");
532        fs::write(&file_path, "content").unwrap();
533
534        let tool = create_write_tool();
535        let context = create_test_context(temp_dir.path());
536        let params = serde_json::json!({
537            "path": file_path.to_str().unwrap(),
538            "content": "new content"
539        });
540
541        let result = tool.check_permissions(&params, &context).await;
542        assert!(result.requires_confirmation());
543    }
544
545    #[tokio::test]
546    async fn test_check_permissions_missing_path() {
547        let temp_dir = TempDir::new().unwrap();
548        let tool = create_write_tool();
549        let context = create_test_context(temp_dir.path());
550        let params = serde_json::json!({});
551
552        let result = tool.check_permissions(&params, &context).await;
553        assert!(result.is_denied());
554    }
555
556    #[tokio::test]
557    async fn test_write_updates_read_history() {
558        let temp_dir = TempDir::new().unwrap();
559        let file_path = temp_dir.path().join("new.txt");
560
561        let tool = create_write_tool();
562        let context = create_test_context(temp_dir.path());
563
564        tool.write_file(&file_path, "content", &context)
565            .await
566            .unwrap();
567
568        // After writing, the file should be in read history
569        assert!(tool.read_history.read().unwrap().has_read(&file_path));
570    }
571}