use std::fs;
use std::io::Write;
use std::path::Path;
use chrono::Utc;
use serde::Serialize;
use tempfile::NamedTempFile;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AtomicWriteError {
#[error("target path has no parent directory: {0}")]
NoParent(std::path::PathBuf),
#[error("io error on {path}: {source}")]
Io {
path: std::path::PathBuf,
#[source]
source: std::io::Error,
},
#[error("serde_json error on {path}: {source}")]
Json {
path: std::path::PathBuf,
#[source]
source: serde_json::Error,
},
#[error("could not persist temp file over {target}: {source}")]
Persist {
target: std::path::PathBuf,
#[source]
source: tempfile::PersistError,
},
#[error("could not allocate a unique backup name at {0} after {1} attempts")]
BackupCollision(std::path::PathBuf, u32),
}
pub fn atomic_write_bytes(target: &Path, bytes: &[u8]) -> Result<(), AtomicWriteError> {
let parent = target
.parent()
.ok_or_else(|| AtomicWriteError::NoParent(target.to_path_buf()))?;
fs::create_dir_all(parent).map_err(|e| AtomicWriteError::Io {
path: parent.to_path_buf(),
source: e,
})?;
let mut tmp = NamedTempFile::new_in(parent).map_err(|e| AtomicWriteError::Io {
path: parent.to_path_buf(),
source: e,
})?;
tmp.write_all(bytes).map_err(|e| AtomicWriteError::Io {
path: tmp.path().to_path_buf(),
source: e,
})?;
tmp.flush().map_err(|e| AtomicWriteError::Io {
path: tmp.path().to_path_buf(),
source: e,
})?;
tmp.as_file().sync_all().map_err(|e| AtomicWriteError::Io {
path: tmp.path().to_path_buf(),
source: e,
})?;
tmp.persist(target).map_err(|e| AtomicWriteError::Persist {
target: target.to_path_buf(),
source: e,
})?;
fsync_parent(parent)?;
Ok(())
}
pub fn atomic_write_json<T: Serialize>(target: &Path, value: &T) -> Result<(), AtomicWriteError> {
let mut bytes = serde_json::to_vec_pretty(value).map_err(|e| AtomicWriteError::Json {
path: target.to_path_buf(),
source: e,
})?;
bytes.push(b'\n');
atomic_write_bytes(target, &bytes)
}
pub fn copy_to_backup(
target: &Path,
backup_dir: &Path,
) -> Result<Option<std::path::PathBuf>, AtomicWriteError> {
if !target.exists() {
return Ok(None);
}
fs::create_dir_all(backup_dir).map_err(|e| AtomicWriteError::Io {
path: backup_dir.to_path_buf(),
source: e,
})?;
let filename = target
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| AtomicWriteError::NoParent(target.to_path_buf()))?;
let stamp = Utc::now().format("%Y%m%dT%H%M%S%.9fZ").to_string();
const MAX_ATTEMPTS: u32 = 1024;
for attempt in 0..MAX_ATTEMPTS {
let candidate = if attempt == 0 {
backup_dir.join(format!("{filename}.{stamp}.bak"))
} else {
backup_dir.join(format!("{filename}.{stamp}.{attempt}.bak"))
};
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&candidate)
{
Ok(_handle) => {
drop(_handle);
fs::copy(target, &candidate).map_err(|e| AtomicWriteError::Io {
path: candidate.clone(),
source: e,
})?;
return Ok(Some(candidate));
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
continue;
}
Err(e) => {
return Err(AtomicWriteError::Io {
path: candidate,
source: e,
});
}
}
}
Err(AtomicWriteError::BackupCollision(
backup_dir.to_path_buf(),
MAX_ATTEMPTS,
))
}
#[cfg(unix)]
fn fsync_parent(parent: &Path) -> Result<(), AtomicWriteError> {
let dir = fs::File::open(parent).map_err(|e| AtomicWriteError::Io {
path: parent.to_path_buf(),
source: e,
})?;
dir.sync_all().map_err(|e| AtomicWriteError::Io {
path: parent.to_path_buf(),
source: e,
})
}
#[cfg(not(unix))]
fn fsync_parent(_parent: &Path) -> Result<(), AtomicWriteError> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn write_bytes_round_trip() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("hello.txt");
atomic_write_bytes(&target, b"hello world").unwrap();
assert_eq!(fs::read(&target).unwrap(), b"hello world");
}
#[test]
fn write_json_round_trip() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("doc.json");
let value = serde_json::json!({ "k": [1, 2, 3] });
atomic_write_json(&target, &value).unwrap();
let read: serde_json::Value = serde_json::from_slice(&fs::read(&target).unwrap()).unwrap();
assert_eq!(read, value);
let raw = fs::read_to_string(&target).unwrap();
assert!(raw.ends_with('\n'), "expected trailing newline");
}
#[test]
fn write_creates_missing_parent() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("a/b/c/file.json");
atomic_write_json(&target, &serde_json::json!({})).unwrap();
assert!(target.exists());
}
#[test]
fn overwrites_existing_file() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("doc.json");
atomic_write_bytes(&target, b"first").unwrap();
atomic_write_bytes(&target, b"second").unwrap();
assert_eq!(fs::read(&target).unwrap(), b"second");
}
#[test]
fn copy_to_backup_no_target() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("never.json");
let backup = tmp.path().join("backups");
assert!(copy_to_backup(&target, &backup).unwrap().is_none());
assert!(!backup.exists());
}
#[test]
fn copy_to_backup_creates_timestamped_copy() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("doc.json");
atomic_write_bytes(&target, b"v1").unwrap();
let backup_dir = tmp.path().join("backups");
let backup_path = copy_to_backup(&target, &backup_dir)
.unwrap()
.expect("backup should be Some when target exists");
assert_eq!(fs::read(&backup_path).unwrap(), b"v1");
assert!(backup_path.starts_with(&backup_dir));
let name = backup_path.file_name().unwrap().to_str().unwrap();
assert!(name.starts_with("doc.json."), "got: {name}");
assert!(name.ends_with(".bak"), "got: {name}");
}
#[test]
fn no_partial_state_if_serialization_succeeds() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("doc.json");
atomic_write_bytes(&target, b"first").unwrap();
atomic_write_bytes(&target, b"second").unwrap();
let entries: Vec<_> = fs::read_dir(tmp.path()).unwrap().collect();
assert_eq!(entries.len(), 1);
}
}