mixtape_tools/filesystem/
write_file.rs1use crate::filesystem::validate_path;
2use crate::prelude::*;
3use std::path::PathBuf;
4use tokio::fs::OpenOptions;
5use tokio::io::AsyncWriteExt;
6
7#[derive(Debug, Deserialize, Serialize, JsonSchema, Default)]
9#[serde(rename_all = "lowercase")]
10pub enum WriteMode {
11 #[default]
13 Rewrite,
14 Append,
16}
17
18#[derive(Debug, Deserialize, JsonSchema)]
20pub struct WriteFileInput {
21 pub path: PathBuf,
23
24 pub content: String,
26
27 #[serde(default)]
29 pub mode: WriteMode,
30}
31
32pub 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 pub fn new() -> Self {
53 Self {
54 base_path: std::env::current_dir().expect("Failed to get current working directory"),
55 }
56 }
57
58 pub fn try_new() -> std::io::Result<Self> {
62 Ok(Self {
63 base_path: std::env::current_dir()?,
64 })
65 }
66
67 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 let validated_path = validate_path(&self.base_path, &input.path)?;
89
90 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(¶ms).is_empty());
165 assert!(!tool.format_input_ansi(¶ms).is_empty());
166 assert!(!tool.format_input_markdown(¶ms).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 #[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 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 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 #[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 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 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}