use std::fs;
use std::io;
use std::path::Path;
pub fn atomic_write(path: &Path, content: &str) -> io::Result<()> {
atomic_write_bytes(path, content.as_bytes())
}
pub fn atomic_write_bytes(path: &Path, content: &[u8]) -> io::Result<()> {
let tmp = path.with_extension(format!(
"tmp.{}.{}",
std::process::id(),
uuid::Uuid::new_v4().simple()
));
fs::write(&tmp, content)?;
match fs::rename(&tmp, path) {
Ok(()) => Ok(()),
Err(e) => {
let _ = fs::remove_file(&tmp);
Err(e)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn temp_file_name_contains_uuid_suffix() {
let pid = std::process::id();
let uuid = uuid::Uuid::new_v4().simple().to_string();
let suffix = format!("tmp.{}.{}", pid, uuid);
assert_eq!(uuid.len(), 32);
assert!(uuid.chars().all(|c| c.is_ascii_hexdigit()));
assert!(suffix.contains(&pid.to_string()));
}
#[test]
fn atomic_write_survives_concurrent_same_path() {
let tmp = std::env::temp_dir().join(format!(
"oxi-fs-util-concurrent-{}",
uuid::Uuid::new_v4().simple()
));
fs::write(&tmp, b"").unwrap();
let payloads: Vec<String> = (0..16).map(|i| format!("payload-{i}")).collect();
let path = tmp.clone();
std::thread::scope(|s| {
for p in payloads {
let path = path.clone();
s.spawn(move || atomic_write(&path, &p).unwrap());
}
});
let got = fs::read_to_string(&tmp).unwrap();
assert!(
(0..16).any(|i| got == format!("payload-{i}")),
"concurrent writes produced a torn result: {got:?}"
);
let dir = tmp.parent().unwrap();
let leaking: Vec<_> = fs::read_dir(dir)
.unwrap()
.flatten()
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with(tmp.file_name().unwrap().to_string_lossy().as_ref())
&& e.file_name().to_string_lossy() != tmp.file_name().unwrap().to_string_lossy()
})
.collect();
let _ = fs::remove_file(&tmp);
assert!(
leaking.is_empty(),
"orphan temp files left behind: {leaking:?}"
);
}
#[test]
fn rename_failure_does_not_leak_orphan() {
let root =
std::env::temp_dir().join(format!("oxi-fs-util-ro-{}", uuid::Uuid::new_v4().simple()));
fs::create_dir(&root).unwrap();
let target = root.join("out.md");
let mut perms = fs::metadata(&root).unwrap().permissions();
perms.set_readonly(true);
fs::set_permissions(&root, perms).unwrap();
let res = atomic_write(&target, "hello");
assert!(res.is_err(), "expected rename to fail under read-only dir");
let mut perms = fs::metadata(&root).unwrap().permissions();
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(false);
fs::set_permissions(&root, perms).unwrap();
let leftovers: Vec<_> = fs::read_dir(&root)
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
fs::remove_dir_all(&root).ok();
assert!(
leftovers.is_empty(),
"temp orphan leaked after rename failure: {leftovers:?}"
);
}
}