use std::path::Path;
use anyhow::{Context, Result};
pub fn write_atomic(path: &Path, contents: &str) -> Result<()> {
use std::io::Write;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating directory {}", parent.display()))?;
}
let tmp = temp_path(path);
{
let mut file =
std::fs::File::create(&tmp).with_context(|| format!("creating {}", tmp.display()))?;
file.write_all(contents.as_bytes())
.with_context(|| format!("writing {}", tmp.display()))?;
file.sync_all()
.with_context(|| format!("flushing {} to disk", tmp.display()))?;
}
std::fs::rename(&tmp, path)
.with_context(|| format!("replacing {} with {}", path.display(), tmp.display()))?;
Ok(())
}
fn temp_path(path: &Path) -> std::path::PathBuf {
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(".tmp");
path.with_file_name(name)
}
pub fn report(result: Result<()>, last_error: &mut Option<String>) {
*last_error = match result {
Ok(()) => None,
Err(e) => Some(format!("{e:#}")),
};
}
#[cfg(test)]
mod tests {
use super::*;
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new(name: &str) -> Self {
let dir =
std::env::temp_dir().join(format!("mirador-store-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn join(&self, name: &str) -> std::path::PathBuf {
self.0.join(name)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn a_write_creates_the_file_and_its_parent_directory() {
let dir = TempDir::new("create");
let path = dir.join("nested/deeper/notes.toml");
write_atomic(&path, "hello").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello");
}
#[test]
fn a_write_replaces_the_previous_contents_completely() {
let dir = TempDir::new("replace");
let path = dir.join("notes.toml");
write_atomic(&path, "a much longer first version").unwrap();
write_atomic(&path, "short").unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"short",
"a shorter second write must not leave a tail of the first"
);
}
#[test]
fn no_temp_file_is_left_behind() {
let dir = TempDir::new("tidy");
let path = dir.join("notes.toml");
write_atomic(&path, "x").unwrap();
let leftovers: Vec<_> = std::fs::read_dir(&dir.0)
.unwrap()
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|path| path.extension().is_some_and(|ext| ext == "tmp"))
.map(|path| path.display().to_string())
.collect();
assert!(leftovers.is_empty(), "left behind: {leftovers:?}");
}
#[test]
fn the_temp_name_keeps_the_original_extension() {
let toml = temp_path(Path::new("/data/mirador.toml"));
let md = temp_path(Path::new("/data/mirador.md"));
assert_eq!(toml, Path::new("/data/mirador.toml.tmp"));
assert_ne!(toml, md, "two files must not share one temp path");
}
#[test]
fn a_failure_is_recorded_and_a_success_clears_it() {
let mut last = Some("stale".to_string());
report(Ok(()), &mut last);
assert_eq!(last, None);
report(Err(anyhow::anyhow!("disk full")), &mut last);
assert_eq!(last.as_deref(), Some("disk full"));
}
#[test]
fn writing_where_a_directory_blocks_the_way_fails_rather_than_panics() {
let dir = TempDir::new("blocked");
let path = dir.join("occupied");
std::fs::create_dir_all(&path).unwrap();
let err = write_atomic(&path, "x").expect_err("a directory is not writable as a file");
assert!(
format!("{err:#}").contains("occupied"),
"the error must name the path: {err:#}"
);
}
}