Skip to main content

ossctl_core/release/
journal.rs

1//! Event-sourced release journal (ADR-0003).
2//!
3//! Append-only JSONL events under `git-common-dir/ossctl/releases/<run_id>/`,
4//! with an idempotent [`reduce`]r folding them into resumable [`RunState`]. The
5//! durable record `release resume`/`verify`/`show` read back.
6//!
7//! # The two halves
8//!
9//! - **The reducer** ([`reduce`] / [`apply`]) is a *pure* fold of `[JournalEvent]
10//!   → RunState`. It has no I/O and is the core testable unit: the same events
11//!   always fold to the same state, and re-applying a seen event changes nothing.
12//! - **The [`Journal`] handle** wires the reducer to durable storage through the
13//!   injected [`JournalStore`] / [`Clock`] / [`IdGen`] ports, enforcing the
14//!   append-then-apply atomicity discipline and holding the single-active-cut
15//!   lock for its lifetime.
16//!
17//! # Append-then-apply (ADR-0003 §2, from `octl-core`)
18//!
19//! Every mutation is: **(1)** fsync the event to `journal.jsonl` (durable),
20//! **(2)** apply it to the in-memory [`RunState`], **(3)** atomically rewrite the
21//! `manifest.json` cache. The journal is the single source of truth; the manifest
22//! is disposable and is always rebuilt by [`reduce`] on [`Journal::open`], so a
23//! crash *anywhere* in that sequence recovers cleanly — a durably-appended event
24//! is folded back in on the next open regardless of whether its manifest write
25//! landed.
26//!
27//! # Idempotency
28//!
29//! Replay idempotency — the property the crash-safety discipline needs — comes
30//! from two mechanisms working together:
31//!
32//! 1. **Watermark** — [`apply`] ignores any event whose `seq` is at or below the
33//!    already-applied high-water mark, so replaying the persisted log (or a seen
34//!    event) is a no-op. This is the "re-applying a seen event changes nothing"
35//!    guarantee.
36//! 2. **Structural** — the projection is built from keyed sets/maps, so folding a
37//!    fact about target `cargo` more than once yields the identical map.
38//!
39//! The log is **append-only facts**: [`Journal::append`] does not deduplicate on
40//! [`crate::protocol::journal::JournalEvent::idempotency_key`] (an earlier design
41//! did, which silently swallowed a legitimate `Failed`→`Ok` phase retry after a
42//! resume). Whether to *emit* an event is the coordinator's decision — and since
43//! the remote registry is ground truth (ADR-0003 §4), a resumed cut re-checks
44//! reality before re-emitting rather than trusting an append gate.
45//!
46//! # Terminal states
47//!
48//! Once the run reaches a terminal status ([`RunStatus::Completed`] or
49//! [`RunStatus::Abandoned`]) the reducer freezes: later events are ignored, so a
50//! corrupt or buggy log cannot un-abandon a run or resurrect a completed one.
51
52use 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/// Resolved on-disk locations for a repo's release journals.
62///
63/// The releases root is `git-common-dir/ossctl/releases` (ADR-0003 §3), resolved
64/// via [`GitRepo::git_common_dir`] — **never** by concatenating `.git/` — or an
65/// explicit override for CI / debugging (`--journal-dir`). All per-run paths and
66/// the single-active-cut lock path are derived from it.
67#[derive(Debug, Clone)]
68pub struct JournalPaths {
69    releases_dir: PathBuf,
70}
71
72impl JournalPaths {
73    /// Build paths rooted at an explicit `releases_dir` (the `--journal-dir`
74    /// override, or a test root).
75    pub fn new(releases_dir: impl Into<PathBuf>) -> Self {
76        Self {
77            releases_dir: releases_dir.into(),
78        }
79    }
80
81    /// Resolve the releases root from git — `<git-common-dir>/ossctl/releases` —
82    /// unless `override_dir` is supplied, in which case it is used verbatim.
83    ///
84    /// # Errors
85    /// Propagates a [`GitRepo::git_common_dir`] failure (not a git repository, or
86    /// git unavailable) when no override is given.
87    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    /// The releases root (`…/ossctl/releases`).
96    #[must_use]
97    pub fn releases_dir(&self) -> &Path {
98        &self.releases_dir
99    }
100
101    /// The single-active-cut lock path (`…/releases/.lock`).
102    #[must_use]
103    pub fn lock_file(&self) -> PathBuf {
104        self.releases_dir.join(".lock")
105    }
106
107    /// The per-run directory (`…/releases/<run_id>/`).
108    #[must_use]
109    pub fn run_dir(&self, run_id: &str) -> PathBuf {
110        self.releases_dir.join(run_id)
111    }
112
113    /// The append-only event log for a run (`…/<run_id>/journal.jsonl`).
114    #[must_use]
115    pub fn journal_file(&self, run_id: &str) -> PathBuf {
116        self.run_dir(run_id).join("journal.jsonl")
117    }
118
119    /// The materialized state cache for a run (`…/<run_id>/manifest.json`).
120    #[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// ── The reducer (pure) ───────────────────────────────────────────────────────
127
128/// Fold an ordered event stream into the materialized [`RunState`] — the pure,
129/// I/O-free core of the journal.
130///
131/// Deterministic and total: the same events (in `seq` order) always produce the
132/// same state. Events are applied in ascending `seq` regardless of slice order,
133/// so a defensively re-sorted log reduces identically.
134#[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
145/// Apply a single event to `state`, in place.
146///
147/// **Idempotent**: an event whose `seq` is at or below `state.applied_seq` is
148/// skipped (the high-water mark), and every mutation targets a keyed set/map, so
149/// re-applying a seen event leaves the state byte-identical. This is what makes
150/// append-then-apply crash-safe — replaying after a crash is a clean
151/// no-op-or-apply.
152///
153/// **Terminal-safe**: once the run is [`RunStatus::Completed`] or
154/// [`RunStatus::Abandoned`], further events are ignored (the watermark still
155/// advances so a later legitimate replay is consistent), so a corrupt log cannot
156/// mutate a run past its terminal fact.
157pub fn apply(state: &mut RunState, event: &JournalEvent) {
158    // Watermark: never fold an event already accounted for. `seq` starts at 1,
159    // so the first event (seq 1) always applies against the initial mark of 0.
160    if event.seq <= state.applied_seq {
161        return;
162    }
163    // Terminal states freeze the projection: nothing recorded after a run is
164    // completed or abandoned may change it. Advance the watermark so the state
165    // still reflects "everything up to here has been seen".
166    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        } => {
177            state.run_id.clone_from(run_id);
178            state.plan_id.clone_from(plan_id);
179            state.version.clone_from(version);
180            state.targets.clone_from(targets);
181            state.created_ts = event.ts;
182            state.status = RunStatus::InProgress;
183        }
184        EventKind::PhaseEntered { phase } => {
185            state.current_phase = Some(*phase);
186        }
187        EventKind::PhaseCompleted { phase, outcome } => {
188            upsert_phase(&mut state.phases, *phase, *outcome);
189            if state.current_phase == Some(*phase) {
190                state.current_phase = None;
191            }
192            // The final barrier completing OK is the run's completion signal
193            // (tag-once is the last phase, ADR-0002).
194            if *phase == Phase::Tag && *outcome == PhaseOutcome::Ok {
195                state.status = RunStatus::Completed;
196            }
197        }
198        EventKind::TargetDryRun { target } => {
199            state.dry_run.insert(target.clone());
200        }
201        EventKind::TargetBuilt { target } => {
202            state.built.insert(target.clone());
203        }
204        EventKind::TargetPublished { target, receipt } => {
205            state.published.insert(target.clone(), receipt.clone());
206        }
207        EventKind::TargetCancelled { target, reason } => {
208            state.cancelled.insert(target.clone(), reason.clone());
209        }
210        EventKind::TagCreatedLocal { tag } => {
211            state.tags.entry(tag.clone()).or_default().created_local = true;
212        }
213        EventKind::TagPushedRemote { tag } => {
214            state.tags.entry(tag.clone()).or_default().pushed_remote = true;
215        }
216        EventKind::GithubReleaseCreated { tag, url } => {
217            let t = state.tags.entry(tag.clone()).or_default();
218            t.github_release = true;
219            t.github_release_url.clone_from(url);
220        }
221        EventKind::RunAbandoned { reason } => {
222            state.status = RunStatus::Abandoned;
223            state.abandon_reason = Some(reason.clone());
224        }
225    }
226    state.applied_seq = event.seq;
227    state.updated_ts = event.ts;
228}
229
230/// Insert-or-update a completed-phase record, keeping `phases` sorted by phase
231/// order (so the manifest is deterministic).
232fn upsert_phase(phases: &mut Vec<PhaseRecord>, phase: Phase, outcome: PhaseOutcome) {
233    if let Some(rec) = phases.iter_mut().find(|r| r.phase == phase) {
234        rec.outcome = outcome;
235    } else {
236        phases.push(PhaseRecord { phase, outcome });
237        phases.sort_by_key(|r| r.phase);
238    }
239}
240
241// ── Reading events back (with forward tolerance) ─────────────────────────────
242
243/// Parse the JSONL journal at `path` into events, in ascending `seq`.
244///
245/// Forward-tolerant per ADR-0003 §2: additive fields are ignored (serde does not
246/// `deny_unknown_fields`), but an event whose `schema_version` is **newer** than
247/// this binary understands — or whose `kind` this binary does not know — is
248/// refused with an actionable error rather than silently mutating state.
249///
250/// # Errors
251/// [`io::ErrorKind::InvalidData`] on a malformed or too-new event line, or any
252/// I/O error surfaced by the store.
253pub fn read_events(store: &dyn JournalStore, path: &Path) -> io::Result<Vec<JournalEvent>> {
254    /// A minimal envelope parsed *before* the strict [`JournalEvent`] so a newer
255    /// schema (which may carry an unknown `kind` the enum cannot deserialize) is
256    /// refused with an actionable upgrade error rather than a generic parse error.
257    #[derive(serde::Deserialize)]
258    struct Envelope {
259        schema_version: u32,
260    }
261
262    let lines = store.read_lines(path)?;
263    let mut events = Vec::with_capacity(lines.len());
264    for (idx, line) in lines.iter().enumerate() {
265        let trimmed = line.trim();
266        if trimmed.is_empty() {
267            continue;
268        }
269        // Version gate first: a too-new event is refused before the strict enum
270        // parse (which would otherwise fail on an unknown `kind` with a generic
271        // message and never reach this check).
272        if let Ok(envelope) = serde_json::from_str::<Envelope>(trimmed) {
273            if envelope.schema_version > JOURNAL_SCHEMA_VERSION {
274                return Err(io::Error::new(
275                    io::ErrorKind::InvalidData,
276                    format!(
277                        "release journal {}: line {} has schema_version {} but this \
278                         ossctl understands at most {}; upgrade ossctl to resume this run",
279                        path.display(),
280                        idx + 1,
281                        envelope.schema_version,
282                        JOURNAL_SCHEMA_VERSION
283                    ),
284                ));
285            }
286        }
287        let event: JournalEvent = serde_json::from_str(trimmed).map_err(|e| {
288            io::Error::new(
289                io::ErrorKind::InvalidData,
290                format!(
291                    "release journal {}: line {} is not a recognized event \
292                     (corrupt, or written by a newer ossctl): {e}",
293                    path.display(),
294                    idx + 1
295                ),
296            )
297        })?;
298        events.push(event);
299    }
300    events.sort_by_key(|e| e.seq);
301    Ok(events)
302}
303
304/// Reject a `run_id` that is not a single safe path segment.
305///
306/// `run_id`s minted by `IdGen` are ULIDs, but `open`/`load_state` also take a
307/// caller-supplied id (a CLI argument), so a `../…`, an absolute path, or an
308/// empty string must never be joined into the releases root (path traversal).
309fn validate_run_id(run_id: &str) -> io::Result<()> {
310    let bad = run_id.is_empty()
311        || run_id == "."
312        || run_id == ".."
313        || run_id.contains('/')
314        || run_id.contains('\\')
315        || run_id.contains('\0');
316    if bad {
317        return Err(io::Error::new(
318            io::ErrorKind::InvalidInput,
319            format!("invalid run id {run_id:?}: must be a single path segment"),
320        ));
321    }
322    Ok(())
323}
324
325/// Read a run's current state **without** locking — the read-only path behind
326/// `release show`.
327///
328/// Prefers the atomically-written `manifest.json` cache (a torn-free read, and
329/// O(1) versus replaying the log); falls back to reducing the authoritative
330/// `journal.jsonl` when the manifest is absent, unparsable, or does not match the
331/// run. The cache can lag the log by the last event after a crash between append
332/// and manifest write, so this is *best-effort* for display — a
333/// correctness-critical read (resume/reconcile) must go through the locked
334/// [`Journal::open`], which always reduces the log.
335///
336/// Returns `Ok(None)` when the run has neither a usable manifest nor a journal.
337///
338/// # Errors
339/// An invalid `run_id`, or any store/parse error from [`read_events`].
340pub fn load_state(
341    store: &dyn JournalStore,
342    paths: &JournalPaths,
343    run_id: &str,
344) -> io::Result<Option<RunState>> {
345    validate_run_id(run_id)?;
346    // Fast path: the atomic manifest cache (never torn).
347    if let Some(bytes) = store.read(&paths.manifest_file(run_id))? {
348        if let Ok(state) = serde_json::from_slice::<RunState>(&bytes) {
349            if state.run_id == run_id && state.schema_version <= JOURNAL_SCHEMA_VERSION {
350                return Ok(Some(state));
351            }
352        }
353        // A corrupt/mismatched cache falls through to the authoritative rebuild.
354    }
355    let events = read_events(store, &paths.journal_file(run_id))?;
356    if events.is_empty() {
357        return Ok(None);
358    }
359    Ok(Some(reduce(&events)))
360}
361
362/// Authoritatively rebuild a run's [`RunState`] straight from its event log —
363/// the read-only path `release verify` reconciles from.
364///
365/// Unlike [`load_state`] this **ignores the `manifest.json` fast-path** and always
366/// reduces the authoritative `journal.jsonl`, so a manifest that lags the log by
367/// its last event (a crash between append and manifest write) cannot hide a
368/// just-published receipt from the reconcile. Unlike [`Journal::open`] it takes
369/// **no lock and writes nothing** — not even the manifest self-heal — so it is
370/// safe against a live run and leaves the journal byte-for-byte unchanged (the
371/// read-only guarantee `verify` promises).
372///
373/// Returns `Ok(None)` when the run has no journal.
374///
375/// # Errors
376/// An invalid `run_id` ([`io::ErrorKind::InvalidInput`]), or any store/parse error
377/// from [`read_events`] (a corrupt or too-new event line).
378pub fn read_run_state(
379    store: &dyn JournalStore,
380    paths: &JournalPaths,
381    run_id: &str,
382) -> io::Result<Option<RunState>> {
383    validate_run_id(run_id)?;
384    let events = read_events(store, &paths.journal_file(run_id))?;
385    if events.is_empty() {
386        return Ok(None);
387    }
388    Ok(Some(reduce(&events)))
389}
390
391/// Read a run's event log **and** its reduced state, read-only — the read path
392/// behind `release show`.
393///
394/// `release show` needs both halves: the ordered [`JournalEvent`] log (to stream
395/// as a live JSONL event window) *and* the folded [`RunState`] (to decide live
396/// vs. terminal and render the post-mortem summary). This is the write-free,
397/// unlocked twin of [`Journal::open`] that returns the log alongside the
398/// projection so the caller reduces once, not twice.
399///
400/// Like [`read_run_state`] it ignores the `manifest.json` fast path and reduces
401/// the authoritative `journal.jsonl`, so a manifest lagging the log by its last
402/// event cannot hide the newest fact from a live tail. Returns `Ok(None)` when
403/// the run has no journal.
404///
405/// # Errors
406/// An invalid `run_id` ([`io::ErrorKind::InvalidInput`]), or any store/parse
407/// error from [`read_events`].
408pub fn read_run(
409    store: &dyn JournalStore,
410    paths: &JournalPaths,
411    run_id: &str,
412) -> io::Result<Option<(Vec<JournalEvent>, RunState)>> {
413    validate_run_id(run_id)?;
414    let events = read_events(store, &paths.journal_file(run_id))?;
415    if events.is_empty() {
416        return Ok(None);
417    }
418    let state = reduce(&events);
419    Ok(Some((events, state)))
420}
421
422/// List the run ids present under the releases root — the enumeration behind
423/// `release list`.
424///
425/// Only entries that carry a non-empty `journal.jsonl` are returned, so the
426/// `.lock` file, atomic-write temp files, and any stray files/dirs the store may
427/// surface are excluded (a run is defined by having a journal).
428///
429/// # Errors
430/// Any store error listing the releases directory.
431pub fn list_runs(store: &dyn JournalStore, paths: &JournalPaths) -> io::Result<Vec<String>> {
432    let mut runs: Vec<String> = store
433        .list_dir(paths.releases_dir())?
434        .into_iter()
435        .filter(|name| validate_run_id(name).is_ok())
436        .filter(|name| {
437            // A real run has a non-empty journal; this also excludes temp files
438            // and empty/aborted run directories.
439            store
440                .read_lines(&paths.journal_file(name))
441                .is_ok_and(|lines| lines.iter().any(|l| !l.trim().is_empty()))
442        })
443        .collect();
444    runs.sort();
445    Ok(runs)
446}
447
448// ── The Journal handle (durable, locked) ─────────────────────────────────────
449
450/// A live, exclusively-locked handle to one release run's journal.
451///
452/// Holds the single-active-cut lock for its entire lifetime (dropping the handle
453/// releases it) and mediates every mutation through the append-then-apply
454/// discipline. Construct it with [`Journal::create`] (a new run) or
455/// [`Journal::open`] (resume an existing one).
456pub struct Journal<'a> {
457    store: &'a dyn JournalStore,
458    clock: &'a dyn Clock,
459    paths: JournalPaths,
460    run_id: String,
461    state: RunState,
462    /// The single-active-cut lock, released on drop. Held, never called.
463    _lock: Box<dyn JournalLock>,
464}
465
466impl<'a> Journal<'a> {
467    /// Create a brand-new run: take the single-active-cut lock, mint a `run_id`
468    /// via `idgen`, and record the `RunCreated` event (which is also persisted to
469    /// a fresh manifest).
470    ///
471    /// # Errors
472    /// [`io::ErrorKind::WouldBlock`] if another cut/resume holds the lock, plus
473    /// any store error appending the first event or writing the manifest.
474    pub fn create(
475        store: &'a dyn JournalStore,
476        clock: &'a dyn Clock,
477        idgen: &dyn IdGen,
478        paths: JournalPaths,
479        plan_id: String,
480        version: String,
481        targets: Vec<String>,
482    ) -> io::Result<Self> {
483        let lock = store.lock_exclusive(&paths.lock_file())?;
484        let run_id = idgen.new_id();
485        let mut journal = Self {
486            store,
487            clock,
488            paths,
489            run_id: run_id.clone(),
490            state: RunState::empty(),
491            _lock: lock,
492        };
493        journal.append(EventKind::RunCreated {
494            run_id,
495            plan_id,
496            version,
497            targets,
498        })?;
499        Ok(journal)
500    }
501
502    /// Resume an existing run: take the single-active-cut lock, rebuild state from
503    /// the journal (the source of truth), and best-effort re-persist the manifest
504    /// cache so it reflects the log even if a prior crash left it stale.
505    ///
506    /// A manifest-write failure here is **not** fatal: the manifest is disposable
507    /// and the in-memory state is already authoritative (rebuilt from the log), so
508    /// a transient cache-write error must not brick an otherwise-recoverable run.
509    ///
510    /// # Errors
511    /// [`io::ErrorKind::WouldBlock`] if the lock is held, [`io::ErrorKind::NotFound`]
512    /// if the run has no journal, [`io::ErrorKind::InvalidInput`] for a malformed
513    /// `run_id`, plus any store/parse error reading the log.
514    pub fn open(
515        store: &'a dyn JournalStore,
516        clock: &'a dyn Clock,
517        paths: JournalPaths,
518        run_id: &str,
519    ) -> io::Result<Self> {
520        validate_run_id(run_id)?;
521        let lock = store.lock_exclusive(&paths.lock_file())?;
522        let events = read_events(store, &paths.journal_file(run_id))?;
523        if events.is_empty() {
524            return Err(io::Error::new(
525                io::ErrorKind::NotFound,
526                format!("no release journal for run {run_id}"),
527            ));
528        }
529        let state = reduce(&events);
530        let journal = Self {
531            store,
532            clock,
533            paths,
534            run_id: run_id.to_string(),
535            state,
536            _lock: lock,
537        };
538        // Best-effort self-heal of the disposable manifest from the log.
539        let _ = journal.persist_manifest();
540        Ok(journal)
541    }
542
543    /// The run id.
544    #[must_use]
545    pub fn run_id(&self) -> &str {
546        &self.run_id
547    }
548
549    /// The current materialized state.
550    #[must_use]
551    pub fn state(&self) -> &RunState {
552        &self.state
553    }
554
555    /// The resolved paths this journal writes to.
556    #[must_use]
557    pub fn paths(&self) -> &JournalPaths {
558        &self.paths
559    }
560
561    /// Record a fact with append-then-apply atomicity, returning the updated
562    /// state.
563    ///
564    /// The sequence is strictly: **(1)** serialize and durably fsync the event to
565    /// the log (the source of truth), **(2)** apply it to the in-memory
566    /// projection, **(3)** best-effort rewrite the disposable manifest cache.
567    ///
568    /// Step 3 failing does **not** fail the append: once step 1 returns the event
569    /// is committed, so reporting `Err` would tempt the caller to retry an
570    /// already-committed fact. A stale manifest self-heals on the next append or
571    /// [`Journal::open`]. Only a step-1 failure is fatal, and it leaves the state
572    /// untouched.
573    ///
574    /// This is a low-level append of a raw [`EventKind`] (`seq`/`ts`/
575    /// `idempotency_key` are assigned here). It does **not** validate release
576    /// state-machine ordering — that policy belongs to the coordinator; the
577    /// reducer only guarantees replay-idempotency and terminal-state freezing.
578    ///
579    /// # Errors
580    /// A store error from the durable append (step 1), or an event that fails to
581    /// serialize.
582    pub fn append(&mut self, kind: EventKind) -> io::Result<&RunState> {
583        let event = JournalEvent {
584            schema_version: JOURNAL_SCHEMA_VERSION,
585            seq: self.state.applied_seq + 1,
586            ts: self.clock.now_unix(),
587            idempotency_key: kind.idempotency_key(),
588            kind,
589        };
590        let line = serde_json::to_string(&event).map_err(|e| {
591            io::Error::new(io::ErrorKind::InvalidData, format!("serialize event: {e}"))
592        })?;
593        // 1. Durable append FIRST (the event is the source of truth).
594        self.store
595            .append_line(&self.paths.journal_file(&self.run_id), &line)?;
596        // 2. Apply to the in-memory projection.
597        apply(&mut self.state, &event);
598        // 3. Best-effort rewrite of the disposable manifest cache.
599        let _ = self.persist_manifest();
600        Ok(&self.state)
601    }
602
603    /// Serialize the current state and atomically replace the manifest cache.
604    fn persist_manifest(&self) -> io::Result<()> {
605        let bytes = serde_json::to_vec_pretty(&self.state).map_err(|e| {
606            io::Error::new(
607                io::ErrorKind::InvalidData,
608                format!("serialize manifest: {e}"),
609            )
610        })?;
611        self.store
612            .write_atomic(&self.paths.manifest_file(&self.run_id), &bytes)
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619    use crate::protocol::journal::{PublishReceipt, TagState};
620    use std::cell::RefCell;
621    use std::collections::{HashMap, HashSet};
622    use std::rc::Rc;
623
624    // ── In-memory fakes for the ports ──────────────────────────────────────
625
626    #[derive(Default)]
627    struct StoreInner {
628        /// path → full byte contents (journal lines are stored joined by "\n").
629        files: HashMap<PathBuf, Vec<u8>>,
630        /// currently-held lock paths (single-active-cut simulation).
631        locked: HashSet<PathBuf>,
632        /// when set, the next `write_atomic` fails (crash-injection).
633        fail_next_atomic: bool,
634    }
635
636    #[derive(Clone, Default)]
637    struct FakeStore {
638        inner: Rc<RefCell<StoreInner>>,
639    }
640
641    impl FakeStore {
642        fn journal_lines(&self, path: &Path) -> Vec<String> {
643            self.inner
644                .borrow()
645                .files
646                .get(path)
647                .map(|b| {
648                    String::from_utf8_lossy(b)
649                        .lines()
650                        .map(str::to_string)
651                        .collect()
652                })
653                .unwrap_or_default()
654        }
655
656        fn arm_atomic_failure(&self) {
657            self.inner.borrow_mut().fail_next_atomic = true;
658        }
659    }
660
661    /// The lock guard: removes its path from `locked` on drop.
662    struct FakeLock {
663        inner: Rc<RefCell<StoreInner>>,
664        path: PathBuf,
665    }
666
667    impl JournalLock for FakeLock {}
668
669    impl Drop for FakeLock {
670        fn drop(&mut self) {
671            self.inner.borrow_mut().locked.remove(&self.path);
672        }
673    }
674
675    impl JournalStore for FakeStore {
676        fn lock_exclusive(&self, lock_path: &Path) -> io::Result<Box<dyn JournalLock>> {
677            let mut inner = self.inner.borrow_mut();
678            if inner.locked.contains(lock_path) {
679                return Err(io::Error::new(
680                    io::ErrorKind::WouldBlock,
681                    "another release cut holds the lock",
682                ));
683            }
684            inner.locked.insert(lock_path.to_path_buf());
685            Ok(Box::new(FakeLock {
686                inner: Rc::clone(&self.inner),
687                path: lock_path.to_path_buf(),
688            }))
689        }
690
691        fn append_line(&self, path: &Path, line: &str) -> io::Result<()> {
692            let mut inner = self.inner.borrow_mut();
693            let buf = inner.files.entry(path.to_path_buf()).or_default();
694            buf.extend_from_slice(line.as_bytes());
695            buf.push(b'\n');
696            Ok(())
697        }
698
699        fn read_lines(&self, path: &Path) -> io::Result<Vec<String>> {
700            Ok(self.journal_lines(path))
701        }
702
703        fn read(&self, path: &Path) -> io::Result<Option<Vec<u8>>> {
704            Ok(self.inner.borrow().files.get(path).cloned())
705        }
706
707        fn write_atomic(&self, path: &Path, bytes: &[u8]) -> io::Result<()> {
708            let mut inner = self.inner.borrow_mut();
709            if inner.fail_next_atomic {
710                inner.fail_next_atomic = false;
711                return Err(io::Error::other("injected atomic-write crash"));
712            }
713            inner.files.insert(path.to_path_buf(), bytes.to_vec());
714            Ok(())
715        }
716
717        fn list_dir(&self, dir: &Path) -> io::Result<Vec<String>> {
718            let inner = self.inner.borrow();
719            let mut names: HashSet<String> = HashSet::new();
720            for path in inner.files.keys() {
721                // Any file under `dir/<name>/…` contributes `<name>`.
722                if let Ok(rest) = path.strip_prefix(dir) {
723                    if let Some(first) = rest.components().next() {
724                        names.insert(first.as_os_str().to_string_lossy().into_owned());
725                    }
726                }
727            }
728            Ok(names.into_iter().collect())
729        }
730    }
731
732    struct FakeClock {
733        t: std::cell::Cell<u64>,
734    }
735    impl FakeClock {
736        fn at(t: u64) -> Self {
737            Self {
738                t: std::cell::Cell::new(t),
739            }
740        }
741    }
742    impl Clock for FakeClock {
743        fn now_unix(&self) -> u64 {
744            let now = self.t.get();
745            self.t.set(now + 1); // advance so successive events get distinct ts
746            now
747        }
748    }
749
750    struct FakeIdGen {
751        id: String,
752    }
753    impl IdGen for FakeIdGen {
754        fn new_id(&self) -> String {
755            self.id.clone()
756        }
757    }
758
759    struct FakeGit {
760        common_dir: PathBuf,
761    }
762    impl GitRepo for FakeGit {
763        fn head_commit(&self) -> io::Result<String> {
764            Ok("deadbeef".into())
765        }
766        fn is_work_tree(&self) -> bool {
767            true
768        }
769        fn shortlog(&self, _since: Option<&str>) -> io::Result<String> {
770            Ok(String::new())
771        }
772        fn tags(&self) -> io::Result<Vec<String>> {
773            Ok(Vec::new())
774        }
775        fn git_common_dir(&self) -> io::Result<PathBuf> {
776            Ok(self.common_dir.clone())
777        }
778    }
779
780    fn paths() -> JournalPaths {
781        JournalPaths::new("/repo/.git/ossctl/releases")
782    }
783
784    fn receipt(version: &str) -> PublishReceipt {
785        PublishReceipt {
786            ecosystem: "cargo".into(),
787            package: Some("ossctl".into()),
788            version: version.into(),
789            registry_url: Some("https://crates.io/crates/ossctl".into()),
790            digest: Some("sha256:abc".into()),
791        }
792    }
793
794    /// A representative full run's worth of events, seq 1..=N.
795    fn sample_events() -> Vec<JournalEvent> {
796        let kinds = vec![
797            EventKind::RunCreated {
798                run_id: "RUN01".into(),
799                plan_id: "plan-abc".into(),
800                version: "0.1.0".into(),
801                targets: vec!["cargo".into(), "npm".into()],
802            },
803            EventKind::PhaseEntered {
804                phase: Phase::DryRun,
805            },
806            EventKind::TargetDryRun {
807                target: "cargo".into(),
808            },
809            EventKind::PhaseCompleted {
810                phase: Phase::DryRun,
811                outcome: PhaseOutcome::Ok,
812            },
813            EventKind::PhaseEntered {
814                phase: Phase::Publish,
815            },
816            EventKind::TargetPublished {
817                target: "cargo".into(),
818                receipt: receipt("0.1.0"),
819            },
820        ];
821        kinds
822            .into_iter()
823            .enumerate()
824            .map(|(i, kind)| JournalEvent {
825                schema_version: JOURNAL_SCHEMA_VERSION,
826                seq: (i + 1) as u64,
827                ts: 1000 + i as u64,
828                idempotency_key: kind.idempotency_key(),
829                kind,
830            })
831            .collect()
832    }
833
834    // ── Path resolution ────────────────────────────────────────────────────
835
836    #[test]
837    fn paths_resolve_under_git_common_dir() {
838        let git = FakeGit {
839            common_dir: PathBuf::from("/repo/.git"),
840        };
841        let p = JournalPaths::from_git(&git, None).unwrap();
842        assert_eq!(p.releases_dir(), Path::new("/repo/.git/ossctl/releases"));
843        assert_eq!(
844            p.journal_file("RUN01"),
845            Path::new("/repo/.git/ossctl/releases/RUN01/journal.jsonl")
846        );
847        assert_eq!(
848            p.manifest_file("RUN01"),
849            Path::new("/repo/.git/ossctl/releases/RUN01/manifest.json")
850        );
851        assert_eq!(p.lock_file(), Path::new("/repo/.git/ossctl/releases/.lock"));
852    }
853
854    #[test]
855    fn path_override_wins_over_git() {
856        let git = FakeGit {
857            common_dir: PathBuf::from("/repo/.git"),
858        };
859        let p = JournalPaths::from_git(&git, Some(Path::new("/ci/journal"))).unwrap();
860        assert_eq!(p.releases_dir(), Path::new("/ci/journal"));
861    }
862
863    // ── Reducer: determinism + idempotency ─────────────────────────────────
864
865    #[test]
866    fn reduce_is_deterministic() {
867        let events = sample_events();
868        let a = reduce(&events);
869        let b = reduce(&events);
870        assert_eq!(a, b);
871        assert_eq!(a.run_id, "RUN01");
872        assert_eq!(a.plan_id, "plan-abc");
873        assert_eq!(a.targets, vec!["cargo".to_string(), "npm".to_string()]);
874        assert!(a.dry_run.contains("cargo"));
875        assert_eq!(a.published.get("cargo").unwrap().version, "0.1.0");
876        // DryRun completed → current phase cleared; Publish entered.
877        assert_eq!(a.current_phase, Some(Phase::Publish));
878        assert_eq!(a.applied_seq, 6);
879    }
880
881    #[test]
882    fn reduce_ignores_slice_order() {
883        let mut events = sample_events();
884        events.reverse();
885        let out = reduce(&events);
886        // Same result as in-order despite the reversed slice.
887        assert_eq!(out, reduce(&sample_events()));
888    }
889
890    #[test]
891    fn replaying_a_seen_event_is_a_no_op() {
892        let events = sample_events();
893        let mut state = reduce(&events);
894        let before = state.clone();
895        // Re-apply an already-folded event (seq below the watermark): no change.
896        apply(&mut state, &events[2]);
897        assert_eq!(state, before);
898        // Re-apply the whole stream on top: still no change (watermark holds).
899        for ev in &events {
900            apply(&mut state, ev);
901        }
902        assert_eq!(state, before);
903    }
904
905    #[test]
906    fn structural_idempotency_of_publish_and_tags() {
907        // Two publishes of the same target with distinct seq → one map entry, and
908        // the later receipt wins deterministically.
909        let mut state = RunState::empty();
910        let mk = |seq: u64, kind: EventKind| JournalEvent {
911            schema_version: JOURNAL_SCHEMA_VERSION,
912            seq,
913            ts: seq,
914            idempotency_key: kind.idempotency_key(),
915            kind,
916        };
917        apply(
918            &mut state,
919            &mk(
920                1,
921                EventKind::RunCreated {
922                    run_id: "R".into(),
923                    plan_id: "p".into(),
924                    version: "0.1.0".into(),
925                    targets: vec!["cargo".into()],
926                },
927            ),
928        );
929        apply(
930            &mut state,
931            &mk(
932                2,
933                EventKind::TargetPublished {
934                    target: "cargo".into(),
935                    receipt: receipt("0.1.0"),
936                },
937            ),
938        );
939        apply(
940            &mut state,
941            &mk(
942                3,
943                EventKind::TargetPublished {
944                    target: "cargo".into(),
945                    receipt: receipt("0.1.1"),
946                },
947            ),
948        );
949        assert_eq!(state.published.len(), 1);
950        assert_eq!(state.published.get("cargo").unwrap().version, "0.1.1");
951
952        apply(
953            &mut state,
954            &mk(
955                4,
956                EventKind::TagCreatedLocal {
957                    tag: "v0.1.1".into(),
958                },
959            ),
960        );
961        apply(
962            &mut state,
963            &mk(
964                5,
965                EventKind::TagPushedRemote {
966                    tag: "v0.1.1".into(),
967                },
968            ),
969        );
970        assert_eq!(
971            state.tags.get("v0.1.1"),
972            Some(&TagState {
973                created_local: true,
974                pushed_remote: true,
975                github_release: false,
976                github_release_url: None,
977            })
978        );
979    }
980
981    #[test]
982    fn tag_phase_ok_completes_the_run() {
983        let mut state = reduce(&sample_events());
984        assert_eq!(state.status, RunStatus::InProgress);
985        let seq = state.applied_seq + 1;
986        apply(
987            &mut state,
988            &JournalEvent {
989                schema_version: JOURNAL_SCHEMA_VERSION,
990                seq,
991                ts: 9000,
992                idempotency_key: "phase_completed:tag".into(),
993                kind: EventKind::PhaseCompleted {
994                    phase: Phase::Tag,
995                    outcome: PhaseOutcome::Ok,
996                },
997            },
998        );
999        assert_eq!(state.status, RunStatus::Completed);
1000    }
1001
1002    #[test]
1003    fn run_abandoned_is_terminal_with_reason() {
1004        let mut state = reduce(&sample_events());
1005        let seq = state.applied_seq + 1;
1006        apply(
1007            &mut state,
1008            &JournalEvent {
1009                schema_version: JOURNAL_SCHEMA_VERSION,
1010                seq,
1011                ts: 9000,
1012                idempotency_key: "run_abandoned".into(),
1013                kind: EventKind::RunAbandoned {
1014                    reason: "OTP timeout".into(),
1015                },
1016            },
1017        );
1018        assert_eq!(state.status, RunStatus::Abandoned);
1019        assert_eq!(state.abandon_reason.as_deref(), Some("OTP timeout"));
1020    }
1021
1022    // ── Journal handle: create / append / manifest round-trip ──────────────
1023
1024    #[test]
1025    fn create_writes_run_created_and_manifest() {
1026        let store = FakeStore::default();
1027        let clock = FakeClock::at(1000);
1028        let idgen = FakeIdGen { id: "RUN01".into() };
1029        let journal = Journal::create(
1030            &store,
1031            &clock,
1032            &idgen,
1033            paths(),
1034            "plan-abc".into(),
1035            "0.1.0".into(),
1036            vec!["cargo".into()],
1037        )
1038        .unwrap();
1039        assert_eq!(journal.run_id(), "RUN01");
1040        assert_eq!(journal.state().run_id, "RUN01");
1041        assert_eq!(journal.state().applied_seq, 1);
1042
1043        // One durable event line was written…
1044        let lines = store.journal_lines(&paths().journal_file("RUN01"));
1045        assert_eq!(lines.len(), 1);
1046        // …and the manifest cache deserializes back to the same state.
1047        let manifest = store
1048            .inner
1049            .borrow()
1050            .files
1051            .get(&paths().manifest_file("RUN01"))
1052            .cloned()
1053            .unwrap();
1054        let loaded: RunState = serde_json::from_slice(&manifest).unwrap();
1055        assert_eq!(&loaded, journal.state());
1056    }
1057
1058    #[test]
1059    fn append_records_facts_and_a_failed_phase_can_later_complete_ok() {
1060        // Regression: an earlier design deduped appends by idempotency key, which
1061        // silently swallowed a Failed→Ok phase retry after a resume. The log now
1062        // records both facts and the reducer's upsert lands the final outcome.
1063        let store = FakeStore::default();
1064        let clock = FakeClock::at(1000);
1065        let idgen = FakeIdGen { id: "RUN01".into() };
1066        let mut journal = Journal::create(
1067            &store,
1068            &clock,
1069            &idgen,
1070            paths(),
1071            "plan-abc".into(),
1072            "0.1.0".into(),
1073            vec!["cargo".into()],
1074        )
1075        .unwrap();
1076        journal
1077            .append(EventKind::PhaseCompleted {
1078                phase: Phase::Publish,
1079                outcome: PhaseOutcome::Failed,
1080            })
1081            .unwrap();
1082        journal
1083            .append(EventKind::PhaseCompleted {
1084                phase: Phase::Publish,
1085                outcome: PhaseOutcome::Ok,
1086            })
1087            .unwrap();
1088        // Both facts are durable (RunCreated + two PhaseCompleted).
1089        let lines = store.journal_lines(&paths().journal_file("RUN01"));
1090        assert_eq!(lines.len(), 3);
1091        // The projection reflects the final Ok outcome, once.
1092        let publish = journal
1093            .state()
1094            .phases
1095            .iter()
1096            .filter(|r| r.phase == Phase::Publish)
1097            .collect::<Vec<_>>();
1098        assert_eq!(publish.len(), 1);
1099        assert_eq!(publish[0].outcome, PhaseOutcome::Ok);
1100    }
1101
1102    #[test]
1103    fn terminal_state_freezes_further_events() {
1104        // After abandonment, a stray later event must not mutate the projection.
1105        let mut state = reduce(&sample_events());
1106        let published_before = state.published.clone();
1107        let mut seq = state.applied_seq;
1108        let mut next = |kind: EventKind| {
1109            seq += 1;
1110            JournalEvent {
1111                schema_version: JOURNAL_SCHEMA_VERSION,
1112                seq,
1113                ts: 9000 + seq,
1114                idempotency_key: kind.idempotency_key(),
1115                kind,
1116            }
1117        };
1118        apply(
1119            &mut state,
1120            &next(EventKind::RunAbandoned {
1121                reason: "aborted".into(),
1122            }),
1123        );
1124        assert_eq!(state.status, RunStatus::Abandoned);
1125        // A publish recorded after abandonment is ignored.
1126        apply(
1127            &mut state,
1128            &next(EventKind::TargetPublished {
1129                target: "npm".into(),
1130                receipt: receipt("9.9.9"),
1131            }),
1132        );
1133        assert_eq!(state.status, RunStatus::Abandoned);
1134        assert_eq!(state.published, published_before);
1135        assert!(!state.published.contains_key("npm"));
1136    }
1137
1138    // ── Append-then-apply crash safety ─────────────────────────────────────
1139
1140    #[test]
1141    fn event_survives_a_manifest_write_crash() {
1142        let store = FakeStore::default();
1143        let clock = FakeClock::at(1000);
1144        let idgen = FakeIdGen { id: "RUN01".into() };
1145        let mut journal = Journal::create(
1146            &store,
1147            &clock,
1148            &idgen,
1149            paths(),
1150            "plan-abc".into(),
1151            "0.1.0".into(),
1152            vec!["cargo".into()],
1153        )
1154        .unwrap();
1155        // Arm a crash on the NEXT manifest write, then append: the durable event
1156        // lands first and the append still SUCCEEDS (the manifest is disposable),
1157        // leaving only a stale cache.
1158        store.arm_atomic_failure();
1159        journal
1160            .append(EventKind::TargetPublished {
1161                target: "cargo".into(),
1162                receipt: receipt("0.1.0"),
1163            })
1164            .unwrap();
1165        drop(journal); // release the lock
1166
1167        // Reopen: state is rebuilt from the authoritative journal, so the event's
1168        // effect is present despite the manifest write having crashed.
1169        let clock2 = FakeClock::at(2000);
1170        let reopened = Journal::open(&store, &clock2, paths(), "RUN01").unwrap();
1171        assert!(reopened.state().published.contains_key("cargo"));
1172        assert_eq!(reopened.state().applied_seq, 2);
1173        // The self-heal on open rewrote the manifest to match the log.
1174        let manifest = store
1175            .inner
1176            .borrow()
1177            .files
1178            .get(&paths().manifest_file("RUN01"))
1179            .cloned()
1180            .unwrap();
1181        let loaded: RunState = serde_json::from_slice(&manifest).unwrap();
1182        assert_eq!(&loaded, reopened.state());
1183    }
1184
1185    #[test]
1186    fn open_reduces_from_journal_when_manifest_absent() {
1187        // Simulate a wiped manifest but intact journal: write only event lines.
1188        let store = FakeStore::default();
1189        for ev in sample_events() {
1190            let line = serde_json::to_string(&ev).unwrap();
1191            store
1192                .append_line(&paths().journal_file("RUN01"), &line)
1193                .unwrap();
1194        }
1195        let clock = FakeClock::at(1000);
1196        let journal = Journal::open(&store, &clock, paths(), "RUN01").unwrap();
1197        assert_eq!(journal.state(), &reduce(&sample_events()));
1198    }
1199
1200    // ── flock mutual exclusion ─────────────────────────────────────────────
1201
1202    #[test]
1203    fn second_create_fails_while_lock_is_held() {
1204        let store = FakeStore::default();
1205        let clock = FakeClock::at(1000);
1206        let idgen = FakeIdGen { id: "RUN01".into() };
1207        let held = Journal::create(
1208            &store,
1209            &clock,
1210            &idgen,
1211            paths(),
1212            "plan-abc".into(),
1213            "0.1.0".into(),
1214            vec!["cargo".into()],
1215        )
1216        .unwrap();
1217
1218        // A concurrent cut must fail fast while the first holds the lock.
1219        let clock2 = FakeClock::at(2000);
1220        let idgen2 = FakeIdGen { id: "RUN02".into() };
1221        // `Journal` is not `Debug` (it holds trait-object ports), so inspect the
1222        // error without `unwrap_err`.
1223        let result = Journal::create(
1224            &store,
1225            &clock2,
1226            &idgen2,
1227            paths(),
1228            "plan-def".into(),
1229            "0.1.0".into(),
1230            vec!["cargo".into()],
1231        );
1232        let err = result.err().expect("concurrent create must fail");
1233        assert_eq!(err.kind(), io::ErrorKind::WouldBlock);
1234
1235        // Once the first handle drops, the lock frees and a new cut succeeds.
1236        drop(held);
1237        let clock3 = FakeClock::at(3000);
1238        let idgen3 = FakeIdGen { id: "RUN02".into() };
1239        assert!(Journal::create(
1240            &store,
1241            &clock3,
1242            &idgen3,
1243            paths(),
1244            "plan-def".into(),
1245            "0.1.0".into(),
1246            vec!["cargo".into()],
1247        )
1248        .is_ok());
1249    }
1250
1251    // ── read-only helpers ──────────────────────────────────────────────────
1252
1253    #[test]
1254    fn load_state_is_read_only_and_takes_no_lock() {
1255        let store = FakeStore::default();
1256        let clock = FakeClock::at(1000);
1257        let idgen = FakeIdGen { id: "RUN01".into() };
1258        let journal = Journal::create(
1259            &store,
1260            &clock,
1261            &idgen,
1262            paths(),
1263            "plan-abc".into(),
1264            "0.1.0".into(),
1265            vec!["cargo".into()],
1266        )
1267        .unwrap();
1268        // The lock is still held by `journal`; load_state must not need it.
1269        let loaded = load_state(&store, &paths(), "RUN01").unwrap().unwrap();
1270        assert_eq!(&loaded, journal.state());
1271        assert!(load_state(&store, &paths(), "MISSING").unwrap().is_none());
1272    }
1273
1274    #[test]
1275    fn load_state_prefers_manifest_but_falls_back_to_journal() {
1276        let store = FakeStore::default();
1277        let clock = FakeClock::at(1000);
1278        let idgen = FakeIdGen { id: "RUN01".into() };
1279        let journal = Journal::create(
1280            &store,
1281            &clock,
1282            &idgen,
1283            paths(),
1284            "plan-abc".into(),
1285            "0.1.0".into(),
1286            vec!["cargo".into()],
1287        )
1288        .unwrap();
1289        let expected = journal.state().clone();
1290        drop(journal);
1291
1292        // Fast path: manifest present → returned as-is.
1293        assert_eq!(
1294            load_state(&store, &paths(), "RUN01").unwrap(),
1295            Some(expected.clone())
1296        );
1297
1298        // Corrupt the manifest → falls back to reducing the authoritative journal.
1299        store
1300            .write_atomic(&paths().manifest_file("RUN01"), b"{not json")
1301            .unwrap();
1302        assert_eq!(
1303            load_state(&store, &paths(), "RUN01").unwrap(),
1304            Some(expected)
1305        );
1306    }
1307
1308    #[test]
1309    fn read_run_state_reduces_from_journal_without_writing() {
1310        // Write only event lines (no manifest), simulating the authoritative log.
1311        let store = FakeStore::default();
1312        for ev in sample_events() {
1313            let line = serde_json::to_string(&ev).unwrap();
1314            store
1315                .append_line(&paths().journal_file("RUN01"), &line)
1316                .unwrap();
1317        }
1318        // Snapshot every file before the read so we can prove nothing was written.
1319        let files_before = store.inner.borrow().files.clone();
1320
1321        let state = read_run_state(&store, &paths(), "RUN01").unwrap().unwrap();
1322        assert_eq!(state, reduce(&sample_events()));
1323
1324        // Read-only: no manifest self-heal, no lock file, no new bytes anywhere.
1325        assert_eq!(
1326            store.inner.borrow().files,
1327            files_before,
1328            "read_run_state must not write anything"
1329        );
1330        assert!(
1331            store.inner.borrow().locked.is_empty(),
1332            "read_run_state must not take the lock"
1333        );
1334        assert!(
1335            !store
1336                .inner
1337                .borrow()
1338                .files
1339                .contains_key(&paths().manifest_file("RUN01")),
1340            "read_run_state must not materialize a manifest"
1341        );
1342
1343        // A run with no journal is a clean None, and a bad id is rejected.
1344        assert!(read_run_state(&store, &paths(), "MISSING")
1345            .unwrap()
1346            .is_none());
1347        assert_eq!(
1348            read_run_state(&store, &paths(), "../escape")
1349                .unwrap_err()
1350                .kind(),
1351            io::ErrorKind::InvalidInput
1352        );
1353    }
1354
1355    #[test]
1356    fn read_run_returns_events_and_reduced_state() {
1357        // `release show` reads the log and its projection together, read-only.
1358        let store = FakeStore::default();
1359        for ev in sample_events() {
1360            let line = serde_json::to_string(&ev).unwrap();
1361            store
1362                .append_line(&paths().journal_file("RUN01"), &line)
1363                .unwrap();
1364        }
1365        let files_before = store.inner.borrow().files.clone();
1366
1367        let (events, state) = read_run(&store, &paths(), "RUN01").unwrap().unwrap();
1368        assert_eq!(events, sample_events());
1369        assert_eq!(state, reduce(&sample_events()));
1370
1371        // Read-only: no manifest self-heal, no lock, no new bytes.
1372        assert_eq!(store.inner.borrow().files, files_before);
1373        assert!(store.inner.borrow().locked.is_empty());
1374
1375        // A missing run is a clean None; a traversal id is rejected.
1376        assert!(read_run(&store, &paths(), "MISSING").unwrap().is_none());
1377        assert_eq!(
1378            read_run(&store, &paths(), "../escape").unwrap_err().kind(),
1379            io::ErrorKind::InvalidInput
1380        );
1381    }
1382
1383    #[test]
1384    fn read_run_state_ignores_a_stale_manifest_fast_path() {
1385        // A manifest that lags the log must NOT shadow the authoritative reduce —
1386        // this is exactly the crash window `verify` must see through.
1387        let store = FakeStore::default();
1388        for ev in sample_events() {
1389            let line = serde_json::to_string(&ev).unwrap();
1390            store
1391                .append_line(&paths().journal_file("RUN01"), &line)
1392                .unwrap();
1393        }
1394        // Plant a deliberately-wrong manifest cache.
1395        let mut stale = RunState::empty();
1396        stale.run_id = "RUN01".into();
1397        stale.plan_id = "STALE".into();
1398        store
1399            .write_atomic(
1400                &paths().manifest_file("RUN01"),
1401                &serde_json::to_vec(&stale).unwrap(),
1402            )
1403            .unwrap();
1404
1405        // load_state trusts the (stale) manifest; read_run_state reduces the log.
1406        assert_eq!(
1407            load_state(&store, &paths(), "RUN01")
1408                .unwrap()
1409                .unwrap()
1410                .plan_id,
1411            "STALE"
1412        );
1413        assert_eq!(
1414            read_run_state(&store, &paths(), "RUN01")
1415                .unwrap()
1416                .unwrap()
1417                .plan_id,
1418            "plan-abc",
1419        );
1420    }
1421
1422    #[test]
1423    fn run_id_validation_rejects_path_traversal() {
1424        let store = FakeStore::default();
1425        let clock = FakeClock::at(1000);
1426        for bad in ["..", "a/b", "", ".", "x/../y"] {
1427            assert_eq!(
1428                load_state(&store, &paths(), bad).unwrap_err().kind(),
1429                io::ErrorKind::InvalidInput,
1430                "load_state must reject run id {bad:?}"
1431            );
1432            let err = Journal::open(&store, &clock, paths(), bad).err().unwrap();
1433            assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1434        }
1435    }
1436
1437    #[test]
1438    fn list_runs_excludes_lock_and_stray_files() {
1439        let store = FakeStore::default();
1440        let clock = FakeClock::at(1000);
1441        for id in ["RUN01", "RUN02"] {
1442            let idgen = FakeIdGen { id: id.into() };
1443            let _j = Journal::create(
1444                &store,
1445                &clock,
1446                &idgen,
1447                paths(),
1448                "plan".into(),
1449                "0.1.0".into(),
1450                vec!["cargo".into()],
1451            )
1452            .unwrap();
1453        }
1454        // A leftover atomic-write temp file and a stray top-level file must not be
1455        // reported as runs (only entries with a non-empty journal count).
1456        store
1457            .write_atomic(&paths().releases_dir().join("journal.jsonl.tmp"), b"x")
1458            .unwrap();
1459        store
1460            .write_atomic(&paths().releases_dir().join(".lock"), b"")
1461            .unwrap();
1462        let runs = list_runs(&store, &paths()).unwrap();
1463        assert_eq!(runs, vec!["RUN01".to_string(), "RUN02".to_string()]);
1464    }
1465
1466    // ── forward tolerance ──────────────────────────────────────────────────
1467
1468    #[test]
1469    fn read_events_refuses_a_too_new_schema_version() {
1470        let store = FakeStore::default();
1471        let mut ev = sample_events()[0].clone();
1472        ev.schema_version = JOURNAL_SCHEMA_VERSION + 1;
1473        let line = serde_json::to_string(&ev).unwrap();
1474        store
1475            .append_line(&paths().journal_file("RUN01"), &line)
1476            .unwrap();
1477        let err = read_events(&store, &paths().journal_file("RUN01")).unwrap_err();
1478        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1479    }
1480
1481    #[test]
1482    fn read_events_tolerates_unknown_additive_fields() {
1483        // An extra top-level field a newer ossctl might add is ignored, not fatal.
1484        let store = FakeStore::default();
1485        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}"#;
1486        store
1487            .append_line(&paths().journal_file("RUN01"), line)
1488            .unwrap();
1489        let events = read_events(&store, &paths().journal_file("RUN01")).unwrap();
1490        assert_eq!(events.len(), 1);
1491        assert_eq!(events[0].seq, 1);
1492    }
1493
1494    #[test]
1495    fn run_status_as_str_matches_serde() {
1496        for s in [
1497            RunStatus::InProgress,
1498            RunStatus::Completed,
1499            RunStatus::Abandoned,
1500        ] {
1501            assert_eq!(
1502                serde_json::to_value(s).unwrap(),
1503                serde_json::Value::String(s.as_str().to_string()),
1504                "as_str() drifted from serde for {s:?}"
1505            );
1506        }
1507    }
1508
1509    #[test]
1510    fn read_events_skips_blank_lines() {
1511        let store = FakeStore::default();
1512        store
1513            .append_line(&paths().journal_file("RUN01"), "")
1514            .unwrap();
1515        assert!(read_events(&store, &paths().journal_file("RUN01"))
1516            .unwrap()
1517            .is_empty());
1518    }
1519}