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"),
91 };
92 Ok(Self { releases_dir })
93 }
94
95 #[must_use]
97 pub fn releases_dir(&self) -> &Path {
98 &self.releases_dir
99 }
100
101 #[must_use]
103 pub fn lock_file(&self) -> PathBuf {
104 self.releases_dir.join(".lock")
105 }
106
107 #[must_use]
109 pub fn run_dir(&self, run_id: &str) -> PathBuf {
110 self.releases_dir.join(run_id)
111 }
112
113 #[must_use]
115 pub fn journal_file(&self, run_id: &str) -> PathBuf {
116 self.run_dir(run_id).join("journal.jsonl")
117 }
118
119 #[must_use]
121 pub fn manifest_file(&self, run_id: &str) -> PathBuf {
122 self.run_dir(run_id).join("manifest.json")
123 }
124}
125
126#[must_use]
135pub fn reduce(events: &[JournalEvent]) -> RunState {
136 let mut ordered: Vec<&JournalEvent> = events.iter().collect();
137 ordered.sort_by_key(|e| e.seq);
138 let mut state = RunState::empty();
139 for ev in ordered {
140 apply(&mut state, ev);
141 }
142 state
143}
144
145pub fn apply(state: &mut RunState, event: &JournalEvent) {
158 if event.seq <= state.applied_seq {
161 return;
162 }
163 if matches!(state.status, RunStatus::Completed | RunStatus::Abandoned) {
167 state.applied_seq = event.seq;
168 return;
169 }
170 match &event.kind {
171 EventKind::RunCreated {
172 run_id,
173 plan_id,
174 version,
175 targets,
176 head_sha,
177 bump,
178 } => {
179 state.run_id.clone_from(run_id);
180 state.plan_id.clone_from(plan_id);
181 state.version.clone_from(version);
182 state.targets.clone_from(targets);
183 state.head_sha.clone_from(head_sha);
184 state.bump_inputs.clone_from(bump);
185 state.created_ts = event.ts;
186 state.status = RunStatus::InProgress;
187 }
188 EventKind::BumpApplied {
189 commit,
190 effective_date,
191 } => {
192 state.bump = Some(crate::protocol::journal::BumpRecord {
193 commit: commit.clone(),
194 effective_date: effective_date.clone(),
195 });
196 }
197 EventKind::PhaseEntered { phase } => {
198 state.current_phase = Some(*phase);
199 }
200 EventKind::PhaseCompleted { phase, outcome } => {
201 upsert_phase(&mut state.phases, *phase, *outcome);
202 if state.current_phase == Some(*phase) {
203 state.current_phase = None;
204 }
205 let completes = match phase {
220 Phase::Dist => true,
221 Phase::Tag => event.schema_version < 2,
222 _ => false,
223 };
224 if completes && *outcome == PhaseOutcome::Ok {
225 state.status = RunStatus::Completed;
226 }
227 }
228 EventKind::TargetDryRun { target } => {
229 state.dry_run.insert(target.clone());
230 }
231 EventKind::TargetBuilt { target } => {
232 state.built.insert(target.clone());
233 }
234 EventKind::TargetPublished { target, receipt } => {
235 state.published.insert(target.clone(), receipt.clone());
236 }
237 EventKind::TargetCancelled { target, reason } => {
238 state.cancelled.insert(target.clone(), reason.clone());
239 }
240 EventKind::TargetDelegated { target, .. } => {
241 state.delegated.insert(target.clone());
242 }
243 EventKind::TagCreatedLocal { tag } => {
244 state.tags.entry(tag.clone()).or_default().created_local = true;
245 }
246 EventKind::TagPushedRemote { tag } => {
247 state.tags.entry(tag.clone()).or_default().pushed_remote = true;
248 }
249 EventKind::GithubReleaseCreated { tag, url } => {
250 let t = state.tags.entry(tag.clone()).or_default();
251 t.github_release = true;
252 t.github_release_url.clone_from(url);
253 }
254 EventKind::GithubReleaseDelegated { tag, .. } => {
255 state
256 .tags
257 .entry(tag.clone())
258 .or_default()
259 .github_release_delegated = true;
260 }
261 EventKind::RunAbandoned { reason } => {
262 state.status = RunStatus::Abandoned;
263 state.abandon_reason = Some(reason.clone());
264 }
265 }
266 state.applied_seq = event.seq;
267 state.updated_ts = event.ts;
268}
269
270fn upsert_phase(phases: &mut Vec<PhaseRecord>, phase: Phase, outcome: PhaseOutcome) {
273 if let Some(rec) = phases.iter_mut().find(|r| r.phase == phase) {
274 rec.outcome = outcome;
275 } else {
276 phases.push(PhaseRecord { phase, outcome });
277 phases.sort_by_key(|r| r.phase);
278 }
279}
280
281pub fn read_events(store: &dyn JournalStore, path: &Path) -> io::Result<Vec<JournalEvent>> {
294 #[derive(serde::Deserialize)]
298 struct Envelope {
299 schema_version: u32,
300 }
301
302 let lines = store.read_lines(path)?;
303 let mut events = Vec::with_capacity(lines.len());
304 for (idx, line) in lines.iter().enumerate() {
305 let trimmed = line.trim();
306 if trimmed.is_empty() {
307 continue;
308 }
309 if let Ok(envelope) = serde_json::from_str::<Envelope>(trimmed) {
313 if envelope.schema_version > JOURNAL_SCHEMA_VERSION {
314 return Err(io::Error::new(
315 io::ErrorKind::InvalidData,
316 format!(
317 "release journal {}: line {} has schema_version {} but this \
318 ossctl understands at most {}; upgrade ossctl to resume this run",
319 path.display(),
320 idx + 1,
321 envelope.schema_version,
322 JOURNAL_SCHEMA_VERSION
323 ),
324 ));
325 }
326 }
327 let event: JournalEvent = serde_json::from_str(trimmed).map_err(|e| {
328 io::Error::new(
329 io::ErrorKind::InvalidData,
330 format!(
331 "release journal {}: line {} is not a recognized event \
332 (corrupt, or written by a newer ossctl): {e}",
333 path.display(),
334 idx + 1
335 ),
336 )
337 })?;
338 events.push(event);
339 }
340 events.sort_by_key(|e| e.seq);
341 Ok(events)
342}
343
344fn validate_run_id(run_id: &str) -> io::Result<()> {
350 let bad = run_id.is_empty()
351 || run_id == "."
352 || run_id == ".."
353 || run_id.contains('/')
354 || run_id.contains('\\')
355 || run_id.contains('\0');
356 if bad {
357 return Err(io::Error::new(
358 io::ErrorKind::InvalidInput,
359 format!("invalid run id {run_id:?}: must be a single path segment"),
360 ));
361 }
362 Ok(())
363}
364
365pub fn load_state(
381 store: &dyn JournalStore,
382 paths: &JournalPaths,
383 run_id: &str,
384) -> io::Result<Option<RunState>> {
385 validate_run_id(run_id)?;
386 if let Some(bytes) = store.read(&paths.manifest_file(run_id))? {
388 if let Ok(state) = serde_json::from_slice::<RunState>(&bytes) {
389 if state.run_id == run_id && state.schema_version <= JOURNAL_SCHEMA_VERSION {
390 return Ok(Some(state));
391 }
392 }
393 }
395 let events = read_events(store, &paths.journal_file(run_id))?;
396 if events.is_empty() {
397 return Ok(None);
398 }
399 Ok(Some(reduce(&events)))
400}
401
402pub fn read_run_state(
419 store: &dyn JournalStore,
420 paths: &JournalPaths,
421 run_id: &str,
422) -> io::Result<Option<RunState>> {
423 validate_run_id(run_id)?;
424 let events = read_events(store, &paths.journal_file(run_id))?;
425 if events.is_empty() {
426 return Ok(None);
427 }
428 Ok(Some(reduce(&events)))
429}
430
431pub fn read_run(
449 store: &dyn JournalStore,
450 paths: &JournalPaths,
451 run_id: &str,
452) -> io::Result<Option<(Vec<JournalEvent>, RunState)>> {
453 validate_run_id(run_id)?;
454 let events = read_events(store, &paths.journal_file(run_id))?;
455 if events.is_empty() {
456 return Ok(None);
457 }
458 let state = reduce(&events);
459 Ok(Some((events, state)))
460}
461
462pub fn list_runs(store: &dyn JournalStore, paths: &JournalPaths) -> io::Result<Vec<String>> {
472 let mut runs: Vec<String> = store
473 .list_dir(paths.releases_dir())?
474 .into_iter()
475 .filter(|name| validate_run_id(name).is_ok())
476 .filter(|name| {
477 store
480 .read_lines(&paths.journal_file(name))
481 .is_ok_and(|lines| lines.iter().any(|l| !l.trim().is_empty()))
482 })
483 .collect();
484 runs.sort();
485 Ok(runs)
486}
487
488pub struct Journal<'a> {
497 store: &'a dyn JournalStore,
498 clock: &'a dyn Clock,
499 paths: JournalPaths,
500 run_id: String,
501 state: RunState,
502 _lock: Box<dyn JournalLock>,
504}
505
506impl<'a> Journal<'a> {
507 pub fn create(
515 store: &'a dyn JournalStore,
516 clock: &'a dyn Clock,
517 idgen: &dyn IdGen,
518 paths: JournalPaths,
519 plan_id: String,
520 version: String,
521 targets: Vec<String>,
522 ) -> io::Result<Self> {
523 Self::create_inner(
524 store, clock, idgen, paths, plan_id, version, targets, None, None,
525 )
526 }
527
528 #[allow(clippy::too_many_arguments)]
536 pub fn create_bump(
537 store: &'a dyn JournalStore,
538 clock: &'a dyn Clock,
539 idgen: &dyn IdGen,
540 paths: JournalPaths,
541 plan_id: String,
542 version: String,
543 targets: Vec<String>,
544 head_sha: String,
545 bump: crate::protocol::journal::BumpInputs,
546 ) -> io::Result<Self> {
547 Self::create_inner(
548 store,
549 clock,
550 idgen,
551 paths,
552 plan_id,
553 version,
554 targets,
555 Some(head_sha),
556 Some(bump),
557 )
558 }
559
560 #[allow(clippy::too_many_arguments)]
561 fn create_inner(
562 store: &'a dyn JournalStore,
563 clock: &'a dyn Clock,
564 idgen: &dyn IdGen,
565 paths: JournalPaths,
566 plan_id: String,
567 version: String,
568 targets: Vec<String>,
569 head_sha: Option<String>,
570 bump: Option<crate::protocol::journal::BumpInputs>,
571 ) -> io::Result<Self> {
572 let lock = store.lock_exclusive(&paths.lock_file())?;
573 let run_id = idgen.new_id();
574 let mut journal = Self {
575 store,
576 clock,
577 paths,
578 run_id: run_id.clone(),
579 state: RunState::empty(),
580 _lock: lock,
581 };
582 journal.append(EventKind::RunCreated {
583 run_id,
584 plan_id,
585 version,
586 targets,
587 head_sha,
588 bump,
589 })?;
590 Ok(journal)
591 }
592
593 pub fn open(
606 store: &'a dyn JournalStore,
607 clock: &'a dyn Clock,
608 paths: JournalPaths,
609 run_id: &str,
610 ) -> io::Result<Self> {
611 validate_run_id(run_id)?;
612 let lock = store.lock_exclusive(&paths.lock_file())?;
613 let events = read_events(store, &paths.journal_file(run_id))?;
614 if events.is_empty() {
615 return Err(io::Error::new(
616 io::ErrorKind::NotFound,
617 format!("no release journal for run {run_id}"),
618 ));
619 }
620 let state = reduce(&events);
621 let journal = Self {
622 store,
623 clock,
624 paths,
625 run_id: run_id.to_string(),
626 state,
627 _lock: lock,
628 };
629 let _ = journal.persist_manifest();
631 Ok(journal)
632 }
633
634 #[must_use]
636 pub fn run_id(&self) -> &str {
637 &self.run_id
638 }
639
640 #[must_use]
642 pub fn state(&self) -> &RunState {
643 &self.state
644 }
645
646 #[must_use]
648 pub fn paths(&self) -> &JournalPaths {
649 &self.paths
650 }
651
652 pub fn append(&mut self, kind: EventKind) -> io::Result<&RunState> {
674 let event = JournalEvent {
675 schema_version: JOURNAL_SCHEMA_VERSION,
676 seq: self.state.applied_seq + 1,
677 ts: self.clock.now_unix(),
678 idempotency_key: kind.idempotency_key(),
679 kind,
680 };
681 let line = serde_json::to_string(&event).map_err(|e| {
682 io::Error::new(io::ErrorKind::InvalidData, format!("serialize event: {e}"))
683 })?;
684 self.store
686 .append_line(&self.paths.journal_file(&self.run_id), &line)?;
687 apply(&mut self.state, &event);
689 let _ = self.persist_manifest();
691 Ok(&self.state)
692 }
693
694 fn persist_manifest(&self) -> io::Result<()> {
696 let bytes = serde_json::to_vec_pretty(&self.state).map_err(|e| {
697 io::Error::new(
698 io::ErrorKind::InvalidData,
699 format!("serialize manifest: {e}"),
700 )
701 })?;
702 self.store
703 .write_atomic(&self.paths.manifest_file(&self.run_id), &bytes)
704 }
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710 use crate::protocol::journal::{PublishReceipt, TagState};
711 use std::cell::RefCell;
712 use std::collections::{HashMap, HashSet};
713 use std::rc::Rc;
714
715 #[derive(Default)]
718 struct StoreInner {
719 files: HashMap<PathBuf, Vec<u8>>,
721 locked: HashSet<PathBuf>,
723 fail_next_atomic: bool,
725 }
726
727 #[derive(Clone, Default)]
728 struct FakeStore {
729 inner: Rc<RefCell<StoreInner>>,
730 }
731
732 impl FakeStore {
733 fn journal_lines(&self, path: &Path) -> Vec<String> {
734 self.inner
735 .borrow()
736 .files
737 .get(path)
738 .map(|b| {
739 String::from_utf8_lossy(b)
740 .lines()
741 .map(str::to_string)
742 .collect()
743 })
744 .unwrap_or_default()
745 }
746
747 fn arm_atomic_failure(&self) {
748 self.inner.borrow_mut().fail_next_atomic = true;
749 }
750 }
751
752 struct FakeLock {
754 inner: Rc<RefCell<StoreInner>>,
755 path: PathBuf,
756 }
757
758 impl JournalLock for FakeLock {}
759
760 impl Drop for FakeLock {
761 fn drop(&mut self) {
762 self.inner.borrow_mut().locked.remove(&self.path);
763 }
764 }
765
766 impl JournalStore for FakeStore {
767 fn lock_exclusive(&self, lock_path: &Path) -> io::Result<Box<dyn JournalLock>> {
768 let mut inner = self.inner.borrow_mut();
769 if inner.locked.contains(lock_path) {
770 return Err(io::Error::new(
771 io::ErrorKind::WouldBlock,
772 "another release cut holds the lock",
773 ));
774 }
775 inner.locked.insert(lock_path.to_path_buf());
776 Ok(Box::new(FakeLock {
777 inner: Rc::clone(&self.inner),
778 path: lock_path.to_path_buf(),
779 }))
780 }
781
782 fn append_line(&self, path: &Path, line: &str) -> io::Result<()> {
783 let mut inner = self.inner.borrow_mut();
784 let buf = inner.files.entry(path.to_path_buf()).or_default();
785 buf.extend_from_slice(line.as_bytes());
786 buf.push(b'\n');
787 Ok(())
788 }
789
790 fn read_lines(&self, path: &Path) -> io::Result<Vec<String>> {
791 Ok(self.journal_lines(path))
792 }
793
794 fn read(&self, path: &Path) -> io::Result<Option<Vec<u8>>> {
795 Ok(self.inner.borrow().files.get(path).cloned())
796 }
797
798 fn write_atomic(&self, path: &Path, bytes: &[u8]) -> io::Result<()> {
799 let mut inner = self.inner.borrow_mut();
800 if inner.fail_next_atomic {
801 inner.fail_next_atomic = false;
802 return Err(io::Error::other("injected atomic-write crash"));
803 }
804 inner.files.insert(path.to_path_buf(), bytes.to_vec());
805 Ok(())
806 }
807
808 fn list_dir(&self, dir: &Path) -> io::Result<Vec<String>> {
809 let inner = self.inner.borrow();
810 let mut names: HashSet<String> = HashSet::new();
811 for path in inner.files.keys() {
812 if let Ok(rest) = path.strip_prefix(dir) {
814 if let Some(first) = rest.components().next() {
815 names.insert(first.as_os_str().to_string_lossy().into_owned());
816 }
817 }
818 }
819 Ok(names.into_iter().collect())
820 }
821 }
822
823 struct FakeClock {
824 t: std::cell::Cell<u64>,
825 }
826 impl FakeClock {
827 fn at(t: u64) -> Self {
828 Self {
829 t: std::cell::Cell::new(t),
830 }
831 }
832 }
833 impl Clock for FakeClock {
834 fn now_unix(&self) -> u64 {
835 let now = self.t.get();
836 self.t.set(now + 1); now
838 }
839 }
840
841 struct FakeIdGen {
842 id: String,
843 }
844 impl IdGen for FakeIdGen {
845 fn new_id(&self) -> String {
846 self.id.clone()
847 }
848 }
849
850 struct FakeGit {
851 common_dir: PathBuf,
852 }
853 impl GitRepo for FakeGit {
854 fn head_commit(&self) -> io::Result<String> {
855 Ok("deadbeef".into())
856 }
857 fn is_work_tree(&self) -> bool {
858 true
859 }
860 fn shortlog(&self, _since: Option<&str>) -> io::Result<String> {
861 Ok(String::new())
862 }
863 fn tags(&self) -> io::Result<Vec<String>> {
864 Ok(Vec::new())
865 }
866 fn git_common_dir(&self) -> io::Result<PathBuf> {
867 Ok(self.common_dir.clone())
868 }
869 }
870
871 fn paths() -> JournalPaths {
872 JournalPaths::new("/repo/.git/ossctl/releases")
873 }
874
875 fn receipt(version: &str) -> PublishReceipt {
876 PublishReceipt {
877 ecosystem: "cargo".into(),
878 package: Some("ossctl".into()),
879 version: version.into(),
880 registry_url: Some("https://crates.io/crates/ossctl".into()),
881 digest: Some("sha256:abc".into()),
882 }
883 }
884
885 fn sample_events() -> Vec<JournalEvent> {
887 let kinds = vec![
888 EventKind::RunCreated {
889 run_id: "RUN01".into(),
890 plan_id: "plan-abc".into(),
891 version: "0.1.0".into(),
892 targets: vec!["cargo".into(), "npm".into()],
893 head_sha: None,
894 bump: None,
895 },
896 EventKind::PhaseEntered {
897 phase: Phase::DryRun,
898 },
899 EventKind::TargetDryRun {
900 target: "cargo".into(),
901 },
902 EventKind::PhaseCompleted {
903 phase: Phase::DryRun,
904 outcome: PhaseOutcome::Ok,
905 },
906 EventKind::PhaseEntered {
907 phase: Phase::Publish,
908 },
909 EventKind::TargetPublished {
910 target: "cargo".into(),
911 receipt: receipt("0.1.0"),
912 },
913 ];
914 kinds
915 .into_iter()
916 .enumerate()
917 .map(|(i, kind)| JournalEvent {
918 schema_version: JOURNAL_SCHEMA_VERSION,
919 seq: (i + 1) as u64,
920 ts: 1000 + i as u64,
921 idempotency_key: kind.idempotency_key(),
922 kind,
923 })
924 .collect()
925 }
926
927 #[test]
930 fn paths_resolve_under_git_common_dir() {
931 let git = FakeGit {
932 common_dir: PathBuf::from("/repo/.git"),
933 };
934 let p = JournalPaths::from_git(&git, None).unwrap();
935 assert_eq!(p.releases_dir(), Path::new("/repo/.git/ossctl/releases"));
936 assert_eq!(
937 p.journal_file("RUN01"),
938 Path::new("/repo/.git/ossctl/releases/RUN01/journal.jsonl")
939 );
940 assert_eq!(
941 p.manifest_file("RUN01"),
942 Path::new("/repo/.git/ossctl/releases/RUN01/manifest.json")
943 );
944 assert_eq!(p.lock_file(), Path::new("/repo/.git/ossctl/releases/.lock"));
945 }
946
947 #[test]
948 fn path_override_wins_over_git() {
949 let git = FakeGit {
950 common_dir: PathBuf::from("/repo/.git"),
951 };
952 let p = JournalPaths::from_git(&git, Some(Path::new("/ci/journal"))).unwrap();
953 assert_eq!(p.releases_dir(), Path::new("/ci/journal"));
954 }
955
956 #[test]
959 fn reduce_is_deterministic() {
960 let events = sample_events();
961 let a = reduce(&events);
962 let b = reduce(&events);
963 assert_eq!(a, b);
964 assert_eq!(a.run_id, "RUN01");
965 assert_eq!(a.plan_id, "plan-abc");
966 assert_eq!(a.targets, vec!["cargo".to_string(), "npm".to_string()]);
967 assert!(a.dry_run.contains("cargo"));
968 assert_eq!(a.published.get("cargo").unwrap().version, "0.1.0");
969 assert_eq!(a.current_phase, Some(Phase::Publish));
971 assert_eq!(a.applied_seq, 6);
972 }
973
974 #[test]
975 fn reduce_ignores_slice_order() {
976 let mut events = sample_events();
977 events.reverse();
978 let out = reduce(&events);
979 assert_eq!(out, reduce(&sample_events()));
981 }
982
983 #[test]
984 fn replaying_a_seen_event_is_a_no_op() {
985 let events = sample_events();
986 let mut state = reduce(&events);
987 let before = state.clone();
988 apply(&mut state, &events[2]);
990 assert_eq!(state, before);
991 for ev in &events {
993 apply(&mut state, ev);
994 }
995 assert_eq!(state, before);
996 }
997
998 #[test]
999 fn structural_idempotency_of_publish_and_tags() {
1000 let mut state = RunState::empty();
1003 let mk = |seq: u64, kind: EventKind| JournalEvent {
1004 schema_version: JOURNAL_SCHEMA_VERSION,
1005 seq,
1006 ts: seq,
1007 idempotency_key: kind.idempotency_key(),
1008 kind,
1009 };
1010 apply(
1011 &mut state,
1012 &mk(
1013 1,
1014 EventKind::RunCreated {
1015 run_id: "R".into(),
1016 plan_id: "p".into(),
1017 version: "0.1.0".into(),
1018 targets: vec!["cargo".into()],
1019 head_sha: None,
1020 bump: None,
1021 },
1022 ),
1023 );
1024 apply(
1025 &mut state,
1026 &mk(
1027 2,
1028 EventKind::TargetPublished {
1029 target: "cargo".into(),
1030 receipt: receipt("0.1.0"),
1031 },
1032 ),
1033 );
1034 apply(
1035 &mut state,
1036 &mk(
1037 3,
1038 EventKind::TargetPublished {
1039 target: "cargo".into(),
1040 receipt: receipt("0.1.1"),
1041 },
1042 ),
1043 );
1044 assert_eq!(state.published.len(), 1);
1045 assert_eq!(state.published.get("cargo").unwrap().version, "0.1.1");
1046
1047 apply(
1048 &mut state,
1049 &mk(
1050 4,
1051 EventKind::TagCreatedLocal {
1052 tag: "v0.1.1".into(),
1053 },
1054 ),
1055 );
1056 apply(
1057 &mut state,
1058 &mk(
1059 5,
1060 EventKind::TagPushedRemote {
1061 tag: "v0.1.1".into(),
1062 },
1063 ),
1064 );
1065 assert_eq!(
1066 state.tags.get("v0.1.1"),
1067 Some(&TagState {
1068 created_local: true,
1069 pushed_remote: true,
1070 github_release: false,
1071 github_release_url: None,
1072 github_release_delegated: false,
1073 })
1074 );
1075 }
1076
1077 #[test]
1078 fn github_release_delegation_reduces_to_the_tag_state_flag() {
1079 let mut state = RunState::empty();
1083 let mk = |seq: u64, kind: EventKind| JournalEvent {
1084 schema_version: JOURNAL_SCHEMA_VERSION,
1085 seq,
1086 ts: seq,
1087 idempotency_key: kind.idempotency_key(),
1088 kind,
1089 };
1090 apply(
1091 &mut state,
1092 &mk(
1093 1,
1094 EventKind::TagCreatedLocal {
1095 tag: "v1.0.0".into(),
1096 },
1097 ),
1098 );
1099 apply(
1100 &mut state,
1101 &mk(
1102 2,
1103 EventKind::TagPushedRemote {
1104 tag: "v1.0.0".into(),
1105 },
1106 ),
1107 );
1108 apply(
1109 &mut state,
1110 &mk(
1111 3,
1112 EventKind::GithubReleaseDelegated {
1113 tag: "v1.0.0".into(),
1114 delegated_to: "cargo-dist".into(),
1115 },
1116 ),
1117 );
1118 assert_eq!(
1119 state.tags.get("v1.0.0"),
1120 Some(&TagState {
1121 created_local: true,
1122 pushed_remote: true,
1123 github_release: false,
1124 github_release_url: None,
1125 github_release_delegated: true,
1126 })
1127 );
1128 }
1129
1130 #[test]
1131 fn dist_phase_ok_completes_the_run_tag_ok_alone_does_not() {
1132 let mut state = reduce(&sample_events());
1133 assert_eq!(state.status, RunStatus::InProgress);
1134 let mut seq = state.applied_seq;
1135 let mut push = |state: &mut RunState, phase: Phase| {
1136 seq += 1;
1137 apply(
1138 state,
1139 &JournalEvent {
1140 schema_version: JOURNAL_SCHEMA_VERSION,
1141 seq,
1142 ts: 9000 + seq,
1143 idempotency_key: format!("phase_completed:{}", phase.as_str()),
1144 kind: EventKind::PhaseCompleted {
1145 phase,
1146 outcome: PhaseOutcome::Ok,
1147 },
1148 },
1149 );
1150 };
1151 push(&mut state, Phase::Tag);
1153 assert_eq!(state.status, RunStatus::InProgress);
1154 push(&mut state, Phase::Dist);
1155 assert_eq!(state.status, RunStatus::Completed);
1156 }
1157
1158 #[test]
1159 fn a_v1_tag_ok_completes_the_run_for_backward_compat() {
1160 let mut state = reduce(&sample_events());
1165 assert_eq!(state.status, RunStatus::InProgress);
1166 let seq = state.applied_seq + 1;
1167 apply(
1168 &mut state,
1169 &JournalEvent {
1170 schema_version: 1,
1171 seq,
1172 ts: 9000,
1173 idempotency_key: "phase_completed:tag".into(),
1174 kind: EventKind::PhaseCompleted {
1175 phase: Phase::Tag,
1176 outcome: PhaseOutcome::Ok,
1177 },
1178 },
1179 );
1180 assert_eq!(state.status, RunStatus::Completed);
1181 }
1182
1183 #[test]
1184 fn run_abandoned_is_terminal_with_reason() {
1185 let mut state = reduce(&sample_events());
1186 let seq = state.applied_seq + 1;
1187 apply(
1188 &mut state,
1189 &JournalEvent {
1190 schema_version: JOURNAL_SCHEMA_VERSION,
1191 seq,
1192 ts: 9000,
1193 idempotency_key: "run_abandoned".into(),
1194 kind: EventKind::RunAbandoned {
1195 reason: "OTP timeout".into(),
1196 },
1197 },
1198 );
1199 assert_eq!(state.status, RunStatus::Abandoned);
1200 assert_eq!(state.abandon_reason.as_deref(), Some("OTP timeout"));
1201 }
1202
1203 #[test]
1206 fn create_writes_run_created_and_manifest() {
1207 let store = FakeStore::default();
1208 let clock = FakeClock::at(1000);
1209 let idgen = FakeIdGen { id: "RUN01".into() };
1210 let journal = Journal::create(
1211 &store,
1212 &clock,
1213 &idgen,
1214 paths(),
1215 "plan-abc".into(),
1216 "0.1.0".into(),
1217 vec!["cargo".into()],
1218 )
1219 .unwrap();
1220 assert_eq!(journal.run_id(), "RUN01");
1221 assert_eq!(journal.state().run_id, "RUN01");
1222 assert_eq!(journal.state().applied_seq, 1);
1223
1224 let lines = store.journal_lines(&paths().journal_file("RUN01"));
1226 assert_eq!(lines.len(), 1);
1227 let manifest = store
1229 .inner
1230 .borrow()
1231 .files
1232 .get(&paths().manifest_file("RUN01"))
1233 .cloned()
1234 .unwrap();
1235 let loaded: RunState = serde_json::from_slice(&manifest).unwrap();
1236 assert_eq!(&loaded, journal.state());
1237 }
1238
1239 #[test]
1240 fn append_records_facts_and_a_failed_phase_can_later_complete_ok() {
1241 let store = FakeStore::default();
1245 let clock = FakeClock::at(1000);
1246 let idgen = FakeIdGen { id: "RUN01".into() };
1247 let mut journal = Journal::create(
1248 &store,
1249 &clock,
1250 &idgen,
1251 paths(),
1252 "plan-abc".into(),
1253 "0.1.0".into(),
1254 vec!["cargo".into()],
1255 )
1256 .unwrap();
1257 journal
1258 .append(EventKind::PhaseCompleted {
1259 phase: Phase::Publish,
1260 outcome: PhaseOutcome::Failed,
1261 })
1262 .unwrap();
1263 journal
1264 .append(EventKind::PhaseCompleted {
1265 phase: Phase::Publish,
1266 outcome: PhaseOutcome::Ok,
1267 })
1268 .unwrap();
1269 let lines = store.journal_lines(&paths().journal_file("RUN01"));
1271 assert_eq!(lines.len(), 3);
1272 let publish = journal
1274 .state()
1275 .phases
1276 .iter()
1277 .filter(|r| r.phase == Phase::Publish)
1278 .collect::<Vec<_>>();
1279 assert_eq!(publish.len(), 1);
1280 assert_eq!(publish[0].outcome, PhaseOutcome::Ok);
1281 }
1282
1283 #[test]
1284 fn terminal_state_freezes_further_events() {
1285 let mut state = reduce(&sample_events());
1287 let published_before = state.published.clone();
1288 let mut seq = state.applied_seq;
1289 let mut next = |kind: EventKind| {
1290 seq += 1;
1291 JournalEvent {
1292 schema_version: JOURNAL_SCHEMA_VERSION,
1293 seq,
1294 ts: 9000 + seq,
1295 idempotency_key: kind.idempotency_key(),
1296 kind,
1297 }
1298 };
1299 apply(
1300 &mut state,
1301 &next(EventKind::RunAbandoned {
1302 reason: "aborted".into(),
1303 }),
1304 );
1305 assert_eq!(state.status, RunStatus::Abandoned);
1306 apply(
1308 &mut state,
1309 &next(EventKind::TargetPublished {
1310 target: "npm".into(),
1311 receipt: receipt("9.9.9"),
1312 }),
1313 );
1314 assert_eq!(state.status, RunStatus::Abandoned);
1315 assert_eq!(state.published, published_before);
1316 assert!(!state.published.contains_key("npm"));
1317 }
1318
1319 #[test]
1322 fn event_survives_a_manifest_write_crash() {
1323 let store = FakeStore::default();
1324 let clock = FakeClock::at(1000);
1325 let idgen = FakeIdGen { id: "RUN01".into() };
1326 let mut journal = Journal::create(
1327 &store,
1328 &clock,
1329 &idgen,
1330 paths(),
1331 "plan-abc".into(),
1332 "0.1.0".into(),
1333 vec!["cargo".into()],
1334 )
1335 .unwrap();
1336 store.arm_atomic_failure();
1340 journal
1341 .append(EventKind::TargetPublished {
1342 target: "cargo".into(),
1343 receipt: receipt("0.1.0"),
1344 })
1345 .unwrap();
1346 drop(journal); let clock2 = FakeClock::at(2000);
1351 let reopened = Journal::open(&store, &clock2, paths(), "RUN01").unwrap();
1352 assert!(reopened.state().published.contains_key("cargo"));
1353 assert_eq!(reopened.state().applied_seq, 2);
1354 let manifest = store
1356 .inner
1357 .borrow()
1358 .files
1359 .get(&paths().manifest_file("RUN01"))
1360 .cloned()
1361 .unwrap();
1362 let loaded: RunState = serde_json::from_slice(&manifest).unwrap();
1363 assert_eq!(&loaded, reopened.state());
1364 }
1365
1366 #[test]
1367 fn open_reduces_from_journal_when_manifest_absent() {
1368 let store = FakeStore::default();
1370 for ev in sample_events() {
1371 let line = serde_json::to_string(&ev).unwrap();
1372 store
1373 .append_line(&paths().journal_file("RUN01"), &line)
1374 .unwrap();
1375 }
1376 let clock = FakeClock::at(1000);
1377 let journal = Journal::open(&store, &clock, paths(), "RUN01").unwrap();
1378 assert_eq!(journal.state(), &reduce(&sample_events()));
1379 }
1380
1381 #[test]
1384 fn second_create_fails_while_lock_is_held() {
1385 let store = FakeStore::default();
1386 let clock = FakeClock::at(1000);
1387 let idgen = FakeIdGen { id: "RUN01".into() };
1388 let held = Journal::create(
1389 &store,
1390 &clock,
1391 &idgen,
1392 paths(),
1393 "plan-abc".into(),
1394 "0.1.0".into(),
1395 vec!["cargo".into()],
1396 )
1397 .unwrap();
1398
1399 let clock2 = FakeClock::at(2000);
1401 let idgen2 = FakeIdGen { id: "RUN02".into() };
1402 let result = Journal::create(
1405 &store,
1406 &clock2,
1407 &idgen2,
1408 paths(),
1409 "plan-def".into(),
1410 "0.1.0".into(),
1411 vec!["cargo".into()],
1412 );
1413 let err = result.err().expect("concurrent create must fail");
1414 assert_eq!(err.kind(), io::ErrorKind::WouldBlock);
1415
1416 drop(held);
1418 let clock3 = FakeClock::at(3000);
1419 let idgen3 = FakeIdGen { id: "RUN02".into() };
1420 assert!(Journal::create(
1421 &store,
1422 &clock3,
1423 &idgen3,
1424 paths(),
1425 "plan-def".into(),
1426 "0.1.0".into(),
1427 vec!["cargo".into()],
1428 )
1429 .is_ok());
1430 }
1431
1432 #[test]
1435 fn load_state_is_read_only_and_takes_no_lock() {
1436 let store = FakeStore::default();
1437 let clock = FakeClock::at(1000);
1438 let idgen = FakeIdGen { id: "RUN01".into() };
1439 let journal = Journal::create(
1440 &store,
1441 &clock,
1442 &idgen,
1443 paths(),
1444 "plan-abc".into(),
1445 "0.1.0".into(),
1446 vec!["cargo".into()],
1447 )
1448 .unwrap();
1449 let loaded = load_state(&store, &paths(), "RUN01").unwrap().unwrap();
1451 assert_eq!(&loaded, journal.state());
1452 assert!(load_state(&store, &paths(), "MISSING").unwrap().is_none());
1453 }
1454
1455 #[test]
1456 fn load_state_prefers_manifest_but_falls_back_to_journal() {
1457 let store = FakeStore::default();
1458 let clock = FakeClock::at(1000);
1459 let idgen = FakeIdGen { id: "RUN01".into() };
1460 let journal = Journal::create(
1461 &store,
1462 &clock,
1463 &idgen,
1464 paths(),
1465 "plan-abc".into(),
1466 "0.1.0".into(),
1467 vec!["cargo".into()],
1468 )
1469 .unwrap();
1470 let expected = journal.state().clone();
1471 drop(journal);
1472
1473 assert_eq!(
1475 load_state(&store, &paths(), "RUN01").unwrap(),
1476 Some(expected.clone())
1477 );
1478
1479 store
1481 .write_atomic(&paths().manifest_file("RUN01"), b"{not json")
1482 .unwrap();
1483 assert_eq!(
1484 load_state(&store, &paths(), "RUN01").unwrap(),
1485 Some(expected)
1486 );
1487 }
1488
1489 #[test]
1490 fn read_run_state_reduces_from_journal_without_writing() {
1491 let store = FakeStore::default();
1493 for ev in sample_events() {
1494 let line = serde_json::to_string(&ev).unwrap();
1495 store
1496 .append_line(&paths().journal_file("RUN01"), &line)
1497 .unwrap();
1498 }
1499 let files_before = store.inner.borrow().files.clone();
1501
1502 let state = read_run_state(&store, &paths(), "RUN01").unwrap().unwrap();
1503 assert_eq!(state, reduce(&sample_events()));
1504
1505 assert_eq!(
1507 store.inner.borrow().files,
1508 files_before,
1509 "read_run_state must not write anything"
1510 );
1511 assert!(
1512 store.inner.borrow().locked.is_empty(),
1513 "read_run_state must not take the lock"
1514 );
1515 assert!(
1516 !store
1517 .inner
1518 .borrow()
1519 .files
1520 .contains_key(&paths().manifest_file("RUN01")),
1521 "read_run_state must not materialize a manifest"
1522 );
1523
1524 assert!(read_run_state(&store, &paths(), "MISSING")
1526 .unwrap()
1527 .is_none());
1528 assert_eq!(
1529 read_run_state(&store, &paths(), "../escape")
1530 .unwrap_err()
1531 .kind(),
1532 io::ErrorKind::InvalidInput
1533 );
1534 }
1535
1536 #[test]
1537 fn read_run_returns_events_and_reduced_state() {
1538 let store = FakeStore::default();
1540 for ev in sample_events() {
1541 let line = serde_json::to_string(&ev).unwrap();
1542 store
1543 .append_line(&paths().journal_file("RUN01"), &line)
1544 .unwrap();
1545 }
1546 let files_before = store.inner.borrow().files.clone();
1547
1548 let (events, state) = read_run(&store, &paths(), "RUN01").unwrap().unwrap();
1549 assert_eq!(events, sample_events());
1550 assert_eq!(state, reduce(&sample_events()));
1551
1552 assert_eq!(store.inner.borrow().files, files_before);
1554 assert!(store.inner.borrow().locked.is_empty());
1555
1556 assert!(read_run(&store, &paths(), "MISSING").unwrap().is_none());
1558 assert_eq!(
1559 read_run(&store, &paths(), "../escape").unwrap_err().kind(),
1560 io::ErrorKind::InvalidInput
1561 );
1562 }
1563
1564 #[test]
1565 fn read_run_state_ignores_a_stale_manifest_fast_path() {
1566 let store = FakeStore::default();
1569 for ev in sample_events() {
1570 let line = serde_json::to_string(&ev).unwrap();
1571 store
1572 .append_line(&paths().journal_file("RUN01"), &line)
1573 .unwrap();
1574 }
1575 let mut stale = RunState::empty();
1577 stale.run_id = "RUN01".into();
1578 stale.plan_id = "STALE".into();
1579 store
1580 .write_atomic(
1581 &paths().manifest_file("RUN01"),
1582 &serde_json::to_vec(&stale).unwrap(),
1583 )
1584 .unwrap();
1585
1586 assert_eq!(
1588 load_state(&store, &paths(), "RUN01")
1589 .unwrap()
1590 .unwrap()
1591 .plan_id,
1592 "STALE"
1593 );
1594 assert_eq!(
1595 read_run_state(&store, &paths(), "RUN01")
1596 .unwrap()
1597 .unwrap()
1598 .plan_id,
1599 "plan-abc",
1600 );
1601 }
1602
1603 #[test]
1604 fn run_id_validation_rejects_path_traversal() {
1605 let store = FakeStore::default();
1606 let clock = FakeClock::at(1000);
1607 for bad in ["..", "a/b", "", ".", "x/../y"] {
1608 assert_eq!(
1609 load_state(&store, &paths(), bad).unwrap_err().kind(),
1610 io::ErrorKind::InvalidInput,
1611 "load_state must reject run id {bad:?}"
1612 );
1613 let err = Journal::open(&store, &clock, paths(), bad).err().unwrap();
1614 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1615 }
1616 }
1617
1618 #[test]
1619 fn list_runs_excludes_lock_and_stray_files() {
1620 let store = FakeStore::default();
1621 let clock = FakeClock::at(1000);
1622 for id in ["RUN01", "RUN02"] {
1623 let idgen = FakeIdGen { id: id.into() };
1624 let _j = Journal::create(
1625 &store,
1626 &clock,
1627 &idgen,
1628 paths(),
1629 "plan".into(),
1630 "0.1.0".into(),
1631 vec!["cargo".into()],
1632 )
1633 .unwrap();
1634 }
1635 store
1638 .write_atomic(&paths().releases_dir().join("journal.jsonl.tmp"), b"x")
1639 .unwrap();
1640 store
1641 .write_atomic(&paths().releases_dir().join(".lock"), b"")
1642 .unwrap();
1643 let runs = list_runs(&store, &paths()).unwrap();
1644 assert_eq!(runs, vec!["RUN01".to_string(), "RUN02".to_string()]);
1645 }
1646
1647 #[test]
1650 fn read_events_refuses_a_too_new_schema_version() {
1651 let store = FakeStore::default();
1652 let mut ev = sample_events()[0].clone();
1653 ev.schema_version = JOURNAL_SCHEMA_VERSION + 1;
1654 let line = serde_json::to_string(&ev).unwrap();
1655 store
1656 .append_line(&paths().journal_file("RUN01"), &line)
1657 .unwrap();
1658 let err = read_events(&store, &paths().journal_file("RUN01")).unwrap_err();
1659 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1660 }
1661
1662 #[test]
1663 fn read_events_tolerates_unknown_additive_fields() {
1664 let store = FakeStore::default();
1666 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}"#;
1667 store
1668 .append_line(&paths().journal_file("RUN01"), line)
1669 .unwrap();
1670 let events = read_events(&store, &paths().journal_file("RUN01")).unwrap();
1671 assert_eq!(events.len(), 1);
1672 assert_eq!(events[0].seq, 1);
1673 }
1674
1675 #[test]
1676 fn run_status_as_str_matches_serde() {
1677 for s in [
1678 RunStatus::InProgress,
1679 RunStatus::Completed,
1680 RunStatus::Abandoned,
1681 ] {
1682 assert_eq!(
1683 serde_json::to_value(s).unwrap(),
1684 serde_json::Value::String(s.as_str().to_string()),
1685 "as_str() drifted from serde for {s:?}"
1686 );
1687 }
1688 }
1689
1690 #[test]
1691 fn read_events_skips_blank_lines() {
1692 let store = FakeStore::default();
1693 store
1694 .append_line(&paths().journal_file("RUN01"), "")
1695 .unwrap();
1696 assert!(read_events(&store, &paths().journal_file("RUN01"))
1697 .unwrap()
1698 .is_empty());
1699 }
1700}