1use std::io;
53use std::path::{Path, PathBuf};
54
55use crate::ports::{Clock, GitRepo, IdGen, JournalLock, JournalStore};
56use crate::protocol::journal::{
57 EventKind, JournalEvent, Phase, PhaseOutcome, PhaseRecord, RunState, RunStatus,
58 JOURNAL_SCHEMA_VERSION,
59};
60
61#[derive(Debug, Clone)]
68pub struct JournalPaths {
69 releases_dir: PathBuf,
70}
71
72impl JournalPaths {
73 pub fn new(releases_dir: impl Into<PathBuf>) -> Self {
76 Self {
77 releases_dir: releases_dir.into(),
78 }
79 }
80
81 pub fn from_git(git: &dyn GitRepo, override_dir: Option<&Path>) -> io::Result<Self> {
88 let releases_dir = match override_dir {
89 Some(dir) => dir.to_path_buf(),
90 None => git.git_common_dir()?.join("ossctl").join("releases"),
93 };
94 Ok(Self { releases_dir })
95 }
96
97 #[must_use]
99 pub fn releases_dir(&self) -> &Path {
100 &self.releases_dir
101 }
102
103 #[must_use]
105 pub fn plans_dir(&self) -> PathBuf {
106 self.releases_dir
107 .parent()
108 .expect("release paths always have the legacy-compatible ossctl parent")
109 .join("plans")
110 }
111
112 #[must_use]
114 pub fn plan_file(&self, plan_id: &str) -> PathBuf {
115 self.plans_dir().join(format!("{plan_id}.json"))
116 }
117
118 #[must_use]
121 pub fn discarded_plan_file(&self, plan_id: &str) -> PathBuf {
122 self.plans_dir()
123 .join("discarded")
124 .join(format!("{plan_id}.discarded"))
125 }
126
127 #[must_use]
129 pub fn lock_file(&self) -> PathBuf {
130 self.releases_dir.join(".lock")
131 }
132
133 #[must_use]
135 pub fn run_dir(&self, run_id: &str) -> PathBuf {
136 self.releases_dir.join(run_id)
137 }
138
139 #[must_use]
141 pub fn journal_file(&self, run_id: &str) -> PathBuf {
142 self.run_dir(run_id).join("journal.jsonl")
143 }
144
145 #[must_use]
147 pub fn manifest_file(&self, run_id: &str) -> PathBuf {
148 self.run_dir(run_id).join("manifest.json")
149 }
150}
151
152#[must_use]
161pub fn reduce(events: &[JournalEvent]) -> RunState {
162 let mut ordered: Vec<&JournalEvent> = events.iter().collect();
163 ordered.sort_by_key(|e| e.seq);
164 let mut state = RunState::empty();
165 for ev in ordered {
166 apply(&mut state, ev);
167 }
168 state
169}
170
171#[allow(clippy::too_many_lines)] pub fn apply(state: &mut RunState, event: &JournalEvent) {
185 if event.seq <= state.applied_seq {
188 return;
189 }
190 if matches!(state.status, RunStatus::Completed | RunStatus::Abandoned) {
194 state.applied_seq = event.seq;
195 return;
196 }
197 match &event.kind {
198 EventKind::RunCreated {
199 run_id,
200 plan_id,
201 version,
202 targets,
203 head_sha,
204 bump,
205 } => {
206 state.run_id.clone_from(run_id);
207 state.plan_id.clone_from(plan_id);
208 state.version.clone_from(version);
209 state.targets.clone_from(targets);
210 state.head_sha.clone_from(head_sha);
211 state.bump_inputs.clone_from(bump);
212 state.created_ts = event.ts;
213 state.terminal_phase = Some(match event.schema_version {
214 0 | 1 => Phase::Tag,
215 2..=4 => Phase::Dist,
216 5 => Phase::Verify,
217 _ => Phase::AdvanceBranch,
218 });
219 state.status = RunStatus::InProgress;
220 }
221 EventKind::BumpApplied {
222 commit,
223 effective_date,
224 } => {
225 state.bump = Some(crate::protocol::journal::BumpRecord {
226 commit: commit.clone(),
227 effective_date: effective_date.clone(),
228 });
229 }
230 EventKind::PhaseEntered { phase } => {
231 state.current_phase = Some(*phase);
232 }
233 EventKind::PhaseCompleted { phase, outcome } => {
234 upsert_phase(&mut state.phases, *phase, *outcome);
235 if state.current_phase == Some(*phase) {
236 state.current_phase = None;
237 }
238 let completes = state.terminal_phase == Some(*phase);
253 if completes && *outcome == PhaseOutcome::Ok {
254 state.status = RunStatus::Completed;
255 }
256 }
257 EventKind::TargetDryRun { target } => {
258 state.dry_run.insert(target.clone());
259 }
260 EventKind::TargetBuilt { target } => {
261 state.built.insert(target.clone());
262 }
263 EventKind::TargetPublished { target, receipt } => {
264 state.published.insert(target.clone(), receipt.clone());
265 }
266 EventKind::TargetCancelled { target, reason } => {
267 state.cancelled.insert(target.clone(), reason.clone());
268 }
269 EventKind::TargetDelegated { target, adapter } => {
270 state.delegated.insert(target.clone());
271 state
272 .delegated_adapters
273 .insert(target.clone(), adapter.clone());
274 }
275 EventKind::TargetVerified { target, outcome } => {
276 state.verified.insert(target.clone(), *outcome);
277 }
278 EventKind::TagCreatedLocal { tag } => {
279 state.tags.entry(tag.clone()).or_default().created_local = true;
280 }
281 EventKind::TagPushedRemote { tag } => {
282 state.tags.entry(tag.clone()).or_default().pushed_remote = true;
283 }
284 EventKind::DefaultBranchSelected { branch } => {
285 state.selected_default_branch = Some(branch.clone());
286 }
287 EventKind::DefaultBranchAdvanced { branch, commit } => {
288 state.default_branch = Some(crate::protocol::journal::DefaultBranchState {
289 branch: branch.clone(),
290 commit: commit.clone(),
291 });
292 }
293 EventKind::GithubReleaseCreated { tag, url } => {
294 let t = state.tags.entry(tag.clone()).or_default();
295 t.github_release = true;
296 t.github_release_url.clone_from(url);
297 }
298 EventKind::GithubReleaseDelegated { tag, .. } => {
299 state
300 .tags
301 .entry(tag.clone())
302 .or_default()
303 .github_release_delegated = true;
304 }
305 EventKind::RunAbandoned { reason } => {
306 state.status = RunStatus::Abandoned;
307 state.abandon_reason = Some(reason.clone());
308 }
309 }
310 state.applied_seq = event.seq;
311 state.updated_ts = event.ts;
312}
313
314fn upsert_phase(phases: &mut Vec<PhaseRecord>, phase: Phase, outcome: PhaseOutcome) {
317 if let Some(rec) = phases.iter_mut().find(|r| r.phase == phase) {
318 rec.outcome = outcome;
319 } else {
320 phases.push(PhaseRecord { phase, outcome });
321 phases.sort_by_key(|r| r.phase);
322 }
323}
324
325pub fn read_events(store: &dyn JournalStore, path: &Path) -> io::Result<Vec<JournalEvent>> {
338 #[derive(serde::Deserialize)]
342 struct Envelope {
343 schema_version: u32,
344 }
345
346 let lines = store.read_lines(path)?;
347 let mut events = Vec::with_capacity(lines.len());
348 for (idx, line) in lines.iter().enumerate() {
349 let trimmed = line.trim();
350 if trimmed.is_empty() {
351 continue;
352 }
353 if let Ok(envelope) = serde_json::from_str::<Envelope>(trimmed) {
357 if envelope.schema_version > JOURNAL_SCHEMA_VERSION {
358 return Err(io::Error::new(
359 io::ErrorKind::InvalidData,
360 format!(
361 "release journal {}: line {} has schema_version {} but this \
362 shipshape understands at most {}; upgrade shipshape to resume this run",
363 path.display(),
364 idx + 1,
365 envelope.schema_version,
366 JOURNAL_SCHEMA_VERSION
367 ),
368 ));
369 }
370 }
371 let event: JournalEvent = serde_json::from_str(trimmed).map_err(|e| {
372 io::Error::new(
373 io::ErrorKind::InvalidData,
374 format!(
375 "release journal {}: line {} is not a recognized event \
376 (corrupt, or written by a newer shipshape): {e}",
377 path.display(),
378 idx + 1
379 ),
380 )
381 })?;
382 events.push(event);
383 }
384 events.sort_by_key(|e| e.seq);
385 Ok(events)
386}
387
388fn validate_run_id(run_id: &str) -> io::Result<()> {
394 let bad = run_id.is_empty()
395 || run_id == "."
396 || run_id == ".."
397 || run_id.contains('/')
398 || run_id.contains('\\')
399 || run_id.contains('\0');
400 if bad {
401 return Err(io::Error::new(
402 io::ErrorKind::InvalidInput,
403 format!("invalid run id {run_id:?}: must be a single path segment"),
404 ));
405 }
406 Ok(())
407}
408
409pub fn load_state(
425 store: &dyn JournalStore,
426 paths: &JournalPaths,
427 run_id: &str,
428) -> io::Result<Option<RunState>> {
429 validate_run_id(run_id)?;
430 if let Some(bytes) = store.read(&paths.manifest_file(run_id))? {
432 if let Ok(state) = serde_json::from_slice::<RunState>(&bytes) {
433 if state.run_id == run_id && state.schema_version <= JOURNAL_SCHEMA_VERSION {
434 return Ok(Some(state));
435 }
436 }
437 }
439 let events = read_events(store, &paths.journal_file(run_id))?;
440 if events.is_empty() {
441 return Ok(None);
442 }
443 Ok(Some(reduce(&events)))
444}
445
446pub fn read_run_state(
463 store: &dyn JournalStore,
464 paths: &JournalPaths,
465 run_id: &str,
466) -> io::Result<Option<RunState>> {
467 validate_run_id(run_id)?;
468 let events = read_events(store, &paths.journal_file(run_id))?;
469 if events.is_empty() {
470 return Ok(None);
471 }
472 Ok(Some(reduce(&events)))
473}
474
475pub fn read_run(
493 store: &dyn JournalStore,
494 paths: &JournalPaths,
495 run_id: &str,
496) -> io::Result<Option<(Vec<JournalEvent>, RunState)>> {
497 validate_run_id(run_id)?;
498 let events = read_events(store, &paths.journal_file(run_id))?;
499 if events.is_empty() {
500 return Ok(None);
501 }
502 let state = reduce(&events);
503 Ok(Some((events, state)))
504}
505
506pub fn list_runs(store: &dyn JournalStore, paths: &JournalPaths) -> io::Result<Vec<String>> {
516 let mut runs: Vec<String> = store
517 .list_dir(paths.releases_dir())?
518 .into_iter()
519 .filter(|name| validate_run_id(name).is_ok())
520 .filter(|name| {
521 store
524 .read_lines(&paths.journal_file(name))
525 .is_ok_and(|lines| lines.iter().any(|l| !l.trim().is_empty()))
526 })
527 .collect();
528 runs.sort();
529 Ok(runs)
530}
531
532pub struct Journal<'a> {
541 store: &'a dyn JournalStore,
542 clock: &'a dyn Clock,
543 paths: JournalPaths,
544 run_id: String,
545 state: RunState,
546 _lock: Box<dyn JournalLock>,
548}
549
550impl<'a> Journal<'a> {
551 pub fn create(
559 store: &'a dyn JournalStore,
560 clock: &'a dyn Clock,
561 idgen: &dyn IdGen,
562 paths: JournalPaths,
563 plan_id: String,
564 version: String,
565 targets: Vec<String>,
566 ) -> io::Result<Self> {
567 Self::create_inner(
568 store, clock, idgen, paths, plan_id, version, targets, None, None,
569 )
570 }
571
572 #[allow(clippy::too_many_arguments)]
580 pub fn create_bump(
581 store: &'a dyn JournalStore,
582 clock: &'a dyn Clock,
583 idgen: &dyn IdGen,
584 paths: JournalPaths,
585 plan_id: String,
586 version: String,
587 targets: Vec<String>,
588 head_sha: String,
589 bump: crate::protocol::journal::BumpInputs,
590 ) -> io::Result<Self> {
591 Self::create_inner(
592 store,
593 clock,
594 idgen,
595 paths,
596 plan_id,
597 version,
598 targets,
599 Some(head_sha),
600 Some(bump),
601 )
602 }
603
604 #[allow(clippy::too_many_arguments)]
608 pub fn create_locked(
609 store: &'a dyn JournalStore,
610 clock: &'a dyn Clock,
611 idgen: &dyn IdGen,
612 paths: JournalPaths,
613 plan_id: String,
614 version: String,
615 targets: Vec<String>,
616 lock: Box<dyn JournalLock>,
617 ) -> io::Result<Self> {
618 Self::create_inner_locked(
619 store, clock, idgen, paths, plan_id, version, targets, None, None, lock,
620 )
621 }
622
623 #[allow(clippy::too_many_arguments)]
625 pub fn create_bump_locked(
626 store: &'a dyn JournalStore,
627 clock: &'a dyn Clock,
628 idgen: &dyn IdGen,
629 paths: JournalPaths,
630 plan_id: String,
631 version: String,
632 targets: Vec<String>,
633 head_sha: String,
634 bump: crate::protocol::journal::BumpInputs,
635 lock: Box<dyn JournalLock>,
636 ) -> io::Result<Self> {
637 Self::create_inner_locked(
638 store,
639 clock,
640 idgen,
641 paths,
642 plan_id,
643 version,
644 targets,
645 Some(head_sha),
646 Some(bump),
647 lock,
648 )
649 }
650
651 #[allow(clippy::too_many_arguments)]
652 fn create_inner(
653 store: &'a dyn JournalStore,
654 clock: &'a dyn Clock,
655 idgen: &dyn IdGen,
656 paths: JournalPaths,
657 plan_id: String,
658 version: String,
659 targets: Vec<String>,
660 head_sha: Option<String>,
661 bump: Option<crate::protocol::journal::BumpInputs>,
662 ) -> io::Result<Self> {
663 let lock = store.lock_exclusive(&paths.lock_file())?;
664 Self::create_inner_locked(
665 store, clock, idgen, paths, plan_id, version, targets, head_sha, bump, lock,
666 )
667 }
668
669 #[allow(clippy::too_many_arguments)]
670 fn create_inner_locked(
671 store: &'a dyn JournalStore,
672 clock: &'a dyn Clock,
673 idgen: &dyn IdGen,
674 paths: JournalPaths,
675 plan_id: String,
676 version: String,
677 targets: Vec<String>,
678 head_sha: Option<String>,
679 bump: Option<crate::protocol::journal::BumpInputs>,
680 lock: Box<dyn JournalLock>,
681 ) -> io::Result<Self> {
682 let run_id = idgen.new_id();
683 let mut journal = Self {
684 store,
685 clock,
686 paths,
687 run_id: run_id.clone(),
688 state: RunState::empty(),
689 _lock: lock,
690 };
691 journal.append(EventKind::RunCreated {
692 run_id,
693 plan_id,
694 version,
695 targets,
696 head_sha,
697 bump,
698 })?;
699 Ok(journal)
700 }
701
702 pub fn open(
715 store: &'a dyn JournalStore,
716 clock: &'a dyn Clock,
717 paths: JournalPaths,
718 run_id: &str,
719 ) -> io::Result<Self> {
720 validate_run_id(run_id)?;
721 let lock = store.lock_exclusive(&paths.lock_file())?;
722 let events = read_events(store, &paths.journal_file(run_id))?;
723 if events.is_empty() {
724 return Err(io::Error::new(
725 io::ErrorKind::NotFound,
726 format!("no release journal for run {run_id}"),
727 ));
728 }
729 let state = reduce(&events);
730 let journal = Self {
731 store,
732 clock,
733 paths,
734 run_id: run_id.to_string(),
735 state,
736 _lock: lock,
737 };
738 let _ = journal.persist_manifest();
740 Ok(journal)
741 }
742
743 #[must_use]
745 pub fn run_id(&self) -> &str {
746 &self.run_id
747 }
748
749 #[must_use]
751 pub fn state(&self) -> &RunState {
752 &self.state
753 }
754
755 #[must_use]
757 pub fn paths(&self) -> &JournalPaths {
758 &self.paths
759 }
760
761 pub fn append(&mut self, kind: EventKind) -> io::Result<&RunState> {
783 let event = JournalEvent {
784 schema_version: JOURNAL_SCHEMA_VERSION,
785 seq: self.state.applied_seq + 1,
786 ts: self.clock.now_unix(),
787 idempotency_key: kind.idempotency_key(),
788 kind,
789 };
790 let line = serde_json::to_string(&event).map_err(|e| {
791 io::Error::new(io::ErrorKind::InvalidData, format!("serialize event: {e}"))
792 })?;
793 self.store
795 .append_line(&self.paths.journal_file(&self.run_id), &line)?;
796 apply(&mut self.state, &event);
798 let _ = self.persist_manifest();
800 Ok(&self.state)
801 }
802
803 fn persist_manifest(&self) -> io::Result<()> {
805 let bytes = serde_json::to_vec_pretty(&self.state).map_err(|e| {
806 io::Error::new(
807 io::ErrorKind::InvalidData,
808 format!("serialize manifest: {e}"),
809 )
810 })?;
811 self.store
812 .write_atomic(&self.paths.manifest_file(&self.run_id), &bytes)
813 }
814}
815
816#[cfg(test)]
817mod tests {
818 use super::*;
819 use crate::protocol::journal::{PublishReceipt, TagState};
820 use std::cell::RefCell;
821 use std::collections::{HashMap, HashSet};
822 use std::rc::Rc;
823
824 #[derive(Default)]
827 struct StoreInner {
828 files: HashMap<PathBuf, Vec<u8>>,
830 locked: HashSet<PathBuf>,
832 fail_next_atomic: bool,
834 }
835
836 #[derive(Clone, Default)]
837 struct FakeStore {
838 inner: Rc<RefCell<StoreInner>>,
839 }
840
841 impl FakeStore {
842 fn journal_lines(&self, path: &Path) -> Vec<String> {
843 self.inner
844 .borrow()
845 .files
846 .get(path)
847 .map(|b| {
848 String::from_utf8_lossy(b)
849 .lines()
850 .map(str::to_string)
851 .collect()
852 })
853 .unwrap_or_default()
854 }
855
856 fn arm_atomic_failure(&self) {
857 self.inner.borrow_mut().fail_next_atomic = true;
858 }
859 }
860
861 struct FakeLock {
863 inner: Rc<RefCell<StoreInner>>,
864 path: PathBuf,
865 }
866
867 impl JournalLock for FakeLock {}
868
869 impl Drop for FakeLock {
870 fn drop(&mut self) {
871 self.inner.borrow_mut().locked.remove(&self.path);
872 }
873 }
874
875 impl JournalStore for FakeStore {
876 fn lock_exclusive(&self, lock_path: &Path) -> io::Result<Box<dyn JournalLock>> {
877 let mut inner = self.inner.borrow_mut();
878 if inner.locked.contains(lock_path) {
879 return Err(io::Error::new(
880 io::ErrorKind::WouldBlock,
881 "another release cut holds the lock",
882 ));
883 }
884 inner.locked.insert(lock_path.to_path_buf());
885 Ok(Box::new(FakeLock {
886 inner: Rc::clone(&self.inner),
887 path: lock_path.to_path_buf(),
888 }))
889 }
890
891 fn append_line(&self, path: &Path, line: &str) -> io::Result<()> {
892 let mut inner = self.inner.borrow_mut();
893 let buf = inner.files.entry(path.to_path_buf()).or_default();
894 buf.extend_from_slice(line.as_bytes());
895 buf.push(b'\n');
896 Ok(())
897 }
898
899 fn read_lines(&self, path: &Path) -> io::Result<Vec<String>> {
900 Ok(self.journal_lines(path))
901 }
902
903 fn read(&self, path: &Path) -> io::Result<Option<Vec<u8>>> {
904 Ok(self.inner.borrow().files.get(path).cloned())
905 }
906
907 fn write_atomic(&self, path: &Path, bytes: &[u8]) -> io::Result<()> {
908 let mut inner = self.inner.borrow_mut();
909 if inner.fail_next_atomic {
910 inner.fail_next_atomic = false;
911 return Err(io::Error::other("injected atomic-write crash"));
912 }
913 inner.files.insert(path.to_path_buf(), bytes.to_vec());
914 Ok(())
915 }
916
917 fn list_dir(&self, dir: &Path) -> io::Result<Vec<String>> {
918 let inner = self.inner.borrow();
919 let mut names: HashSet<String> = HashSet::new();
920 for path in inner.files.keys() {
921 if let Ok(rest) = path.strip_prefix(dir) {
923 if let Some(first) = rest.components().next() {
924 names.insert(first.as_os_str().to_string_lossy().into_owned());
925 }
926 }
927 }
928 Ok(names.into_iter().collect())
929 }
930 }
931
932 struct FakeClock {
933 t: std::cell::Cell<u64>,
934 }
935 impl FakeClock {
936 fn at(t: u64) -> Self {
937 Self {
938 t: std::cell::Cell::new(t),
939 }
940 }
941 }
942 impl Clock for FakeClock {
943 fn now_unix(&self) -> u64 {
944 let now = self.t.get();
945 self.t.set(now + 1); now
947 }
948 }
949
950 struct FakeIdGen {
951 id: String,
952 }
953 impl IdGen for FakeIdGen {
954 fn new_id(&self) -> String {
955 self.id.clone()
956 }
957 }
958
959 struct FakeGit {
960 common_dir: PathBuf,
961 }
962 impl GitRepo for FakeGit {
963 fn head_commit(&self) -> io::Result<String> {
964 Ok("deadbeef".into())
965 }
966 fn is_work_tree(&self) -> bool {
967 true
968 }
969 fn shortlog(&self, _since: Option<&str>) -> io::Result<String> {
970 Ok(String::new())
971 }
972 fn tags(&self) -> io::Result<Vec<String>> {
973 Ok(Vec::new())
974 }
975 fn git_common_dir(&self) -> io::Result<PathBuf> {
976 Ok(self.common_dir.clone())
977 }
978 }
979
980 fn paths() -> JournalPaths {
981 JournalPaths::new("/repo/.git/ossctl/releases")
982 }
983
984 fn receipt(version: &str) -> PublishReceipt {
985 PublishReceipt {
986 ecosystem: "cargo".into(),
987 package: Some("shipshape".into()),
988 version: version.into(),
989 registry_url: Some("https://crates.io/crates/shipshape".into()),
990 digest: Some("sha256:abc".into()),
991 }
992 }
993
994 fn sample_events() -> Vec<JournalEvent> {
996 let kinds = vec![
997 EventKind::RunCreated {
998 run_id: "RUN01".into(),
999 plan_id: "plan-abc".into(),
1000 version: "0.1.0".into(),
1001 targets: vec!["cargo".into(), "npm".into()],
1002 head_sha: None,
1003 bump: None,
1004 },
1005 EventKind::PhaseEntered {
1006 phase: Phase::DryRun,
1007 },
1008 EventKind::TargetDryRun {
1009 target: "cargo".into(),
1010 },
1011 EventKind::PhaseCompleted {
1012 phase: Phase::DryRun,
1013 outcome: PhaseOutcome::Ok,
1014 },
1015 EventKind::PhaseEntered {
1016 phase: Phase::Publish,
1017 },
1018 EventKind::TargetPublished {
1019 target: "cargo".into(),
1020 receipt: receipt("0.1.0"),
1021 },
1022 ];
1023 kinds
1024 .into_iter()
1025 .enumerate()
1026 .map(|(i, kind)| JournalEvent {
1027 schema_version: JOURNAL_SCHEMA_VERSION,
1028 seq: (i + 1) as u64,
1029 ts: 1000 + i as u64,
1030 idempotency_key: kind.idempotency_key(),
1031 kind,
1032 })
1033 .collect()
1034 }
1035
1036 #[test]
1039 fn paths_resolve_under_git_common_dir() {
1040 let git = FakeGit {
1041 common_dir: PathBuf::from("/repo/.git"),
1042 };
1043 let p = JournalPaths::from_git(&git, None).unwrap();
1044 assert_eq!(p.releases_dir(), Path::new("/repo/.git/ossctl/releases"));
1045 assert_eq!(
1046 p.journal_file("RUN01"),
1047 Path::new("/repo/.git/ossctl/releases/RUN01/journal.jsonl")
1048 );
1049 assert_eq!(
1050 p.manifest_file("RUN01"),
1051 Path::new("/repo/.git/ossctl/releases/RUN01/manifest.json")
1052 );
1053 assert_eq!(p.lock_file(), Path::new("/repo/.git/ossctl/releases/.lock"));
1054 }
1055
1056 #[test]
1057 fn path_override_wins_over_git() {
1058 let git = FakeGit {
1059 common_dir: PathBuf::from("/repo/.git"),
1060 };
1061 let p = JournalPaths::from_git(&git, Some(Path::new("/ci/journal"))).unwrap();
1062 assert_eq!(p.releases_dir(), Path::new("/ci/journal"));
1063 }
1064
1065 #[test]
1068 fn reduce_is_deterministic() {
1069 let events = sample_events();
1070 let a = reduce(&events);
1071 let b = reduce(&events);
1072 assert_eq!(a, b);
1073 assert_eq!(a.run_id, "RUN01");
1074 assert_eq!(a.plan_id, "plan-abc");
1075 assert_eq!(a.targets, vec!["cargo".to_string(), "npm".to_string()]);
1076 assert!(a.dry_run.contains("cargo"));
1077 assert_eq!(a.published.get("cargo").unwrap().version, "0.1.0");
1078 assert_eq!(a.current_phase, Some(Phase::Publish));
1080 assert_eq!(a.applied_seq, 6);
1081 }
1082
1083 #[test]
1084 fn reduce_ignores_slice_order() {
1085 let mut events = sample_events();
1086 events.reverse();
1087 let out = reduce(&events);
1088 assert_eq!(out, reduce(&sample_events()));
1090 }
1091
1092 #[test]
1093 fn replaying_a_seen_event_is_a_no_op() {
1094 let events = sample_events();
1095 let mut state = reduce(&events);
1096 let before = state.clone();
1097 apply(&mut state, &events[2]);
1099 assert_eq!(state, before);
1100 for ev in &events {
1102 apply(&mut state, ev);
1103 }
1104 assert_eq!(state, before);
1105 }
1106
1107 #[test]
1108 fn structural_idempotency_of_publish_and_tags() {
1109 let mut state = RunState::empty();
1112 let mk = |seq: u64, kind: EventKind| JournalEvent {
1113 schema_version: JOURNAL_SCHEMA_VERSION,
1114 seq,
1115 ts: seq,
1116 idempotency_key: kind.idempotency_key(),
1117 kind,
1118 };
1119 apply(
1120 &mut state,
1121 &mk(
1122 1,
1123 EventKind::RunCreated {
1124 run_id: "R".into(),
1125 plan_id: "p".into(),
1126 version: "0.1.0".into(),
1127 targets: vec!["cargo".into()],
1128 head_sha: None,
1129 bump: None,
1130 },
1131 ),
1132 );
1133 apply(
1134 &mut state,
1135 &mk(
1136 2,
1137 EventKind::TargetPublished {
1138 target: "cargo".into(),
1139 receipt: receipt("0.1.0"),
1140 },
1141 ),
1142 );
1143 apply(
1144 &mut state,
1145 &mk(
1146 3,
1147 EventKind::TargetPublished {
1148 target: "cargo".into(),
1149 receipt: receipt("0.1.1"),
1150 },
1151 ),
1152 );
1153 assert_eq!(state.published.len(), 1);
1154 assert_eq!(state.published.get("cargo").unwrap().version, "0.1.1");
1155
1156 apply(
1157 &mut state,
1158 &mk(
1159 4,
1160 EventKind::TagCreatedLocal {
1161 tag: "v0.1.1".into(),
1162 },
1163 ),
1164 );
1165 apply(
1166 &mut state,
1167 &mk(
1168 5,
1169 EventKind::TagPushedRemote {
1170 tag: "v0.1.1".into(),
1171 },
1172 ),
1173 );
1174 assert_eq!(
1175 state.tags.get("v0.1.1"),
1176 Some(&TagState {
1177 created_local: true,
1178 pushed_remote: true,
1179 github_release: false,
1180 github_release_url: None,
1181 github_release_delegated: false,
1182 })
1183 );
1184 }
1185
1186 #[test]
1187 fn github_release_delegation_reduces_to_the_tag_state_flag() {
1188 let mut state = RunState::empty();
1192 let mk = |seq: u64, kind: EventKind| JournalEvent {
1193 schema_version: JOURNAL_SCHEMA_VERSION,
1194 seq,
1195 ts: seq,
1196 idempotency_key: kind.idempotency_key(),
1197 kind,
1198 };
1199 apply(
1200 &mut state,
1201 &mk(
1202 1,
1203 EventKind::TagCreatedLocal {
1204 tag: "v1.0.0".into(),
1205 },
1206 ),
1207 );
1208 apply(
1209 &mut state,
1210 &mk(
1211 2,
1212 EventKind::TagPushedRemote {
1213 tag: "v1.0.0".into(),
1214 },
1215 ),
1216 );
1217 apply(
1218 &mut state,
1219 &mk(
1220 3,
1221 EventKind::GithubReleaseDelegated {
1222 tag: "v1.0.0".into(),
1223 delegated_to: "cargo-dist".into(),
1224 },
1225 ),
1226 );
1227 assert_eq!(
1228 state.tags.get("v1.0.0"),
1229 Some(&TagState {
1230 created_local: true,
1231 pushed_remote: true,
1232 github_release: false,
1233 github_release_url: None,
1234 github_release_delegated: true,
1235 })
1236 );
1237 }
1238
1239 #[test]
1240 fn verify_phase_ok_completes_a_v5_run_even_when_a_v6_binary_resumes_it() {
1241 let mut events = sample_events();
1242 events[0].schema_version = 5;
1245 let mut state = reduce(&events);
1246 assert_eq!(state.status, RunStatus::InProgress);
1247 let mut seq = state.applied_seq;
1248 let mut push = |state: &mut RunState, phase: Phase| {
1249 seq += 1;
1250 apply(
1251 state,
1252 &JournalEvent {
1253 schema_version: JOURNAL_SCHEMA_VERSION,
1254 seq,
1255 ts: 9000 + seq,
1256 idempotency_key: format!("phase_completed:{}", phase.as_str()),
1257 kind: EventKind::PhaseCompleted {
1258 phase,
1259 outcome: PhaseOutcome::Ok,
1260 },
1261 },
1262 );
1263 };
1264 push(&mut state, Phase::Tag);
1266 assert_eq!(state.status, RunStatus::InProgress);
1267 push(&mut state, Phase::Dist);
1268 assert_eq!(state.status, RunStatus::InProgress);
1269 push(&mut state, Phase::Verify);
1270 assert_eq!(state.status, RunStatus::Completed);
1271 }
1272
1273 #[test]
1274 fn a_v4_dist_ok_stays_completed_for_backward_compat() {
1275 let mut events = sample_events();
1276 events[0].schema_version = 4;
1277 let mut state = reduce(&events);
1278 let seq = state.applied_seq + 1;
1279 apply(
1280 &mut state,
1281 &JournalEvent {
1282 schema_version: 4,
1283 seq,
1284 ts: 9000,
1285 idempotency_key: "phase_completed:dist".into(),
1286 kind: EventKind::PhaseCompleted {
1287 phase: Phase::Dist,
1288 outcome: PhaseOutcome::Ok,
1289 },
1290 },
1291 );
1292 assert_eq!(state.status, RunStatus::Completed);
1293 }
1294
1295 #[test]
1296 fn a_v1_tag_ok_completes_the_run_for_backward_compat() {
1297 let mut events = sample_events();
1302 events[0].schema_version = 1;
1303 let mut state = reduce(&events);
1304 assert_eq!(state.status, RunStatus::InProgress);
1305 let seq = state.applied_seq + 1;
1306 apply(
1307 &mut state,
1308 &JournalEvent {
1309 schema_version: 1,
1310 seq,
1311 ts: 9000,
1312 idempotency_key: "phase_completed:tag".into(),
1313 kind: EventKind::PhaseCompleted {
1314 phase: Phase::Tag,
1315 outcome: PhaseOutcome::Ok,
1316 },
1317 },
1318 );
1319 assert_eq!(state.status, RunStatus::Completed);
1320 }
1321
1322 #[test]
1323 fn run_abandoned_is_terminal_with_reason() {
1324 let mut state = reduce(&sample_events());
1325 let seq = state.applied_seq + 1;
1326 apply(
1327 &mut state,
1328 &JournalEvent {
1329 schema_version: JOURNAL_SCHEMA_VERSION,
1330 seq,
1331 ts: 9000,
1332 idempotency_key: "run_abandoned".into(),
1333 kind: EventKind::RunAbandoned {
1334 reason: "OTP timeout".into(),
1335 },
1336 },
1337 );
1338 assert_eq!(state.status, RunStatus::Abandoned);
1339 assert_eq!(state.abandon_reason.as_deref(), Some("OTP timeout"));
1340 }
1341
1342 #[test]
1345 fn create_writes_run_created_and_manifest() {
1346 let store = FakeStore::default();
1347 let clock = FakeClock::at(1000);
1348 let idgen = FakeIdGen { id: "RUN01".into() };
1349 let journal = Journal::create(
1350 &store,
1351 &clock,
1352 &idgen,
1353 paths(),
1354 "plan-abc".into(),
1355 "0.1.0".into(),
1356 vec!["cargo".into()],
1357 )
1358 .unwrap();
1359 assert_eq!(journal.run_id(), "RUN01");
1360 assert_eq!(journal.state().run_id, "RUN01");
1361 assert_eq!(journal.state().applied_seq, 1);
1362
1363 let lines = store.journal_lines(&paths().journal_file("RUN01"));
1365 assert_eq!(lines.len(), 1);
1366 let manifest = store
1368 .inner
1369 .borrow()
1370 .files
1371 .get(&paths().manifest_file("RUN01"))
1372 .cloned()
1373 .unwrap();
1374 let loaded: RunState = serde_json::from_slice(&manifest).unwrap();
1375 assert_eq!(&loaded, journal.state());
1376 }
1377
1378 #[test]
1379 fn append_records_facts_and_a_failed_phase_can_later_complete_ok() {
1380 let store = FakeStore::default();
1384 let clock = FakeClock::at(1000);
1385 let idgen = FakeIdGen { id: "RUN01".into() };
1386 let mut journal = Journal::create(
1387 &store,
1388 &clock,
1389 &idgen,
1390 paths(),
1391 "plan-abc".into(),
1392 "0.1.0".into(),
1393 vec!["cargo".into()],
1394 )
1395 .unwrap();
1396 journal
1397 .append(EventKind::PhaseCompleted {
1398 phase: Phase::Publish,
1399 outcome: PhaseOutcome::Failed,
1400 })
1401 .unwrap();
1402 journal
1403 .append(EventKind::PhaseCompleted {
1404 phase: Phase::Publish,
1405 outcome: PhaseOutcome::Ok,
1406 })
1407 .unwrap();
1408 let lines = store.journal_lines(&paths().journal_file("RUN01"));
1410 assert_eq!(lines.len(), 3);
1411 let publish = journal
1413 .state()
1414 .phases
1415 .iter()
1416 .filter(|r| r.phase == Phase::Publish)
1417 .collect::<Vec<_>>();
1418 assert_eq!(publish.len(), 1);
1419 assert_eq!(publish[0].outcome, PhaseOutcome::Ok);
1420 }
1421
1422 #[test]
1423 fn terminal_state_freezes_further_events() {
1424 let mut state = reduce(&sample_events());
1426 let published_before = state.published.clone();
1427 let mut seq = state.applied_seq;
1428 let mut next = |kind: EventKind| {
1429 seq += 1;
1430 JournalEvent {
1431 schema_version: JOURNAL_SCHEMA_VERSION,
1432 seq,
1433 ts: 9000 + seq,
1434 idempotency_key: kind.idempotency_key(),
1435 kind,
1436 }
1437 };
1438 apply(
1439 &mut state,
1440 &next(EventKind::RunAbandoned {
1441 reason: "aborted".into(),
1442 }),
1443 );
1444 assert_eq!(state.status, RunStatus::Abandoned);
1445 apply(
1447 &mut state,
1448 &next(EventKind::TargetPublished {
1449 target: "npm".into(),
1450 receipt: receipt("9.9.9"),
1451 }),
1452 );
1453 assert_eq!(state.status, RunStatus::Abandoned);
1454 assert_eq!(state.published, published_before);
1455 assert!(!state.published.contains_key("npm"));
1456 }
1457
1458 #[test]
1461 fn event_survives_a_manifest_write_crash() {
1462 let store = FakeStore::default();
1463 let clock = FakeClock::at(1000);
1464 let idgen = FakeIdGen { id: "RUN01".into() };
1465 let mut journal = Journal::create(
1466 &store,
1467 &clock,
1468 &idgen,
1469 paths(),
1470 "plan-abc".into(),
1471 "0.1.0".into(),
1472 vec!["cargo".into()],
1473 )
1474 .unwrap();
1475 store.arm_atomic_failure();
1479 journal
1480 .append(EventKind::TargetPublished {
1481 target: "cargo".into(),
1482 receipt: receipt("0.1.0"),
1483 })
1484 .unwrap();
1485 drop(journal); let clock2 = FakeClock::at(2000);
1490 let reopened = Journal::open(&store, &clock2, paths(), "RUN01").unwrap();
1491 assert!(reopened.state().published.contains_key("cargo"));
1492 assert_eq!(reopened.state().applied_seq, 2);
1493 let manifest = store
1495 .inner
1496 .borrow()
1497 .files
1498 .get(&paths().manifest_file("RUN01"))
1499 .cloned()
1500 .unwrap();
1501 let loaded: RunState = serde_json::from_slice(&manifest).unwrap();
1502 assert_eq!(&loaded, reopened.state());
1503 }
1504
1505 #[test]
1506 fn open_reduces_from_journal_when_manifest_absent() {
1507 let store = FakeStore::default();
1509 for ev in sample_events() {
1510 let line = serde_json::to_string(&ev).unwrap();
1511 store
1512 .append_line(&paths().journal_file("RUN01"), &line)
1513 .unwrap();
1514 }
1515 let clock = FakeClock::at(1000);
1516 let journal = Journal::open(&store, &clock, paths(), "RUN01").unwrap();
1517 assert_eq!(journal.state(), &reduce(&sample_events()));
1518 }
1519
1520 #[test]
1523 fn second_create_fails_while_lock_is_held() {
1524 let store = FakeStore::default();
1525 let clock = FakeClock::at(1000);
1526 let idgen = FakeIdGen { id: "RUN01".into() };
1527 let held = Journal::create(
1528 &store,
1529 &clock,
1530 &idgen,
1531 paths(),
1532 "plan-abc".into(),
1533 "0.1.0".into(),
1534 vec!["cargo".into()],
1535 )
1536 .unwrap();
1537
1538 let clock2 = FakeClock::at(2000);
1540 let idgen2 = FakeIdGen { id: "RUN02".into() };
1541 let result = Journal::create(
1544 &store,
1545 &clock2,
1546 &idgen2,
1547 paths(),
1548 "plan-def".into(),
1549 "0.1.0".into(),
1550 vec!["cargo".into()],
1551 );
1552 let err = result.err().expect("concurrent create must fail");
1553 assert_eq!(err.kind(), io::ErrorKind::WouldBlock);
1554
1555 drop(held);
1557 let clock3 = FakeClock::at(3000);
1558 let idgen3 = FakeIdGen { id: "RUN02".into() };
1559 assert!(Journal::create(
1560 &store,
1561 &clock3,
1562 &idgen3,
1563 paths(),
1564 "plan-def".into(),
1565 "0.1.0".into(),
1566 vec!["cargo".into()],
1567 )
1568 .is_ok());
1569 }
1570
1571 #[test]
1574 fn load_state_is_read_only_and_takes_no_lock() {
1575 let store = FakeStore::default();
1576 let clock = FakeClock::at(1000);
1577 let idgen = FakeIdGen { id: "RUN01".into() };
1578 let journal = Journal::create(
1579 &store,
1580 &clock,
1581 &idgen,
1582 paths(),
1583 "plan-abc".into(),
1584 "0.1.0".into(),
1585 vec!["cargo".into()],
1586 )
1587 .unwrap();
1588 let loaded = load_state(&store, &paths(), "RUN01").unwrap().unwrap();
1590 assert_eq!(&loaded, journal.state());
1591 assert!(load_state(&store, &paths(), "MISSING").unwrap().is_none());
1592 }
1593
1594 #[test]
1595 fn load_state_prefers_manifest_but_falls_back_to_journal() {
1596 let store = FakeStore::default();
1597 let clock = FakeClock::at(1000);
1598 let idgen = FakeIdGen { id: "RUN01".into() };
1599 let journal = Journal::create(
1600 &store,
1601 &clock,
1602 &idgen,
1603 paths(),
1604 "plan-abc".into(),
1605 "0.1.0".into(),
1606 vec!["cargo".into()],
1607 )
1608 .unwrap();
1609 let expected = journal.state().clone();
1610 drop(journal);
1611
1612 assert_eq!(
1614 load_state(&store, &paths(), "RUN01").unwrap(),
1615 Some(expected.clone())
1616 );
1617
1618 store
1620 .write_atomic(&paths().manifest_file("RUN01"), b"{not json")
1621 .unwrap();
1622 assert_eq!(
1623 load_state(&store, &paths(), "RUN01").unwrap(),
1624 Some(expected)
1625 );
1626 }
1627
1628 #[test]
1629 fn read_run_state_reduces_from_journal_without_writing() {
1630 let store = FakeStore::default();
1632 for ev in sample_events() {
1633 let line = serde_json::to_string(&ev).unwrap();
1634 store
1635 .append_line(&paths().journal_file("RUN01"), &line)
1636 .unwrap();
1637 }
1638 let files_before = store.inner.borrow().files.clone();
1640
1641 let state = read_run_state(&store, &paths(), "RUN01").unwrap().unwrap();
1642 assert_eq!(state, reduce(&sample_events()));
1643
1644 assert_eq!(
1646 store.inner.borrow().files,
1647 files_before,
1648 "read_run_state must not write anything"
1649 );
1650 assert!(
1651 store.inner.borrow().locked.is_empty(),
1652 "read_run_state must not take the lock"
1653 );
1654 assert!(
1655 !store
1656 .inner
1657 .borrow()
1658 .files
1659 .contains_key(&paths().manifest_file("RUN01")),
1660 "read_run_state must not materialize a manifest"
1661 );
1662
1663 assert!(read_run_state(&store, &paths(), "MISSING")
1665 .unwrap()
1666 .is_none());
1667 assert_eq!(
1668 read_run_state(&store, &paths(), "../escape")
1669 .unwrap_err()
1670 .kind(),
1671 io::ErrorKind::InvalidInput
1672 );
1673 }
1674
1675 #[test]
1676 fn read_run_returns_events_and_reduced_state() {
1677 let store = FakeStore::default();
1679 for ev in sample_events() {
1680 let line = serde_json::to_string(&ev).unwrap();
1681 store
1682 .append_line(&paths().journal_file("RUN01"), &line)
1683 .unwrap();
1684 }
1685 let files_before = store.inner.borrow().files.clone();
1686
1687 let (events, state) = read_run(&store, &paths(), "RUN01").unwrap().unwrap();
1688 assert_eq!(events, sample_events());
1689 assert_eq!(state, reduce(&sample_events()));
1690
1691 assert_eq!(store.inner.borrow().files, files_before);
1693 assert!(store.inner.borrow().locked.is_empty());
1694
1695 assert!(read_run(&store, &paths(), "MISSING").unwrap().is_none());
1697 assert_eq!(
1698 read_run(&store, &paths(), "../escape").unwrap_err().kind(),
1699 io::ErrorKind::InvalidInput
1700 );
1701 }
1702
1703 #[test]
1704 fn read_run_state_ignores_a_stale_manifest_fast_path() {
1705 let store = FakeStore::default();
1708 for ev in sample_events() {
1709 let line = serde_json::to_string(&ev).unwrap();
1710 store
1711 .append_line(&paths().journal_file("RUN01"), &line)
1712 .unwrap();
1713 }
1714 let mut stale = RunState::empty();
1716 stale.run_id = "RUN01".into();
1717 stale.plan_id = "STALE".into();
1718 store
1719 .write_atomic(
1720 &paths().manifest_file("RUN01"),
1721 &serde_json::to_vec(&stale).unwrap(),
1722 )
1723 .unwrap();
1724
1725 assert_eq!(
1727 load_state(&store, &paths(), "RUN01")
1728 .unwrap()
1729 .unwrap()
1730 .plan_id,
1731 "STALE"
1732 );
1733 assert_eq!(
1734 read_run_state(&store, &paths(), "RUN01")
1735 .unwrap()
1736 .unwrap()
1737 .plan_id,
1738 "plan-abc",
1739 );
1740 }
1741
1742 #[test]
1743 fn run_id_validation_rejects_path_traversal() {
1744 let store = FakeStore::default();
1745 let clock = FakeClock::at(1000);
1746 for bad in ["..", "a/b", "", ".", "x/../y"] {
1747 assert_eq!(
1748 load_state(&store, &paths(), bad).unwrap_err().kind(),
1749 io::ErrorKind::InvalidInput,
1750 "load_state must reject run id {bad:?}"
1751 );
1752 let err = Journal::open(&store, &clock, paths(), bad).err().unwrap();
1753 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1754 }
1755 }
1756
1757 #[test]
1758 fn list_runs_excludes_lock_and_stray_files() {
1759 let store = FakeStore::default();
1760 let clock = FakeClock::at(1000);
1761 for id in ["RUN01", "RUN02"] {
1762 let idgen = FakeIdGen { id: id.into() };
1763 let _j = Journal::create(
1764 &store,
1765 &clock,
1766 &idgen,
1767 paths(),
1768 "plan".into(),
1769 "0.1.0".into(),
1770 vec!["cargo".into()],
1771 )
1772 .unwrap();
1773 }
1774 store
1777 .write_atomic(&paths().releases_dir().join("journal.jsonl.tmp"), b"x")
1778 .unwrap();
1779 store
1780 .write_atomic(&paths().releases_dir().join(".lock"), b"")
1781 .unwrap();
1782 let runs = list_runs(&store, &paths()).unwrap();
1783 assert_eq!(runs, vec!["RUN01".to_string(), "RUN02".to_string()]);
1784 }
1785
1786 #[test]
1789 fn read_events_refuses_a_too_new_schema_version() {
1790 let store = FakeStore::default();
1791 let mut ev = sample_events()[0].clone();
1792 ev.schema_version = JOURNAL_SCHEMA_VERSION + 1;
1793 let line = serde_json::to_string(&ev).unwrap();
1794 store
1795 .append_line(&paths().journal_file("RUN01"), &line)
1796 .unwrap();
1797 let err = read_events(&store, &paths().journal_file("RUN01")).unwrap_err();
1798 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1799 }
1800
1801 #[test]
1802 fn read_events_tolerates_unknown_additive_fields() {
1803 let store = FakeStore::default();
1805 let line = r#"{"schema_version":1,"seq":1,"ts":1000,"idempotency_key":"run_created","kind":"run_created","run_id":"R","plan_id":"p","version":"0.1.0","targets":[],"future_field":42}"#;
1806 store
1807 .append_line(&paths().journal_file("RUN01"), line)
1808 .unwrap();
1809 let events = read_events(&store, &paths().journal_file("RUN01")).unwrap();
1810 assert_eq!(events.len(), 1);
1811 assert_eq!(events[0].seq, 1);
1812 }
1813
1814 #[test]
1815 fn run_status_as_str_matches_serde() {
1816 for s in [
1817 RunStatus::InProgress,
1818 RunStatus::Completed,
1819 RunStatus::Abandoned,
1820 ] {
1821 assert_eq!(
1822 serde_json::to_value(s).unwrap(),
1823 serde_json::Value::String(s.as_str().to_string()),
1824 "as_str() drifted from serde for {s:?}"
1825 );
1826 }
1827 }
1828
1829 #[test]
1830 fn read_events_skips_blank_lines() {
1831 let store = FakeStore::default();
1832 store
1833 .append_line(&paths().journal_file("RUN01"), "")
1834 .unwrap();
1835 assert!(read_events(&store, &paths().journal_file("RUN01"))
1836 .unwrap()
1837 .is_empty());
1838 }
1839}