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()))?;
}
carry_permissions_across(path, &tmp);
std::fs::rename(&tmp, path)
.with_context(|| format!("replacing {} with {}", path.display(), tmp.display()))?;
Ok(())
}
#[allow(unused_variables)]
fn carry_permissions_across(from: &Path, to: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(existing) = std::fs::metadata(from) {
let mode = existing.permissions().mode();
let _ = std::fs::set_permissions(to, std::fs::Permissions::from_mode(mode));
}
}
}
fn temp_path(path: &Path) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(format!(
".{}.{}.tmp",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
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:#}")),
};
}
pub fn line_ending(source: &str) -> &'static str {
match source.find('\n') {
Some(at) if at > 0 && source.as_bytes()[at - 1] == b'\r' => "\r\n",
_ => "\n",
}
}
#[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);
}
}
fn scratch(name: &str) -> std::path::PathBuf {
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).expect("test directory");
dir
}
#[test]
fn concurrent_writers_do_not_take_each_others_saves_away() {
let dir = scratch("concurrent");
let path = dir.join("todos.toml");
let (a, b) = ("A".repeat(20_000), "B".repeat(20_000));
let mut failures = 0;
for _ in 0..100 {
let handles: Vec<_> = (0..8)
.map(|i| {
let p = path.clone();
let c = if i % 2 == 0 { a.clone() } else { b.clone() };
std::thread::spawn(move || write_atomic(&p, &c))
})
.collect();
for handle in handles {
if !handle.join().is_ok_and(|result| result.is_ok()) {
failures += 1;
}
}
let got = std::fs::read_to_string(&path).expect("readable");
assert!(
got == a || got == b,
"a writer's file was left mixed or truncated: {} bytes",
got.len()
);
}
assert_eq!(failures, 0, "{failures} of 800 concurrent saves failed");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn a_restricted_file_stays_restricted_after_a_save() {
use std::os::unix::fs::PermissionsExt;
let dir = scratch("perms");
let path = dir.join("todos.toml");
std::fs::write(&path, "before").expect("write");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("chmod");
write_atomic(&path, "after").expect("saves");
let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "the file was widened to {mode:o}");
let _ = std::fs::remove_dir_all(&dir);
}
#[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 a_temp_name_says_where_it_came_from_and_is_never_reused() {
let toml = temp_path(Path::new("/data/mirador.toml"));
let md = temp_path(Path::new("/data/mirador.md"));
let shown = toml.to_string_lossy().into_owned();
assert!(
shown.contains("mirador.toml"),
"keeps the full name: {shown}"
);
assert!(
shown.rsplit('.').next() == Some("tmp"),
"and is recognisable: {shown}"
);
assert_ne!(toml, md, "two files must not share one temp path");
assert_ne!(
temp_path(Path::new("/data/mirador.toml")),
temp_path(Path::new("/data/mirador.toml")),
"two writes of one file must not share a 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:#}"
);
}
}