use std::collections::HashMap;
use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use tempfile::NamedTempFile;
pub fn atomic_write<F>(target: &Path, contents_fn: F) -> io::Result<()>
where
F: FnOnce(&mut BufWriter<&std::fs::File>) -> io::Result<()>,
{
with_target_lock(target, || atomic_write_inner(target, contents_fn))
}
pub fn atomic_write_bytes(target: &Path, data: &[u8]) -> io::Result<()> {
atomic_write(target, |w| w.write_all(data))
}
pub fn with_target_lock<T>(target: &Path, op: impl FnOnce() -> io::Result<T>) -> io::Result<T> {
let lock = lock_for_target(target);
let _guard = lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
op()
}
pub fn atomic_write_unlocked<F>(target: &Path, contents_fn: F) -> io::Result<()>
where
F: FnOnce(&mut BufWriter<&std::fs::File>) -> io::Result<()>,
{
atomic_write_inner(target, contents_fn)
}
fn atomic_write_inner<F>(target: &Path, contents_fn: F) -> io::Result<()>
where
F: FnOnce(&mut BufWriter<&std::fs::File>) -> io::Result<()>,
{
let parent = target.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"target path has no parent directory",
)
})?;
target.file_name().and_then(|s| s.to_str()).ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "target path has no file name")
})?;
(|| {
let tmp_file = NamedTempFile::new_in(parent)?;
{
let file = tmp_file.as_file();
let mut writer = BufWriter::new(file);
contents_fn(&mut writer)?;
writer.flush()?;
}
tmp_file.as_file().sync_all()?;
if let Ok(existing) = std::fs::metadata(target) {
tmp_file.as_file().set_permissions(existing.permissions())?;
}
tmp_file.persist(target).map_err(|err| err.error)?;
#[cfg(unix)]
sync_parent_dir(parent)?;
Ok(())
})()
}
#[cfg(unix)]
fn sync_parent_dir(parent: &Path) -> io::Result<()> {
std::fs::File::open(parent)?.sync_all()
}
pub fn lock_for_target(target: &Path) -> Arc<Mutex<()>> {
static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
let map = LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
let mut guard = map
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard
.entry(target.to_path_buf())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn atomic_write_creates_file() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("test.txt");
atomic_write(&target, |w| writeln!(w, "hello")).unwrap();
assert_eq!(fs::read_to_string(&target).unwrap().trim(), "hello");
}
#[test]
fn atomic_write_replaces_existing() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("test.txt");
fs::write(&target, "old").unwrap();
atomic_write_bytes(&target, b"new").unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "new");
}
#[test]
fn atomic_write_cleans_up_on_error() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("fail.txt");
let err = atomic_write(&target, |_w| Err(io::Error::other("boom")));
assert!(err.is_err());
assert!(!target.exists());
let entries: Vec<_> = fs::read_dir(dir.path()).unwrap().collect();
assert!(entries.is_empty());
}
#[test]
fn concurrent_writes_dont_corrupt() {
use std::thread;
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("concurrent.txt");
let handles: Vec<_> = (0..10)
.map(|i| {
let t = target.clone();
thread::spawn(move || {
atomic_write_bytes(&t, format!("writer-{i}").as_bytes()).unwrap();
})
})
.collect();
for h in handles {
h.join().unwrap();
}
let content = fs::read_to_string(&target).unwrap();
assert!(content.starts_with("writer-"));
}
#[cfg(unix)]
#[test]
fn atomic_write_preserves_existing_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("script.sh");
fs::write(&target, "#!/bin/sh\necho old\n").unwrap();
fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap();
atomic_write_bytes(&target, b"#!/bin/sh\necho new\n").unwrap();
let mode = fs::metadata(&target).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o755);
}
#[cfg(windows)]
#[test]
fn atomic_write_replaces_existing_on_windows_without_predelete() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("replace.txt");
fs::write(&target, "old").unwrap();
atomic_write_bytes(&target, b"new").unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "new");
}
}