use camino::{Utf8Path, Utf8PathBuf};
use crate::error::AppError;
use crate::transaction::sync_parent;
const SCRATCH_SUFFIX: &str = ".sdd-stage";
#[derive(Debug, Clone, Copy)]
pub struct Stage;
impl Stage {
pub fn write(destination: &Utf8Path, bytes: &[u8]) -> Result<Utf8PathBuf, AppError> {
Self::write_at(&scratch_for(destination), destination, bytes)
}
pub fn write_at(
scratch: &Utf8Path,
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.to_owned();
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}; move it aside and run this again"),
)
})?;
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 {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
let serial = NEXT.fetch_add(1, Ordering::Relaxed);
Utf8PathBuf::from(format!(
"{destination}{SCRATCH_SUFFIX}.{}-{serial}",
std::process::id()
))
}
#[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_pre_existing_scratch_path_refuses_rather_than_being_followed() {
let dir = tempfile::tempdir().unwrap();
let destination = root(&dir).join("SKILL.md");
let scratch = root(&dir).join("SKILL.md.sdd-stage.taken");
let victim = root(&dir).join("victim");
std::fs::write(&victim, b"keep\n").unwrap();
std::os::unix::fs::symlink(&victim, scratch.as_std_path()).unwrap();
assert!(Stage::write_at(&scratch, &destination, b"new\n").is_err());
assert_eq!(std::fs::read(&victim).unwrap(), b"keep\n");
}
#[test]
fn a_leftover_scratch_file_is_never_reused_as_this_runs_own() {
let dir = tempfile::tempdir().unwrap();
let destination = root(&dir).join("SKILL.md");
let leftover = root(&dir).join("SKILL.md.sdd-stage.1-0");
std::fs::write(leftover.as_std_path(), b"half a write").unwrap();
let scratch = Stage::write(&destination, b"new\n").unwrap();
assert_ne!(scratch, leftover);
Stage::replace(&scratch, &destination).unwrap();
assert_eq!(std::fs::read(&destination).unwrap(), b"new\n");
assert_eq!(std::fs::read(&leftover).unwrap(), b"half a write");
}
#[test]
fn two_scratch_paths_for_one_destination_never_collide() {
let destination = Utf8Path::new("/work/AGENTS.md");
assert_ne!(scratch_for(destination), scratch_for(destination));
}
}