agpm_cli/utils/fs/
atomic.rs1use crate::utils::fs::dirs::ensure_dir;
7use anyhow::{Context, Result};
8use std::fs;
9use std::path::Path;
10
11pub fn safe_write(path: &Path, content: &str) -> Result<()> {
44 atomic_write(path, content.as_bytes())
45}
46
47pub fn atomic_write(path: &Path, content: &[u8]) -> Result<()> {
92 use std::io::Write;
93
94 let safe_path = crate::utils::platform::windows_long_path(path);
96
97 if let Some(parent) = safe_path.parent() {
99 ensure_dir(parent)?;
100 }
101
102 let temp_path = safe_path.with_extension("tmp");
104
105 {
106 let mut file = fs::File::create(&temp_path).with_context(|| {
107 let platform_help = if crate::utils::platform::is_windows() {
108 "On Windows: Check file permissions, path length, and that directory exists"
109 } else {
110 "Check file permissions and that directory exists"
111 };
112
113 format!("Failed to create temp file: {}\n\n{}", temp_path.display(), platform_help)
114 })?;
115
116 file.write_all(content)
117 .with_context(|| format!("Failed to write to temp file: {}", temp_path.display()))?;
118
119 file.sync_all().with_context(|| "Failed to sync file to disk")?;
120 }
121
122 fs::rename(&temp_path, &safe_path)
124 .with_context(|| format!("Failed to rename temp file to: {}", safe_path.display()))?;
125
126 Ok(())
127}
128
129pub async fn atomic_write_multiple(files: &[(std::path::PathBuf, Vec<u8>)]) -> Result<()> {
185 use futures::future::try_join_all;
186
187 if files.is_empty() {
188 return Ok(());
189 }
190
191 let mut tasks = Vec::new();
192
193 for (path, content) in files {
194 let path = path.clone();
195 let content = content.clone();
196 let task =
197 tokio::task::spawn_blocking(move || atomic_write(&path, &content).map(|()| path));
198 tasks.push(task);
199 }
200
201 let results = try_join_all(tasks).await.context("Failed to join atomic write tasks")?;
202
203 let mut errors = Vec::new();
204
205 for result in results {
206 if let Err(e) = result {
207 errors.push(e);
208 }
209 }
210
211 if !errors.is_empty() {
212 let error_msgs: Vec<String> =
213 errors.into_iter().map(|error| format!(" {error}")).collect();
214 return Err(anyhow::anyhow!(
215 "Failed to write {} files:\n{}",
216 error_msgs.len(),
217 error_msgs.join("\n")
218 ));
219 }
220
221 Ok(())
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use tempfile::tempdir;
228
229 #[test]
230 fn test_safe_write() {
231 let temp = tempdir().unwrap();
232 let file_path = temp.path().join("test.txt");
233
234 safe_write(&file_path, "test content").unwrap();
235
236 let content = std::fs::read_to_string(&file_path).unwrap();
237 assert_eq!(content, "test content");
238 }
239
240 #[test]
241 fn test_safe_write_creates_parent_dirs() {
242 let temp = tempdir().unwrap();
243 let file_path = temp.path().join("subdir").join("test.txt");
244
245 safe_write(&file_path, "test content").unwrap();
246
247 assert!(file_path.exists());
248 let content = std::fs::read_to_string(&file_path).unwrap();
249 assert_eq!(content, "test content");
250 }
251
252 #[test]
253 fn test_atomic_write_basic() {
254 let temp = tempdir().unwrap();
255 let file = temp.path().join("atomic.txt");
256
257 atomic_write(&file, b"test content").unwrap();
258 assert_eq!(std::fs::read_to_string(&file).unwrap(), "test content");
259 }
260
261 #[test]
262 fn test_atomic_write_overwrites() {
263 let temp = tempdir().unwrap();
264 let file = temp.path().join("atomic.txt");
265
266 atomic_write(&file, b"initial").unwrap();
268 assert_eq!(std::fs::read_to_string(&file).unwrap(), "initial");
269
270 atomic_write(&file, b"updated").unwrap();
272 assert_eq!(std::fs::read_to_string(&file).unwrap(), "updated");
273 }
274
275 #[test]
276 fn test_atomic_write_creates_parent() {
277 let temp = tempdir().unwrap();
278 let file = temp.path().join("deep").join("nested").join("atomic.txt");
279
280 atomic_write(&file, b"nested content").unwrap();
281 assert!(file.exists());
282 assert_eq!(std::fs::read_to_string(&file).unwrap(), "nested content");
283 }
284
285 #[tokio::test]
286 async fn test_atomic_write_multiple() {
287 let temp = tempdir().unwrap();
288 let file1 = temp.path().join("atomic1.txt");
289 let file2 = temp.path().join("atomic2.txt");
290
291 let files =
292 vec![(file1.clone(), b"content1".to_vec()), (file2.clone(), b"content2".to_vec())];
293
294 atomic_write_multiple(&files).await.unwrap();
295
296 assert!(file1.exists());
297 assert!(file2.exists());
298 assert_eq!(std::fs::read_to_string(&file1).unwrap(), "content1");
299 assert_eq!(std::fs::read_to_string(&file2).unwrap(), "content2");
300 }
301
302 #[tokio::test]
303 async fn test_atomic_write_multiple_partial_failure() {
304 let temp = tempdir().unwrap();
306 let valid_path = temp.path().join("valid.txt");
307
308 let invalid_base = temp.path().join("not_a_directory.txt");
311 std::fs::write(&invalid_base, "this is a file").unwrap();
312 let invalid_path = invalid_base.join("impossible_file.txt");
313
314 let files =
315 vec![(valid_path.clone(), b"content".to_vec()), (invalid_path, b"fail".to_vec())];
316
317 let result = atomic_write_multiple(&files).await;
318 assert!(result.is_err());
319 }
320
321 #[test]
322 fn test_safe_write_readonly_parent() {
323 if std::env::var("CI").is_ok() {
326 return;
327 }
328
329 let temp = tempdir().unwrap();
330 let readonly_dir = temp.path().join("readonly");
331 ensure_dir(&readonly_dir).unwrap();
332
333 #[cfg(unix)]
335 {
336 use std::os::unix::fs::PermissionsExt;
337 let mut perms = std::fs::metadata(&readonly_dir).unwrap().permissions();
338 perms.set_mode(0o555); std::fs::set_permissions(&readonly_dir, perms).unwrap();
340
341 let file = readonly_dir.join("test.txt");
342 let result = safe_write(&file, "test");
343 assert!(result.is_err());
344
345 let mut perms = std::fs::metadata(&readonly_dir).unwrap().permissions();
347 perms.set_mode(0o755);
348 std::fs::set_permissions(&readonly_dir, perms).unwrap();
349 }
350 }
351
352 #[test]
353 fn test_safe_copy_file() {
354 let temp = tempdir().unwrap();
355 let src = temp.path().join("source.txt");
356 let dst = temp.path().join("dest.txt");
357
358 std::fs::write(&src, "test content").unwrap();
359 std::fs::copy(&src, &dst).unwrap();
360
361 assert_eq!(std::fs::read_to_string(&dst).unwrap(), "test content");
362 }
363
364 #[test]
365 fn test_copy_with_parent_creation() {
366 let temp = tempdir().unwrap();
367 let src = temp.path().join("source.txt");
368 let dst = temp.path().join("subdir").join("dest.txt");
369
370 std::fs::write(&src, "test content").unwrap();
371 crate::utils::fs::ensure_parent_dir(&dst).unwrap();
372 std::fs::copy(&src, &dst).unwrap();
373
374 assert!(dst.exists());
375 assert_eq!(std::fs::read_to_string(&dst).unwrap(), "test content");
376 }
377
378 #[test]
379 fn test_copy_nonexistent_source() {
380 let temp = tempdir().unwrap();
381 let src = temp.path().join("nonexistent.txt");
382 let dst = temp.path().join("dest.txt");
383
384 let result = std::fs::copy(&src, &dst);
385 assert!(result.is_err());
386 }
387}