spec_driven_docs/transaction/
journal.rs1use camino::{Utf8Path, Utf8PathBuf};
19use serde::{Deserialize, Serialize};
20
21use crate::domain::ownership::Sha256;
22use crate::error::AppError;
23use crate::transaction::stage::Stage;
24use crate::transaction::sync_parent;
25
26pub const SCHEMA_VERSION: u32 = 1;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "kebab-case")]
32pub enum EntryState {
33 Planned,
35 Done,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct Entry {
42 pub destination: Utf8PathBuf,
44 pub before: Option<Sha256>,
46 pub after: Option<Sha256>,
48 pub state: EntryState,
50}
51
52impl Entry {
53 #[must_use]
55 pub const fn write(destination: Utf8PathBuf, before: Option<Sha256>, after: Sha256) -> Self {
56 Self {
57 destination,
58 before,
59 after: Some(after),
60 state: EntryState::Planned,
61 }
62 }
63
64 #[must_use]
66 pub const fn remove(destination: Utf8PathBuf, before: Sha256) -> Self {
67 Self {
68 destination,
69 before: Some(before),
70 after: None,
71 state: EntryState::Planned,
72 }
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct Record {
80 pub schema_version: u32,
82 pub backups: Utf8PathBuf,
84 pub entries: Vec<Entry>,
86}
87
88#[derive(Debug)]
90pub struct Journal {
91 path: Utf8PathBuf,
92 record: Record,
93}
94
95fn write_record(path: &Utf8Path, record: &Record) -> Result<(), AppError> {
96 let text = serde_json::to_string_pretty(record)
97 .map_err(|source| anyhow::anyhow!("the journal did not serialize: {source}"))?;
98 crate::adapters::fs::write_atomic(path, text.as_bytes())?;
99 sync_parent(path)?;
100 Ok(())
101}
102
103impl Journal {
104 pub fn begin(
110 path: &Utf8Path,
111 backups: &Utf8Path,
112 entries: Vec<Entry>,
113 ) -> Result<Self, AppError> {
114 let record = Record {
115 schema_version: SCHEMA_VERSION,
116 backups: backups.to_owned(),
117 entries,
118 };
119 write_record(path, &record)?;
120 Ok(Self {
121 path: path.to_owned(),
122 record,
123 })
124 }
125
126 pub fn mark_done(&mut self, destination: &Utf8Path) -> Result<(), AppError> {
132 for entry in &mut self.record.entries {
133 if entry.destination == destination {
134 entry.state = EntryState::Done;
135 }
136 }
137 write_record(&self.path, &self.record)
138 }
139
140 pub fn roll_back(&self) -> Result<(), AppError> {
146 roll_back_record(&self.path, &self.record)
147 }
148
149 pub fn finish(&mut self) -> Result<(), AppError> {
155 std::fs::remove_file(&self.path)?;
156 sync_parent(&self.path).map_err(AppError::Io)
161 }
162
163 #[must_use]
165 pub const fn record(&self) -> &Record {
166 &self.record
167 }
168}
169
170fn roll_back_record(path: &Utf8Path, record: &Record) -> Result<(), AppError> {
172 let stage = Stage::new(&record.backups)?;
173 let mut failed: Vec<String> = Vec::new();
174 for entry in &record.entries {
175 let restored = entry.before.as_ref().map_or_else(
178 || remove_if_present(&entry.destination),
179 |digest| {
180 if already_holds(&entry.destination, digest) {
181 Ok(())
182 } else {
183 stage.restore(digest, &entry.destination)
184 }
185 },
186 );
187 if let Err(source) = restored {
188 failed.push(format!("{}: {source}", entry.destination));
189 }
190 }
191 if !failed.is_empty() {
192 return Err(AppError::Unrecovered(format!(
193 "{path} names destinations that could not be put back: {}",
194 failed.join("; ")
195 )));
196 }
197 std::fs::remove_file(path)?;
201 sync_parent(path)?;
202 Ok(())
203}
204
205fn already_holds(destination: &Utf8Path, digest: &Sha256) -> bool {
206 std::fs::read(destination).is_ok_and(|found| &Sha256::of(&found) == digest)
207}
208
209fn remove_if_present(destination: &Utf8Path) -> Result<(), AppError> {
210 match std::fs::remove_file(destination) {
211 Ok(()) => sync_parent(destination).map_err(AppError::Io),
212 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
213 Err(source) => Err(AppError::Io(source)),
214 }
215}
216
217pub fn recover(path: &Utf8Path) -> Result<bool, AppError> {
229 let text = match std::fs::read_to_string(path) {
230 Ok(text) => text,
231 Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(false),
232 Err(source) => return Err(AppError::Io(source)),
233 };
234 let record: Record = serde_json::from_str(&text).map_err(|source| {
235 AppError::Unrecovered(format!(
236 "{path} is a journal this binary cannot read: {source}; move it aside once you have checked the destinations it names"
237 ))
238 })?;
239 if record.schema_version != SCHEMA_VERSION {
240 return Err(AppError::Unrecovered(format!(
241 "{path} is journal schema {}, and this binary recovers schema {SCHEMA_VERSION}",
242 record.schema_version
243 )));
244 }
245 roll_back_record(path, &record)?;
246 Ok(true)
247}
248
249#[cfg(test)]
250mod tests {
251 #![allow(
252 clippy::unwrap_used,
253 reason = "a test panics as its failure signal, not as control flow"
254 )]
255
256 use super::*;
257
258 fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
259 Utf8PathBuf::from(dir.path().to_str().unwrap())
260 }
261
262 #[test]
263 fn a_recovery_restores_what_was_there_and_removes_what_was_not() {
264 let dir = tempfile::tempdir().unwrap();
265 let backups = root(&dir).join("backups");
266 let stage = Stage::new(&backups).unwrap();
267 let held = root(&dir).join("a/held.md");
268 let fresh = root(&dir).join("a/fresh.md");
269 crate::adapters::fs::write_file(&held, b"before\n").unwrap();
270 let before = stage.back_up(&held).unwrap().unwrap();
271
272 let path = root(&dir).join("run.journal");
273 let mut journal = Journal::begin(
274 &path,
275 &backups,
276 vec![
277 Entry::write(held.clone(), Some(before), Sha256::of(b"after\n")),
278 Entry::write(fresh.clone(), None, Sha256::of(b"after\n")),
279 ],
280 )
281 .unwrap();
282 std::fs::write(&held, b"after\n").unwrap();
283 journal.mark_done(&held).unwrap();
284 crate::adapters::fs::write_file(&fresh, b"after\n").unwrap();
285
286 drop(journal);
288 assert!(recover(&path).unwrap());
289 assert_eq!(std::fs::read(&held).unwrap(), b"before\n");
290 assert!(!fresh.exists());
291 assert!(!path.exists());
292 assert!(!recover(&path).unwrap());
293 }
294
295 #[test]
296 fn recovery_is_idempotent_across_a_second_interruption() {
297 let dir = tempfile::tempdir().unwrap();
298 let backups = root(&dir).join("backups");
299 let stage = Stage::new(&backups).unwrap();
300 let first = root(&dir).join("first.md");
301 let second = root(&dir).join("second.md");
302 crate::adapters::fs::write_file(&first, b"one\n").unwrap();
303 crate::adapters::fs::write_file(&second, b"two\n").unwrap();
304 let one = stage.back_up(&first).unwrap().unwrap();
305 let two = stage.back_up(&second).unwrap().unwrap();
306
307 let path = root(&dir).join("run.journal");
308 Journal::begin(
309 &path,
310 &backups,
311 vec![
312 Entry::write(first.clone(), Some(one), Sha256::of(b"new\n")),
313 Entry::write(second.clone(), Some(two), Sha256::of(b"new\n")),
314 ],
315 )
316 .unwrap();
317 std::fs::write(&first, b"new\n").unwrap();
318 std::fs::write(&second, b"new\n").unwrap();
319
320 recover(&path).unwrap();
323 assert_eq!(std::fs::read(&first).unwrap(), b"one\n");
324 assert_eq!(std::fs::read(&second).unwrap(), b"two\n");
325 assert!(!recover(&path).unwrap());
326 }
327
328 #[test]
329 fn a_finished_run_leaves_no_journal() {
330 let dir = tempfile::tempdir().unwrap();
331 let path = root(&dir).join("run.journal");
332 let mut journal = Journal::begin(&path, &root(&dir).join("backups"), Vec::new()).unwrap();
333 assert!(path.is_file());
334 journal.finish().unwrap();
335 assert!(!path.exists());
336 }
337
338 #[test]
339 fn an_unreadable_journal_refuses_rather_than_starting_over() {
340 let dir = tempfile::tempdir().unwrap();
341 let path = root(&dir).join("run.journal");
342 std::fs::write(&path, "{not json").unwrap();
343 let error = recover(&path).unwrap_err();
344 assert_eq!(error.kind(), "Unrecovered");
345 assert!(path.is_file(), "the unreadable journal was deleted");
346 }
347}