spec_driven_docs/transaction/
stage.rs1use camino::{Utf8Path, Utf8PathBuf};
8
9use crate::error::AppError;
10use crate::transaction::sync_parent;
11
12const SCRATCH_SUFFIX: &str = ".sdd-stage";
14
15#[derive(Debug, Clone, Copy)]
17pub struct Stage;
18
19impl Stage {
20 pub fn write(destination: &Utf8Path, bytes: &[u8]) -> Result<Utf8PathBuf, AppError> {
32 Self::write_at(&scratch_for(destination), destination, bytes)
33 }
34
35 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 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 pub fn discard(scratch: &Utf8Path) {
90 let _ = std::fs::remove_file(scratch);
91 }
92}
93
94#[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 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}