Skip to main content

spec_driven_docs/transaction/
stage.rs

1//! Scratch files beside their destinations.
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.
6
7use camino::{Utf8Path, Utf8PathBuf};
8
9use crate::error::AppError;
10use crate::transaction::sync_parent;
11
12/// The suffix a staged file carries until it is renamed into place.
13const SCRATCH_SUFFIX: &str = ".sdd-stage";
14
15/// The staging operations, which hold no state of their own.
16#[derive(Debug, Clone, Copy)]
17pub struct Stage;
18
19impl Stage {
20    /// Write `bytes` to a scratch file beside `destination`.
21    ///
22    /// The name carries this run, and the file is created exclusively, so
23    /// the write never lands in one somebody else left. A scratch path
24    /// already taken refuses rather than being reused: nothing proves an
25    /// existing file came from a run of this tool, and renaming it over
26    /// the destination would replace the destination with its contents.
27    ///
28    /// # Errors
29    ///
30    /// Any I/O error creating the parent, the scratch file, or syncing it.
31    pub fn write(destination: &Utf8Path, bytes: &[u8]) -> Result<Utf8PathBuf, AppError> {
32        Self::write_at(&scratch_for(destination), destination, bytes)
33    }
34
35    /// Write `bytes` to one named scratch path beside `destination`.
36    ///
37    /// The path is the caller's, which is what lets a test plant something
38    /// at it and prove the exclusive create refuses rather than follows.
39    ///
40    /// # Errors
41    ///
42    /// As [`Stage::write`].
43    pub fn write_at(
44        scratch: &Utf8Path,
45        destination: &Utf8Path,
46        bytes: &[u8],
47    ) -> Result<Utf8PathBuf, AppError> {
48        use std::io::Write as _;
49
50        if let Some(parent) = destination.parent() {
51            std::fs::create_dir_all(parent)?;
52        }
53        let scratch = scratch.to_owned();
54        let mut handle = std::fs::OpenOptions::new()
55            .write(true)
56            .create_new(true)
57            .open(&scratch)
58            .map_err(|source| {
59                std::io::Error::new(
60                    source.kind(),
61                    format!("{scratch}: {source}; move it aside and run this again"),
62                )
63            })?;
64        let written = handle.write_all(bytes).and_then(|()| handle.sync_all());
65        drop(handle);
66        if let Err(source) = written {
67            let _ = std::fs::remove_file(&scratch);
68            return Err(AppError::Io(source));
69        }
70        Ok(scratch)
71    }
72
73    /// Rename a staged file over its destination and sync the directory.
74    ///
75    /// # Errors
76    ///
77    /// Any I/O error of the rename or the sync. The scratch file is removed
78    /// on failure, so a retry is not blocked by its own leftover.
79    pub fn replace(scratch: &Utf8Path, destination: &Utf8Path) -> Result<(), AppError> {
80        if let Err(source) = std::fs::rename(scratch, destination) {
81            let _ = std::fs::remove_file(scratch);
82            return Err(AppError::Io(source));
83        }
84        sync_parent(destination)?;
85        Ok(())
86    }
87
88    /// Drop a staged file that will not be used.
89    pub fn discard(scratch: &Utf8Path) {
90        let _ = std::fs::remove_file(scratch);
91    }
92}
93
94/// The scratch path one destination stages through.
95///
96/// The name carries the process and a counter, so two runs never choose
97/// one path and a leftover never looks like this run's own.
98#[must_use]
99pub fn scratch_for(destination: &Utf8Path) -> Utf8PathBuf {
100    use std::sync::atomic::{AtomicU64, Ordering};
101    static NEXT: AtomicU64 = AtomicU64::new(0);
102    let serial = NEXT.fetch_add(1, Ordering::Relaxed);
103    Utf8PathBuf::from(format!(
104        "{destination}{SCRATCH_SUFFIX}.{}-{serial}",
105        std::process::id()
106    ))
107}
108
109#[cfg(test)]
110mod tests {
111    #![allow(
112        clippy::unwrap_used,
113        reason = "a test panics as its failure signal, not as control flow"
114    )]
115
116    use super::*;
117
118    fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
119        Utf8PathBuf::from(dir.path().to_str().unwrap())
120    }
121
122    #[test]
123    fn a_staged_file_sits_beside_its_destination_and_lands_by_rename() {
124        let dir = tempfile::tempdir().unwrap();
125        let destination = root(&dir).join("a/b/SKILL.md");
126        let scratch = Stage::write(&destination, b"new\n").unwrap();
127        assert_eq!(scratch.parent(), destination.parent());
128        assert!(!destination.exists());
129        Stage::replace(&scratch, &destination).unwrap();
130        assert_eq!(std::fs::read(&destination).unwrap(), b"new\n");
131        assert!(!scratch.exists());
132    }
133
134    #[test]
135    fn a_pre_existing_scratch_path_refuses_rather_than_being_followed() {
136        let dir = tempfile::tempdir().unwrap();
137        let destination = root(&dir).join("SKILL.md");
138        let scratch = root(&dir).join("SKILL.md.sdd-stage.taken");
139        let victim = root(&dir).join("victim");
140        std::fs::write(&victim, b"keep\n").unwrap();
141        std::os::unix::fs::symlink(&victim, scratch.as_std_path()).unwrap();
142        assert!(Stage::write_at(&scratch, &destination, b"new\n").is_err());
143        assert_eq!(std::fs::read(&victim).unwrap(), b"keep\n");
144    }
145
146    #[test]
147    fn a_leftover_scratch_file_is_never_reused_as_this_runs_own() {
148        let dir = tempfile::tempdir().unwrap();
149        let destination = root(&dir).join("SKILL.md");
150        // What a stopped run leaves, under the suffix but not this run's
151        // name. It is neither read nor renamed over the destination.
152        let leftover = root(&dir).join("SKILL.md.sdd-stage.1-0");
153        std::fs::write(leftover.as_std_path(), b"half a write").unwrap();
154
155        let scratch = Stage::write(&destination, b"new\n").unwrap();
156        assert_ne!(scratch, leftover);
157        Stage::replace(&scratch, &destination).unwrap();
158        assert_eq!(std::fs::read(&destination).unwrap(), b"new\n");
159        assert_eq!(std::fs::read(&leftover).unwrap(), b"half a write");
160    }
161
162    #[test]
163    fn two_scratch_paths_for_one_destination_never_collide() {
164        let destination = Utf8Path::new("/work/AGENTS.md");
165        assert_ne!(scratch_for(destination), scratch_for(destination));
166    }
167}