spec_driven_docs/transaction/
stage.rs1use camino::{Utf8Path, Utf8PathBuf};
10
11use crate::domain::ownership::Sha256;
12use crate::error::AppError;
13use crate::transaction::{sync_dir, sync_parent};
14
15const SCRATCH_SUFFIX: &str = ".sdd-stage";
17
18#[derive(Debug, Clone)]
20pub struct Stage {
21 backups: Utf8PathBuf,
22}
23
24impl Stage {
25 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 #[must_use]
39 pub fn backup_path(&self, digest: &Sha256) -> Utf8PathBuf {
40 self.backups.join(digest.to_string())
41 }
42
43 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 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 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 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 pub fn discard(scratch: &Utf8Path) {
134 let _ = std::fs::remove_file(scratch);
135 }
136}
137
138#[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}