Skip to main content

mixtape_tools/filesystem/
write_file.rs

1use crate::filesystem::validate_path;
2use crate::prelude::*;
3use std::path::PathBuf;
4use tokio::fs::OpenOptions;
5use tokio::io::AsyncWriteExt;
6
7/// Write mode for file operations
8#[derive(Debug, Deserialize, Serialize, JsonSchema, Default)]
9#[serde(rename_all = "lowercase")]
10pub enum WriteMode {
11    /// Overwrite the file if it exists, create if it doesn't
12    #[default]
13    Rewrite,
14    /// Append to the end of the file if it exists, create if it doesn't
15    Append,
16}
17
18/// Input for writing a file
19#[derive(Debug, Deserialize, JsonSchema)]
20pub struct WriteFileInput {
21    /// Path to the file to write (relative to base path or absolute)
22    pub path: PathBuf,
23
24    /// Content to write to the file
25    pub content: String,
26
27    /// Write mode: 'rewrite' (default) or 'append'
28    #[serde(default)]
29    pub mode: WriteMode,
30}
31
32/// Tool for writing content to files
33pub struct WriteFileTool {
34    base_path: PathBuf,
35}
36
37impl Default for WriteFileTool {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl WriteFileTool {
44    /// Creates a new tool using the current working directory as the base path.
45    ///
46    /// Equivalent to `Default::default()`.
47    ///
48    /// # Panics
49    ///
50    /// Panics if the current working directory cannot be determined.
51    /// Use [`try_new`](Self::try_new) or [`with_base_path`](Self::with_base_path) instead.
52    pub fn new() -> Self {
53        Self {
54            base_path: std::env::current_dir().expect("Failed to get current working directory"),
55        }
56    }
57
58    /// Creates a new tool using the current working directory as the base path.
59    ///
60    /// Returns an error if the current working directory cannot be determined.
61    pub fn try_new() -> std::io::Result<Self> {
62        Ok(Self {
63            base_path: std::env::current_dir()?,
64        })
65    }
66
67    /// Creates a tool with a custom base directory.
68    ///
69    /// All file operations will be constrained to this directory.
70    pub fn with_base_path(base_path: PathBuf) -> Self {
71        Self { base_path }
72    }
73}
74
75impl Tool for WriteFileTool {
76    type Input = WriteFileInput;
77
78    fn name(&self) -> &str {
79        "write_file"
80    }
81
82    fn description(&self) -> &str {
83        "Write content to a file. Can either overwrite the file or append to it."
84    }
85
86    async fn execute(&self, input: Self::Input) -> std::result::Result<ToolResult, ToolError> {
87        // Validate path is within base directory
88        let validated_path = validate_path(&self.base_path, &input.path)?;
89
90        // Create parent directories if they don't exist
91        if let Some(parent) = validated_path.parent() {
92            if !parent.exists() {
93                tokio::fs::create_dir_all(parent).await.map_err(|e| {
94                    ToolError::from(format!("Failed to create parent directories: {}", e))
95                })?;
96            }
97        }
98
99        let mut file = match input.mode {
100            WriteMode::Rewrite => OpenOptions::new()
101                .write(true)
102                .create(true)
103                .truncate(true)
104                .open(&validated_path)
105                .await
106                .map_err(|e| ToolError::from(format!("Failed to open file for writing: {}", e)))?,
107
108            WriteMode::Append => OpenOptions::new()
109                .write(true)
110                .create(true)
111                .append(true)
112                .open(&validated_path)
113                .await
114                .map_err(|e| {
115                    ToolError::from(format!("Failed to open file for appending: {}", e))
116                })?,
117        };
118
119        file.write_all(input.content.as_bytes())
120            .await
121            .map_err(|e| ToolError::from(format!("Failed to write to file: {}", e)))?;
122
123        file.flush()
124            .await
125            .map_err(|e| ToolError::from(format!("Failed to flush file: {}", e)))?;
126
127        let bytes_written = input.content.len();
128        let lines_written = input.content.lines().count();
129
130        Ok(format!(
131            "Successfully wrote {} bytes ({} lines) to {}",
132            bytes_written,
133            lines_written,
134            input.path.display()
135        )
136        .into())
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use tempfile::TempDir;
144    use tokio::fs;
145
146    #[test]
147    fn test_tool_metadata() {
148        let tool: WriteFileTool = Default::default();
149        assert_eq!(tool.name(), "write_file");
150        assert!(!tool.description().is_empty());
151    }
152
153    #[test]
154    fn test_try_new() {
155        let tool = WriteFileTool::try_new();
156        assert!(tool.is_ok());
157    }
158
159    #[test]
160    fn test_format_methods() {
161        let tool = WriteFileTool::new();
162        let params = serde_json::json!({"path": "test.txt", "content": "hello"});
163
164        assert!(!tool.format_input_plain(&params).is_empty());
165        assert!(!tool.format_input_ansi(&params).is_empty());
166        assert!(!tool.format_input_markdown(&params).is_empty());
167
168        let result = ToolResult::from("Successfully wrote");
169        assert!(!tool.format_output_plain(&result).is_empty());
170        assert!(!tool.format_output_ansi(&result).is_empty());
171        assert!(!tool.format_output_markdown(&result).is_empty());
172    }
173
174    #[tokio::test]
175    async fn test_write_file_create() {
176        let temp_dir = TempDir::new().unwrap();
177        let tool = WriteFileTool::with_base_path(temp_dir.path().to_path_buf());
178
179        let input = WriteFileInput {
180            path: PathBuf::from("test.txt"),
181            content: "Hello, World!".to_string(),
182            mode: WriteMode::Rewrite,
183        };
184
185        let result = tool.execute(input).await.unwrap();
186        assert!(result.as_text().contains("13 bytes"));
187
188        let content = fs::read_to_string(temp_dir.path().join("test.txt"))
189            .await
190            .unwrap();
191        assert_eq!(content, "Hello, World!");
192    }
193
194    #[tokio::test]
195    async fn test_write_file_overwrite() {
196        let temp_dir = TempDir::new().unwrap();
197        let file_path = temp_dir.path().join("test.txt");
198        fs::write(&file_path, "Old content").await.unwrap();
199
200        let tool = WriteFileTool::with_base_path(temp_dir.path().to_path_buf());
201        let input = WriteFileInput {
202            path: PathBuf::from("test.txt"),
203            content: "New content".to_string(),
204            mode: WriteMode::Rewrite,
205        };
206
207        tool.execute(input).await.unwrap();
208
209        let content = fs::read_to_string(&file_path).await.unwrap();
210        assert_eq!(content, "New content");
211    }
212
213    #[tokio::test]
214    async fn test_write_file_append() {
215        let temp_dir = TempDir::new().unwrap();
216        let file_path = temp_dir.path().join("test.txt");
217        fs::write(&file_path, "Line 1\n").await.unwrap();
218
219        let tool = WriteFileTool::with_base_path(temp_dir.path().to_path_buf());
220        let input = WriteFileInput {
221            path: PathBuf::from("test.txt"),
222            content: "Line 2\n".to_string(),
223            mode: WriteMode::Append,
224        };
225
226        tool.execute(input).await.unwrap();
227
228        let content = fs::read_to_string(&file_path).await.unwrap();
229        assert_eq!(content, "Line 1\nLine 2\n");
230    }
231
232    // ===== Edge Case Tests =====
233
234    #[tokio::test]
235    async fn test_write_file_utf8_characters() {
236        let temp_dir = TempDir::new().unwrap();
237        let tool = WriteFileTool::with_base_path(temp_dir.path().to_path_buf());
238
239        let utf8_content = "Hello 世界! Ümläüts: äöü 🎵";
240        let input = WriteFileInput {
241            path: PathBuf::from("utf8.txt"),
242            content: utf8_content.to_string(),
243            mode: WriteMode::Rewrite,
244        };
245
246        tool.execute(input).await.unwrap();
247
248        let content = fs::read_to_string(temp_dir.path().join("utf8.txt"))
249            .await
250            .unwrap();
251        assert_eq!(content, utf8_content);
252    }
253
254    #[tokio::test]
255    async fn test_write_file_empty_content() {
256        let temp_dir = TempDir::new().unwrap();
257        let tool = WriteFileTool::with_base_path(temp_dir.path().to_path_buf());
258
259        let input = WriteFileInput {
260            path: PathBuf::from("empty.txt"),
261            content: String::new(),
262            mode: WriteMode::Rewrite,
263        };
264
265        let result = tool.execute(input).await.unwrap();
266        assert!(result.as_text().contains("0 bytes"));
267
268        let content = fs::read_to_string(temp_dir.path().join("empty.txt"))
269            .await
270            .unwrap();
271        assert_eq!(content, "");
272    }
273
274    #[tokio::test]
275    async fn test_write_file_preserves_crlf() {
276        let temp_dir = TempDir::new().unwrap();
277        let tool = WriteFileTool::with_base_path(temp_dir.path().to_path_buf());
278
279        let crlf_content = "Line 1\r\nLine 2\r\nLine 3\r\n";
280        let input = WriteFileInput {
281            path: PathBuf::from("crlf.txt"),
282            content: crlf_content.to_string(),
283            mode: WriteMode::Rewrite,
284        };
285
286        tool.execute(input).await.unwrap();
287
288        // Read as bytes to verify exact line endings
289        let bytes = fs::read(temp_dir.path().join("crlf.txt")).await.unwrap();
290        let content = String::from_utf8(bytes).unwrap();
291        assert_eq!(content, crlf_content);
292        assert!(content.contains("\r\n"));
293    }
294
295    #[tokio::test]
296    async fn test_write_file_mixed_line_endings() {
297        let temp_dir = TempDir::new().unwrap();
298        let tool = WriteFileTool::with_base_path(temp_dir.path().to_path_buf());
299
300        let mixed_content = "Line 1\nLine 2\r\nLine 3\rLine 4";
301        let input = WriteFileInput {
302            path: PathBuf::from("mixed.txt"),
303            content: mixed_content.to_string(),
304            mode: WriteMode::Rewrite,
305        };
306
307        tool.execute(input).await.unwrap();
308
309        let bytes = fs::read(temp_dir.path().join("mixed.txt")).await.unwrap();
310        let content = String::from_utf8(bytes).unwrap();
311        assert_eq!(content, mixed_content);
312    }
313
314    #[tokio::test]
315    async fn test_write_file_large_content() {
316        let temp_dir = TempDir::new().unwrap();
317        let tool = WriteFileTool::with_base_path(temp_dir.path().to_path_buf());
318
319        // Create 1000 lines of content
320        let large_content = (0..1000)
321            .map(|i| format!("Line {} with some content", i))
322            .collect::<Vec<_>>()
323            .join("\n");
324
325        let input = WriteFileInput {
326            path: PathBuf::from("large.txt"),
327            content: large_content.clone(),
328            mode: WriteMode::Rewrite,
329        };
330
331        tool.execute(input).await.unwrap();
332
333        let content = fs::read_to_string(temp_dir.path().join("large.txt"))
334            .await
335            .unwrap();
336        assert_eq!(content, large_content);
337        assert_eq!(content.lines().count(), 1000);
338    }
339
340    // ===== Error Path Tests =====
341
342    #[tokio::test]
343    async fn test_write_file_rejects_path_traversal() {
344        let temp_dir = TempDir::new().unwrap();
345        let tool = WriteFileTool::with_base_path(temp_dir.path().to_path_buf());
346
347        let input = WriteFileInput {
348            path: PathBuf::from("../../../tmp/evil.txt"),
349            content: "malicious".to_string(),
350            mode: WriteMode::Rewrite,
351        };
352
353        let result = tool.execute(input).await;
354        assert!(result.is_err(), "Should reject path traversal");
355    }
356
357    #[tokio::test]
358    async fn test_write_file_creates_parent_directories() {
359        let temp_dir = TempDir::new().unwrap();
360        let tool = WriteFileTool::with_base_path(temp_dir.path().to_path_buf());
361
362        // Parent directories don't exist yet
363        let input = WriteFileInput {
364            path: PathBuf::from("nonexistent/subdir/file.txt"),
365            content: "content".to_string(),
366            mode: WriteMode::Rewrite,
367        };
368
369        let result = tool.execute(input).await;
370        assert!(
371            result.is_ok(),
372            "Should create parent directories automatically"
373        );
374
375        // Verify the file was created with correct content
376        let file_path = temp_dir.path().join("nonexistent/subdir/file.txt");
377        assert!(file_path.exists(), "File should exist");
378        let content = fs::read_to_string(&file_path).await.unwrap();
379        assert_eq!(content, "content");
380    }
381}