use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
pub const TEMP_SUFFIX: &str = ".tmp";
pub fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> {
atomic_write_with(path, |f| f.write_all(data))
}
pub fn atomic_write_with(path: &Path, write: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
let target = resolve_symlink(path);
let tmp = temp_path(&target);
let r = (|| {
{
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
write(&mut f)?;
f.flush()?;
if let Ok(m) = fs::metadata(&target) {
let _ = f.set_permissions(m.permissions());
}
f.sync_all()?;
}
fs::rename(&tmp, &target)
})();
if let Err(e) = r {
let _ = fs::remove_file(&tmp);
return Err(e);
}
sync_dir(&parent_dir(&target));
Ok(())
}
pub fn temp_path(path: &Path) -> PathBuf {
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "file".into());
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
parent_dir(path).join(format!(".{name}.{}-{n}{TEMP_SUFFIX}", pid()))
}
pub fn is_temp_name(name: &str) -> bool {
name.starts_with('.') && name.ends_with(TEMP_SUFFIX) && name.len() > 1 + TEMP_SUFFIX.len()
}
fn parent_dir(path: &Path) -> PathBuf {
match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => PathBuf::from("."),
}
}
fn resolve_symlink(path: &Path) -> PathBuf {
match fs::symlink_metadata(path) {
Ok(m) if m.file_type().is_symlink() => fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()),
_ => path.to_path_buf(),
}
}
fn sync_dir(dir: &Path) {
#[cfg(unix)]
if let Ok(d) = File::open(dir) {
let _ = d.sync_all();
}
#[cfg(not(unix))]
let _ = dir;
}
pub fn pid() -> u32 {
#[cfg(not(target_arch = "wasm32"))]
return std::process::id();
#[cfg(target_arch = "wasm32")]
0
}
#[cfg(test)]
mod tests {
use super::*;
fn tmpdir(tag: &str) -> tempfile::TempDir {
tempfile::Builder::new().prefix(&format!("ashlar-atomic-{tag}-")).tempdir().unwrap()
}
fn names(d: &Path) -> Vec<String> {
let mut v: Vec<String> = fs::read_dir(d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
v.sort();
v
}
#[test]
fn replaces_and_leaves_no_temp_files() {
let tmp = tmpdir("replace");
let d = tmp.path();
let p = d.join("a.doc");
atomic_write(&p, b"one").unwrap();
atomic_write(&p, b"two").unwrap();
assert_eq!(fs::read(&p).unwrap(), b"two");
assert_eq!(names(d), vec!["a.doc"]);
}
#[test]
fn failed_rename_keeps_old_file_and_cleans_temp() {
let tmp = tmpdir("fail");
let d = tmp.path();
let p = d.join("a.doc");
atomic_write(&p, b"good").unwrap();
let dir_target = d.join("sub");
fs::create_dir_all(dir_target.join("x")).unwrap();
assert!(atomic_write(&dir_target, b"bad").is_err());
assert_eq!(fs::read(&p).unwrap(), b"good");
assert!(!names(d).iter().any(|n| is_temp_name(n)), "{:?}", names(d));
}
#[test]
fn writer_error_mid_write_keeps_original() {
let tmp = tmpdir("midwrite");
let d = tmp.path();
let p = d.join("a.doc");
atomic_write(&p, b"original").unwrap();
let r = atomic_write_with(&p, |f| {
f.write_all(b"half of the new conte")?;
Err(io::Error::other("simulated crash"))
});
assert!(r.is_err());
assert_eq!(fs::read(&p).unwrap(), b"original");
assert_eq!(names(d), vec!["a.doc"]);
}
#[test]
fn missing_directory_is_an_error() {
let tmp = tmpdir("missing");
let d = tmp.path();
assert!(atomic_write(&d.join("nope/a.doc"), b"x").is_err());
assert!(names(d).is_empty());
}
#[test]
fn empty_and_large_payloads() {
let tmp = tmpdir("sizes");
let d = tmp.path();
let p = d.join("a.bin");
atomic_write(&p, b"").unwrap();
assert_eq!(fs::read(&p).unwrap().len(), 0);
let big = vec![7u8; 8 << 20];
atomic_write(&p, &big).unwrap();
assert_eq!(fs::read(&p).unwrap(), big);
}
#[test]
fn temp_names_are_unique_and_recognised() {
let a = temp_path(Path::new("x/a.doc"));
let b = temp_path(Path::new("x/a.doc"));
assert_ne!(a, b);
assert!(is_temp_name(&a.file_name().unwrap().to_string_lossy()));
assert!(!is_temp_name("a.doc"));
assert!(!is_temp_name(".tmp"));
assert_eq!(temp_path(Path::new("bare")).parent().unwrap(), Path::new("."));
}
#[cfg(unix)]
#[test]
fn keeps_permissions_and_follows_symlinks() {
use std::os::unix::fs::PermissionsExt;
let tmp = tmpdir("perm");
let d = tmp.path();
let real = d.join("real.doc");
atomic_write(&real, b"one").unwrap();
fs::set_permissions(&real, fs::Permissions::from_mode(0o600)).unwrap();
let link = d.join("link.doc");
std::os::unix::fs::symlink(&real, &link).unwrap();
atomic_write(&link, b"two").unwrap();
assert!(fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link survives");
assert_eq!(fs::read(&real).unwrap(), b"two");
assert_eq!(fs::metadata(&real).unwrap().permissions().mode() & 0o777, 0o600);
}
#[test]
fn concurrent_writers_never_tear() {
let tmp = tmpdir("race");
let d = tmp.path();
let p = d.join("a.doc");
let handles: Vec<_> = (0..8u8)
.map(|i| {
let p = p.clone();
std::thread::spawn(move || {
for _ in 0..20 {
atomic_write(&p, &vec![i; 64 * 1024]).unwrap();
}
})
})
.collect();
for h in handles {
h.join().unwrap();
}
let bytes = fs::read(&p).unwrap();
assert_eq!(bytes.len(), 64 * 1024);
assert!(bytes.iter().all(|b| *b == bytes[0]), "one writer's content, whole");
assert_eq!(names(d), vec!["a.doc"]);
}
}