use camino::{Utf8Path, Utf8PathBuf};
use crate::domain::ownership::Sha256;
use crate::error::AppError;
use crate::transaction::{sync_dir, sync_parent};
const SCRATCH_SUFFIX: &str = ".sdd-stage";
#[derive(Debug, Clone)]
pub struct Stage {
backups: Utf8PathBuf,
}
impl Stage {
pub fn new(backup_root: &Utf8Path) -> Result<Self, AppError> {
std::fs::create_dir_all(backup_root)?;
Ok(Self {
backups: backup_root.to_owned(),
})
}
#[must_use]
pub fn backup_path(&self, digest: &Sha256) -> Utf8PathBuf {
self.backups.join(digest.to_string())
}
pub fn back_up(&self, destination: &Utf8Path) -> Result<Option<Sha256>, AppError> {
if !destination.is_file() {
return Ok(None);
}
let bytes = std::fs::read(destination)?;
let digest = Sha256::of(&bytes);
let held = self.backup_path(&digest);
if !held.is_file() {
crate::adapters::fs::write_atomic(&held, &bytes)?;
}
sync_dir(&self.backups)?;
Ok(Some(digest))
}
pub fn restore(&self, digest: &Sha256, destination: &Utf8Path) -> Result<(), AppError> {
let held = self.backup_path(digest);
let bytes = std::fs::read(&held).map_err(|source| {
AppError::Unrecovered(format!(
"the copy of {destination} is not in the backup store at {held}: {source}"
))
})?;
crate::adapters::fs::write_atomic(destination, &bytes)?;
sync_parent(destination)?;
Ok(())
}
pub fn write(destination: &Utf8Path, bytes: &[u8]) -> Result<Utf8PathBuf, AppError> {
use std::io::Write as _;
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)?;
}
let scratch = scratch_for(destination);
let mut handle = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&scratch)
.map_err(|source| {
std::io::Error::new(
source.kind(),
format!("{scratch}: {source}; remove the scratch file to retry"),
)
})?;
let written = handle.write_all(bytes).and_then(|()| handle.sync_all());
drop(handle);
if let Err(source) = written {
let _ = std::fs::remove_file(&scratch);
return Err(AppError::Io(source));
}
Ok(scratch)
}
pub fn replace(scratch: &Utf8Path, destination: &Utf8Path) -> Result<(), AppError> {
if let Err(source) = std::fs::rename(scratch, destination) {
let _ = std::fs::remove_file(scratch);
return Err(AppError::Io(source));
}
sync_parent(destination)?;
Ok(())
}
pub fn discard(scratch: &Utf8Path) {
let _ = std::fs::remove_file(scratch);
}
}
#[must_use]
pub fn scratch_for(destination: &Utf8Path) -> Utf8PathBuf {
Utf8PathBuf::from(format!("{destination}{SCRATCH_SUFFIX}"))
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
reason = "a test panics as its failure signal, not as control flow"
)]
use super::*;
fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
Utf8PathBuf::from(dir.path().to_str().unwrap())
}
#[test]
fn a_staged_file_sits_beside_its_destination_and_lands_by_rename() {
let dir = tempfile::tempdir().unwrap();
let destination = root(&dir).join("a/b/SKILL.md");
let scratch = Stage::write(&destination, b"new\n").unwrap();
assert_eq!(scratch.parent(), destination.parent());
assert!(!destination.exists());
Stage::replace(&scratch, &destination).unwrap();
assert_eq!(std::fs::read(&destination).unwrap(), b"new\n");
assert!(!scratch.exists());
}
#[test]
fn a_backup_round_trips_and_an_absent_destination_has_none() {
let dir = tempfile::tempdir().unwrap();
let stage = Stage::new(&root(&dir).join("backups")).unwrap();
let destination = root(&dir).join("a/SKILL.md");
assert_eq!(stage.back_up(&destination).unwrap(), None);
crate::adapters::fs::write_file(&destination, b"held\n").unwrap();
let digest = stage.back_up(&destination).unwrap().unwrap();
std::fs::write(&destination, b"replaced\n").unwrap();
stage.restore(&digest, &destination).unwrap();
assert_eq!(std::fs::read(&destination).unwrap(), b"held\n");
}
#[test]
fn a_pre_existing_scratch_path_refuses_rather_than_being_followed() {
let dir = tempfile::tempdir().unwrap();
let destination = root(&dir).join("SKILL.md");
let victim = root(&dir).join("victim");
std::fs::write(&victim, b"keep\n").unwrap();
std::os::unix::fs::symlink(&victim, scratch_for(&destination).as_std_path()).unwrap();
assert!(Stage::write(&destination, b"new\n").is_err());
assert_eq!(std::fs::read(&victim).unwrap(), b"keep\n");
}
#[test]
fn a_missing_backup_reports_an_unrecovered_run() {
let dir = tempfile::tempdir().unwrap();
let stage = Stage::new(&root(&dir).join("backups")).unwrap();
let error = stage
.restore(&Sha256::of(b"absent"), &root(&dir).join("x"))
.unwrap_err();
assert_eq!(error.kind(), "Unrecovered");
assert_eq!(error.exit_code(), 73);
}
}