use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
pub fn staging_path(final_path: &Path) -> PathBuf {
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let mut name = final_path.file_name().unwrap_or_default().to_os_string();
name.push(format!(".tmp.{}.{seq}", std::process::id()));
match final_path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.join(name),
_ => PathBuf::from(name),
}
}
pub fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let tmp = staging_path(path);
std::fs::write(&tmp, bytes)?;
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn two_stagings_of_one_destination_do_not_collide() {
let path = Path::new("/var/lib/kamaji/ports.json");
assert_ne!(staging_path(path), staging_path(path));
}
#[test]
fn staging_keeps_the_whole_file_name_and_never_ends_in_json() {
let staged = staging_path(Path::new("/tmp/deploys/.yah-marketing.json"));
let name = staged.file_name().unwrap().to_string_lossy().into_owned();
assert!(name.starts_with(".yah-marketing.json.tmp."), "{name}");
assert_ne!(staged.extension().unwrap(), "json");
assert_eq!(staged.parent().unwrap(), Path::new("/tmp/deploys"));
}
#[test]
fn write_atomic_creates_parents_and_leaves_no_staging_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested/deeper/ledger.json");
write_atomic(&path, b"{}").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"{}");
let leftovers: Vec<_> = std::fs::read_dir(path.parent().unwrap())
.unwrap()
.filter_map(|e| e.ok().map(|e| e.file_name()))
.filter(|n| n.to_string_lossy().contains(".tmp."))
.collect();
assert!(leftovers.is_empty(), "staging files left behind: {leftovers:?}");
}
}