Skip to main content

spec_driven_docs/transaction/
stage.rs

1//! Scratch files beside their destinations, and backups by digest.
2//!
3//! Staging is always a sibling of the destination, so the rename that
4//! follows never crosses a filesystem, whichever mount `HOME`,
5//! `XDG_STATE_HOME`, or `CLAUDE_CONFIG_DIR` puts a root on. Backups are
6//! copies under one root and cross filesystems freely, which is why they
7//! are copies and the replacements are renames.
8
9use camino::{Utf8Path, Utf8PathBuf};
10
11use crate::domain::ownership::Sha256;
12use crate::error::AppError;
13use crate::transaction::{sync_dir, sync_parent};
14
15/// The suffix a staged file carries until it is renamed into place.
16const SCRATCH_SUFFIX: &str = ".sdd-stage";
17
18/// Where a run keeps what it is about to replace.
19#[derive(Debug, Clone)]
20pub struct Stage {
21    backups: Utf8PathBuf,
22}
23
24impl Stage {
25    /// Open the backup store under `backup_root`.
26    ///
27    /// # Errors
28    ///
29    /// Any I/O error creating the store.
30    pub fn new(backup_root: &Utf8Path) -> Result<Self, AppError> {
31        std::fs::create_dir_all(backup_root)?;
32        Ok(Self {
33            backups: backup_root.to_owned(),
34        })
35    }
36
37    /// Where one backed-up digest is kept.
38    #[must_use]
39    pub fn backup_path(&self, digest: &Sha256) -> Utf8PathBuf {
40        self.backups.join(digest.to_string())
41    }
42
43    /// Copy an existing destination into the store, returning its digest.
44    ///
45    /// `None` means the destination does not exist, which is what tells a
46    /// recovery to remove it rather than restore it.
47    ///
48    /// # Errors
49    ///
50    /// Any I/O error reading the destination or writing the copy.
51    pub fn back_up(&self, destination: &Utf8Path) -> Result<Option<Sha256>, AppError> {
52        if !destination.is_file() {
53            return Ok(None);
54        }
55        let bytes = std::fs::read(destination)?;
56        let digest = Sha256::of(&bytes);
57        let held = self.backup_path(&digest);
58        if !held.is_file() {
59            crate::adapters::fs::write_atomic(&held, &bytes)?;
60        }
61        sync_dir(&self.backups)?;
62        Ok(Some(digest))
63    }
64
65    /// Put a backed-up copy back at `destination`.
66    ///
67    /// # Errors
68    ///
69    /// [`AppError::Unrecovered`] when the copy is gone, and any I/O error
70    /// of the write.
71    pub fn restore(&self, digest: &Sha256, destination: &Utf8Path) -> Result<(), AppError> {
72        let held = self.backup_path(digest);
73        let bytes = std::fs::read(&held).map_err(|source| {
74            AppError::Unrecovered(format!(
75                "the copy of {destination} is not in the backup store at {held}: {source}"
76            ))
77        })?;
78        crate::adapters::fs::write_atomic(destination, &bytes)?;
79        sync_parent(destination)?;
80        Ok(())
81    }
82
83    /// Write `bytes` to a scratch file beside `destination`.
84    ///
85    /// The scratch file is created exclusively, so a path already there —
86    /// a symlink included — refuses rather than being followed.
87    ///
88    /// # Errors
89    ///
90    /// Any I/O error creating the parent, the scratch file, or syncing it.
91    pub fn write(destination: &Utf8Path, bytes: &[u8]) -> Result<Utf8PathBuf, AppError> {
92        use std::io::Write as _;
93
94        if let Some(parent) = destination.parent() {
95            std::fs::create_dir_all(parent)?;
96        }
97        let scratch = scratch_for(destination);
98        let mut handle = std::fs::OpenOptions::new()
99            .write(true)
100            .create_new(true)
101            .open(&scratch)
102            .map_err(|source| {
103                std::io::Error::new(
104                    source.kind(),
105                    format!("{scratch}: {source}; remove the scratch file to retry"),
106                )
107            })?;
108        let written = handle.write_all(bytes).and_then(|()| handle.sync_all());
109        drop(handle);
110        if let Err(source) = written {
111            let _ = std::fs::remove_file(&scratch);
112            return Err(AppError::Io(source));
113        }
114        Ok(scratch)
115    }
116
117    /// Rename a staged file over its destination and sync the directory.
118    ///
119    /// # Errors
120    ///
121    /// Any I/O error of the rename or the sync. The scratch file is removed
122    /// on failure, so a retry is not blocked by its own leftover.
123    pub fn replace(scratch: &Utf8Path, destination: &Utf8Path) -> Result<(), AppError> {
124        if let Err(source) = std::fs::rename(scratch, destination) {
125            let _ = std::fs::remove_file(scratch);
126            return Err(AppError::Io(source));
127        }
128        sync_parent(destination)?;
129        Ok(())
130    }
131
132    /// Drop a staged file that will not be used.
133    pub fn discard(scratch: &Utf8Path) {
134        let _ = std::fs::remove_file(scratch);
135    }
136}
137
138/// The scratch path one destination stages through.
139#[must_use]
140pub fn scratch_for(destination: &Utf8Path) -> Utf8PathBuf {
141    Utf8PathBuf::from(format!("{destination}{SCRATCH_SUFFIX}"))
142}
143
144#[cfg(test)]
145mod tests {
146    #![allow(
147        clippy::unwrap_used,
148        reason = "a test panics as its failure signal, not as control flow"
149    )]
150
151    use super::*;
152
153    fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
154        Utf8PathBuf::from(dir.path().to_str().unwrap())
155    }
156
157    #[test]
158    fn a_staged_file_sits_beside_its_destination_and_lands_by_rename() {
159        let dir = tempfile::tempdir().unwrap();
160        let destination = root(&dir).join("a/b/SKILL.md");
161        let scratch = Stage::write(&destination, b"new\n").unwrap();
162        assert_eq!(scratch.parent(), destination.parent());
163        assert!(!destination.exists());
164        Stage::replace(&scratch, &destination).unwrap();
165        assert_eq!(std::fs::read(&destination).unwrap(), b"new\n");
166        assert!(!scratch.exists());
167    }
168
169    #[test]
170    fn a_backup_round_trips_and_an_absent_destination_has_none() {
171        let dir = tempfile::tempdir().unwrap();
172        let stage = Stage::new(&root(&dir).join("backups")).unwrap();
173        let destination = root(&dir).join("a/SKILL.md");
174        assert_eq!(stage.back_up(&destination).unwrap(), None);
175
176        crate::adapters::fs::write_file(&destination, b"held\n").unwrap();
177        let digest = stage.back_up(&destination).unwrap().unwrap();
178        std::fs::write(&destination, b"replaced\n").unwrap();
179        stage.restore(&digest, &destination).unwrap();
180        assert_eq!(std::fs::read(&destination).unwrap(), b"held\n");
181    }
182
183    #[test]
184    fn a_pre_existing_scratch_path_refuses_rather_than_being_followed() {
185        let dir = tempfile::tempdir().unwrap();
186        let destination = root(&dir).join("SKILL.md");
187        let victim = root(&dir).join("victim");
188        std::fs::write(&victim, b"keep\n").unwrap();
189        std::os::unix::fs::symlink(&victim, scratch_for(&destination).as_std_path()).unwrap();
190        assert!(Stage::write(&destination, b"new\n").is_err());
191        assert_eq!(std::fs::read(&victim).unwrap(), b"keep\n");
192    }
193
194    #[test]
195    fn a_missing_backup_reports_an_unrecovered_run() {
196        let dir = tempfile::tempdir().unwrap();
197        let stage = Stage::new(&root(&dir).join("backups")).unwrap();
198        let error = stage
199            .restore(&Sha256::of(b"absent"), &root(&dir).join("x"))
200            .unwrap_err();
201        assert_eq!(error.kind(), "Unrecovered");
202        assert_eq!(error.exit_code(), 73);
203    }
204}