use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::fs;
use tokio::io::AsyncWriteExt;
static TEMP_SEQ: AtomicU64 = AtomicU64::new(0);
pub(crate) fn unique_temp_path(path: &Path) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let seq = TEMP_SEQ.fetch_add(1, Ordering::Relaxed);
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("artifact");
path.with_file_name(format!(
".{file_name}.{}.{nanos}.{seq}.tmp",
std::process::id()
))
}
async fn write_and_sync(tmp: &Path, bytes: &[u8]) -> io::Result<()> {
let mut file = fs::File::create(tmp).await?;
file.write_all(bytes).await?;
file.sync_all().await
}
async fn atomic_rename(from: &Path, to: &Path) -> io::Result<()> {
match fs::rename(from, to).await {
Ok(()) => Ok(()),
Err(_err) if cfg!(windows) && to.exists() => {
let _ = fs::remove_file(to).await;
fs::rename(from, to).await
}
Err(err) => Err(err),
}
}
async fn sync_parent_dir(path: &Path) {
if let Some(parent) = path.parent() {
if let Ok(dir) = fs::File::open(parent).await {
let _ = dir.sync_all().await;
}
}
}
pub(crate) async fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
let tmp = unique_temp_path(path);
if let Err(err) = write_and_sync(&tmp, bytes).await {
let _ = fs::remove_file(&tmp).await;
return Err(err);
}
if let Err(err) = atomic_rename(&tmp, path).await {
let _ = fs::remove_file(&tmp).await;
return Err(err);
}
sync_parent_dir(path).await;
Ok(())
}
async fn cleanup_temps(staged: &[(PathBuf, PathBuf)]) {
for (tmp, _) in staged {
let _ = fs::remove_file(tmp).await;
}
}
pub(crate) async fn atomic_write_batch(writes: Vec<(PathBuf, Vec<u8>)>) -> io::Result<()> {
let mut staged: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(writes.len());
for (path, bytes) in &writes {
if let Some(parent) = path.parent() {
if let Err(err) = fs::create_dir_all(parent).await {
cleanup_temps(&staged).await;
return Err(err);
}
}
let tmp = unique_temp_path(path);
if let Err(err) = write_and_sync(&tmp, bytes).await {
let _ = fs::remove_file(&tmp).await;
cleanup_temps(&staged).await;
return Err(err);
}
staged.push((tmp, path.clone()));
}
for (idx, (tmp, path)) in staged.iter().enumerate() {
if let Err(err) = atomic_rename(tmp, path).await {
cleanup_temps(&staged[idx..]).await;
return Err(err);
}
}
let mut synced: Vec<PathBuf> = Vec::new();
for (_, path) in &staged {
if let Some(parent) = path.parent() {
if !synced.iter().any(|p| p.as_path() == parent) {
sync_parent_dir(path).await;
synced.push(parent.to_path_buf());
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn temp_litter(dir: &Path) -> Vec<String> {
std::fs::read_dir(dir)
.unwrap()
.filter_map(|entry| entry.ok())
.filter_map(|entry| entry.file_name().into_string().ok())
.filter(|name| name.ends_with(".tmp"))
.collect()
}
#[tokio::test]
async fn atomic_write_creates_file_and_leaves_no_temp() {
let dir = tempdir().unwrap();
let path = dir.path().join("nested").join("doc.md");
atomic_write(&path, b"hello").await.unwrap();
assert_eq!(fs::read(&path).await.unwrap(), b"hello");
assert!(temp_litter(path.parent().unwrap()).is_empty());
}
#[tokio::test]
async fn atomic_write_overwrites_existing_without_temp() {
let dir = tempdir().unwrap();
let path = dir.path().join("doc.md");
atomic_write(&path, b"v1").await.unwrap();
atomic_write(&path, b"v2-longer").await.unwrap();
assert_eq!(fs::read(&path).await.unwrap(), b"v2-longer");
assert!(temp_litter(dir.path()).is_empty());
}
#[tokio::test]
async fn atomic_write_failure_preserves_target_and_cleans_temp() {
let dir = tempdir().unwrap();
let path = dir.path().join("busy");
fs::create_dir(&path).await.unwrap();
fs::write(path.join("child"), b"keep").await.unwrap();
let err = atomic_write(&path, b"payload").await;
assert!(
err.is_err(),
"writing a file over a non-empty dir must fail"
);
assert!(path.is_dir());
assert_eq!(fs::read(path.join("child")).await.unwrap(), b"keep");
assert!(temp_litter(dir.path()).is_empty());
}
#[tokio::test]
async fn atomic_write_batch_commits_all_and_leaves_no_temp() {
let dir = tempdir().unwrap();
let a = dir.path().join("idx").join("a.json");
let b = dir.path().join("idx").join("b.json");
let c = dir.path().join("views").join("c.md");
atomic_write_batch(vec![
(a.clone(), b"{\"a\":1}".to_vec()),
(b.clone(), b"{\"b\":2}".to_vec()),
(c.clone(), b"# c".to_vec()),
])
.await
.unwrap();
assert_eq!(fs::read(&a).await.unwrap(), b"{\"a\":1}");
assert_eq!(fs::read(&b).await.unwrap(), b"{\"b\":2}");
assert_eq!(fs::read(&c).await.unwrap(), b"# c");
assert!(temp_litter(a.parent().unwrap()).is_empty());
assert!(temp_litter(c.parent().unwrap()).is_empty());
}
#[tokio::test]
async fn atomic_write_batch_failure_publishes_nothing_and_leaves_no_temp() {
let dir = tempdir().unwrap();
let first = dir.path().join("first.json");
let blocker = dir.path().join("blocker");
fs::write(&blocker, b"keep").await.unwrap();
let bad = blocker.join("child.json");
let result =
atomic_write_batch(vec![(first.clone(), b"1".to_vec()), (bad, b"2".to_vec())]).await;
assert!(result.is_err(), "staging under a file-parent must fail");
assert!(!first.exists(), "a failed batch must publish nothing");
assert_eq!(fs::read(&blocker).await.unwrap(), b"keep");
assert!(temp_litter(dir.path()).is_empty());
}
}