use anyhow::{Context, Result};
use std::path::Path;
pub fn write(path: &Path, contents: &str) -> Result<()> {
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let name = path.file_name().unwrap_or_default().to_string_lossy();
let tmp = dir.join(format!(".{name}.{}.tmp", std::process::id()));
if let Err(e) = std::fs::write(&tmp, contents) {
let _ = std::fs::remove_file(&tmp);
return Err(e).with_context(|| format!("writing {}", tmp.display()));
}
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(e).with_context(|| format!("replacing {}", path.display()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_reader_never_sees_a_partial_file() {
let dir = std::env::temp_dir().join(format!("keel-atomic-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("run.json");
let small = "{}".to_string();
let large = format!("{{\"pad\":\"{}\"}}", "x".repeat(400_000));
write(&path, &large).unwrap();
let reader = {
let path = path.clone();
std::thread::spawn(move || {
for _ in 0..400 {
match std::fs::read_to_string(&path) {
Ok(s) => assert!(
s.ends_with("}") && (s.len() == 2 || s.len() > 400_000),
"observed a partial file of {} bytes",
s.len()
),
Err(e) => assert_eq!(
e.kind(),
std::io::ErrorKind::NotFound,
"unexpected read error"
),
}
}
})
};
for _ in 0..200 {
write(&path, &small).unwrap();
write(&path, &large).unwrap();
}
reader.join().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_failed_write_leaves_no_temporary_behind() {
let dir = std::env::temp_dir().join(format!("keel-atomic-fail-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
assert!(write(&dir.join("nope.json"), "{}").is_err());
assert!(!dir.exists(), "a failed write created the directory");
}
}