use std::fs::OpenOptions;
use std::io::{Result, Write};
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
pub fn atomic_write_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let suffix = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let tmp = path.with_extension(format!("json.tmp.{pid}.{suffix}"));
let body = serde_json::to_string_pretty(value).map_err(std::io::Error::other)?;
{
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
f.write_all(body.as_bytes())?;
f.sync_all()?;
}
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::*;
use serde::Serialize;
#[derive(Serialize)]
struct Sample {
name: String,
value: u32,
}
#[test]
fn writes_pretty_json_to_target_path() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("out.json");
let s = Sample { name: "x".into(), value: 7 };
atomic_write_json(&path, &s).unwrap();
let body = std::fs::read_to_string(&path).unwrap();
assert!(body.contains("\"name\""));
assert!(body.contains("\"value\": 7"));
}
#[test]
fn creates_parent_dir_if_missing() {
let dir = tempfile::TempDir::new().unwrap();
let nested = dir.path().join("a/b/c/out.json");
let s = Sample { name: "x".into(), value: 1 };
atomic_write_json(&nested, &s).unwrap();
assert!(nested.exists());
}
#[test]
fn leaves_no_tmp_sibling_after_success() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("out.json");
let s = Sample { name: "x".into(), value: 1 };
atomic_write_json(&path, &s).unwrap();
let entries: Vec<_> =
std::fs::read_dir(dir.path()).unwrap().flatten().map(|e| e.file_name()).collect();
assert_eq!(entries.len(), 1, "stray tmp file: {entries:?}");
}
#[test]
fn cleans_up_tmp_when_rename_fails() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("out.json");
std::fs::create_dir(&path).unwrap();
let s = Sample { name: "x".into(), value: 1 };
let err = atomic_write_json(&path, &s).expect_err("rename onto a dir must fail");
let tmp_count = std::fs::read_dir(dir.path())
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
.count();
assert_eq!(tmp_count, 0, "leaked tmp file after rename failure: {err}");
}
}