Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

src/viz/release_wizard.rs

//! 🧙 **Release wizard** — the guided, gated walkthrough the 🧬 dashboard's Release
//! button opens (task #16). NOT a one-shot run: it orchestrates the EXISTING
//! release tiers as four sequential gates, each of which must be GREEN to advance
//! (with an explicit operator override):
//!
//! 1. **Advise** — [`release::doctor::run`](crate::release::doctor) (already
//!    computed into the shared [`GateModel`](super::gate::GateModel)): dirty trees,
//!    skew, ⛔ transitive-pins, `cycle_advice`, the edition gate. Problems light up
//!    on the dashboard graph (the #15 gate overlay).
//! 2. **Fix** — surface the suggested moves (bump skew → target, cut the cycle's
//!    `via` edge, `cargo fix --edition`); re-run Advise until clear.
//! 3. **Rehearse** — [`release::stage`](crate::release::stage) `--execute` into the
//!    embedded-holger `/sparring` registry; stream publish order + per-dependent
//!    build-verify.
//! 4. **Promote** — `release promote` to real crates.io in topo order; the
//!    irreversible step, behind a typed hard-confirm.
//!
//! The wizard is **orchestration UI only** — it reuses the shared gate model and
//! calls the existing engines; it adds NO release logic. The step state machine
//! ([`WizardStep`] + [`WizardModel`]) is a PURE, unit-tested type: which steps are
//! reachable, `can_advance(step)`, and what blocks. The UI renders it; the robot
//! test drives it. Steps 3 + 4 mutate / are slow + external, so they are wired to
//! the real engines behind explicit buttons, run on a background thread (never the
//! egui paint thread), and stream progress; promote requires a typed confirmation.
//! Tests do NOT run real stage/promote — they exercise the state machine + gating
//! with advisory/seeded data only.

use std::sync::{Arc, Mutex};

use super::gate::GateModel;

/// The four release tiers, in order. Each is a gate the wizard walks through.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum WizardStep {
    /// `release doctor` — read the advisory verdict (the shared gate model).
    Advise,
    /// Surface + apply the suggested fixes; re-run Advise until clear.
    Fix,
    /// `release stage --execute` into the embedded-holger `/sparring` registry.
    Rehearse,
    /// `release promote` to crates.io — irreversible, typed confirm.
    Promote,
}

impl WizardStep {
    /// All steps in walk order.
    pub const ORDER: [WizardStep; 4] = [
        WizardStep::Advise,
        WizardStep::Fix,
        WizardStep::Rehearse,
        WizardStep::Promote,
    ];

    /// The step's position (0..4) in the walk.
    pub fn index(self) -> usize {
        Self::ORDER.iter().position(|s| *s == self).unwrap_or(0)
    }

    /// The next step, or `None` past the end.
    pub fn next(self) -> Option<WizardStep> {
        Self::ORDER.get(self.index() + 1).copied()
    }

    /// The stable debug name used in `state_json` + the robot-drive contract.
    pub fn name(self) -> &'static str {
        match self {
            WizardStep::Advise => "Advise",
            WizardStep::Fix => "Fix",
            WizardStep::Rehearse => "Rehearse",
            WizardStep::Promote => "Promote",
        }
    }

    /// The header label the wizard paints + a robot click matches.
    pub fn label(self) -> &'static str {
        match self {
            WizardStep::Advise => "1 · Advise",
            WizardStep::Fix => "2 · Fix",
            WizardStep::Rehearse => "3 · Rehearse",
            WizardStep::Promote => "4 · Promote",
        }
    }

    /// Resolve a step from its `state_json` name.
    pub fn from_name(name: &str) -> Option<WizardStep> {
        Self::ORDER.into_iter().find(|s| s.name() == name)
    }
}

/// A step's gate verdict — whether the gate for THIS step is satisfied.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum GateStatus {
    /// The gate is satisfied — this step is clear to advance from.
    Green,
    /// The gate is not satisfied (blocking problems) — needs an override to pass.
    Red,
    /// The step's work has not been run/observed yet (the slow Rehearse/Promote
    /// tiers before their async job runs).
    Pending,
}

impl GateStatus {
    fn as_str(self) -> &'static str {
        match self {
            GateStatus::Green => "green",
            GateStatus::Red => "red",
            GateStatus::Pending => "pending",
        }
    }
}

/// The live state of an async tier job (Rehearse / Promote) — owned behind an
/// `Arc<Mutex<…>>` so the background thread streams progress into it while the
/// egui paint thread only reads it. NEVER runs the engine on the paint thread.
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct JobProgress {
    /// `true` while the background thread is running.
    pub running: bool,
    /// `true` once it finished (success or failure).
    pub done: bool,
    /// `true` when it finished cleanly (the gate goes green).
    pub ok: bool,
    /// Streamed progress lines (publish order, per-dependent build-verify, …).
    pub lines: Vec<String>,
    /// A terminal error, if the job failed.
    pub error: Option<String>,
}

/// The PURE step state machine + gating logic — the testable core. Holds the
/// current step, the per-step override-armed flags, the promote typed-confirm
/// buffer, and (read-only) handles to the async tier jobs. It does NOT own the
/// gate model (that lives in the dashboard's [`GateModel`]); gating is computed
/// from a borrowed model so the wizard never recomputes the verdict.
pub struct WizardModel {
    /// Where in the walk the operator is.
    pub current: WizardStep,
    /// Per-step override-armed: the operator explicitly chose to advance past a
    /// RED step. Indexed by [`WizardStep::index`].
    overrides: [bool; 4],
    /// The exact phrase the operator must type to arm the irreversible promote.
    pub promote_confirm_phrase: String,
    /// What the operator has typed into the promote confirm box so far.
    pub promote_confirm_input: String,
    /// The Rehearse tier's async job (stage --execute into /sparring).
    rehearse: Arc<Mutex<JobProgress>>,
    /// The Promote tier's async job (publish to crates.io in topo order).
    promote: Arc<Mutex<JobProgress>>,
    /// The Fix tier's APPLY record: what `apply_skew_bumps` did when the operator
    /// hit "apply fixes" (the bumps that turned the skew advice into real edits).
    /// Fast + synchronous (a toml_edit rewrite), so it's filled in-place by the
    /// dashboard, not on a background thread; held here so it surfaces in
    /// `state_json` and the UI after a re-run of Advise.
    fix: Arc<Mutex<JobProgress>>,
}

impl Default for WizardModel {
    fn default() -> Self {
        Self {
            current: WizardStep::Advise,
            overrides: [false; 4],
            promote_confirm_phrase: "PROMOTE".to_string(),
            promote_confirm_input: String::new(),
            rehearse: Arc::new(Mutex::new(JobProgress::default())),
            promote: Arc::new(Mutex::new(JobProgress::default())),
            fix: Arc::new(Mutex::new(JobProgress::default())),
        }
    }
}

impl WizardModel {
    pub fn new() -> Self {
        Self::default()
    }

    /// A snapshot of the Rehearse job (cheap clone of the streamed state).
    pub fn rehearse_progress(&self) -> JobProgress {
        self.rehearse.lock().map(|g| g.clone()).unwrap_or_default()
    }

    /// A snapshot of the Promote job.
    pub fn promote_progress(&self) -> JobProgress {
        self.promote.lock().map(|g| g.clone()).unwrap_or_default()
    }

    /// A snapshot of the Fix-apply record (what the bumps did, last apply).
    pub fn fix_progress(&self) -> JobProgress {
        self.fix.lock().map(|g| g.clone()).unwrap_or_default()
    }

    /// Record the outcome of an "apply fixes" action (the dashboard ran
    /// `release::cargo::apply_skew_bumps` against the checkout). `lines` are the
    /// human bump descriptions; `ok` is false only if the apply errored. After
    /// this the caller re-runs Advise so the gates re-evaluate against the edits.
    pub fn record_fix_applied(&self, ok: bool, lines: Vec<String>, error: Option<String>) {
        if let Ok(mut g) = self.fix.lock() {
            g.running = false;
            g.done = true;
            g.ok = ok;
            g.lines = lines;
            g.error = error;
        }
    }

    /// `true` once the operator has typed the exact confirm phrase — the promote
    /// hard-confirm. Whitespace-trimmed, case-sensitive (a deliberate friction).
    pub fn promote_confirmed(&self) -> bool {
        self.promote_confirm_input.trim() == self.promote_confirm_phrase
    }

    /// Is the override armed for `step`?
    pub fn override_armed(&self, step: WizardStep) -> bool {
        self.overrides[step.index()]
    }

    /// Arm / disarm the override for a step (the explicit "advance anyway").
    pub fn set_override(&mut self, step: WizardStep, armed: bool) {
        self.overrides[step.index()] = armed;
    }

    /// THE GATE (pure). The status of `step` given the shared gate model:
    ///
    /// * **Advise** — green when `gate.gate_ok()` (no blocking ⛔ / edition fail);
    ///   red otherwise. This is the verdict the dashboard already computes.
    /// * **Fix** — green once Advise is green (nothing left to fix); red while
    ///   Advise is red (suggested moves are still outstanding).
    /// * **Rehearse** — pending until the stage job has run; green when it
    ///   finished ok, red when it failed.
    /// * **Promote** — pending until the promote job has run; green when it
    ///   finished ok, red when it failed.
    ///
    /// `gate` is `None` only when no model is loaded (remote / no checkout) — the
    /// advisory tiers then read pending (nothing to advise on yet).
    pub fn gate_status(&self, step: WizardStep, gate: Option<&GateModel>) -> GateStatus {
        match step {
            WizardStep::Advise | WizardStep::Fix => match gate {
                None => GateStatus::Pending,
                Some(m) if m.gate_ok() => GateStatus::Green,
                Some(_) => GateStatus::Red,
            },
            WizardStep::Rehearse => job_status(&self.rehearse_progress()),
            WizardStep::Promote => job_status(&self.promote_progress()),
        }
    }

    /// Can the operator advance PAST `step` to the next tier? A step's gate must be
    /// GREEN — unless its override is armed (the explicit escape hatch). The
    /// terminal Promote step has no "next", so it never advances.
    ///
    /// Promote additionally requires the typed confirm: you cannot advance INTO an
    /// armed promote without it (enforced in [`Self::can_run_promote`]); advancing
    /// *out of* Rehearse only needs Rehearse green/overridden.
    pub fn can_advance(&self, step: WizardStep, gate: Option<&GateModel>) -> bool {
        if step.next().is_none() {
            return false; // Promote is terminal.
        }
        match self.gate_status(step, gate) {
            GateStatus::Green => true,
            GateStatus::Red | GateStatus::Pending => self.override_armed(step),
        }
    }

    /// What BLOCKS advancing past `step` (human strings the UI surfaces + the test
    /// asserts). Empty ⇔ [`Self::can_advance`] is true.
    pub fn blockers(&self, step: WizardStep, gate: Option<&GateModel>) -> Vec<String> {
        if self.can_advance(step, gate) {
            return Vec::new();
        }
        if step.next().is_none() {
            return vec!["Promote is the final, irreversible tier — nothing to advance to".into()];
        }
        let mut out = Vec::new();
        match step {
            WizardStep::Advise | WizardStep::Fix => {
                if let Some(m) = gate {
                    let held = m.held_back();
                    if !held.is_empty() {
                        out.push(format!(
                            "{} held back by a transitive pin: {}",
                            held.len(),
                            held.join(", ")
                        ));
                    }
                    if !m.edition.is_clean() {
                        out.push(format!(
                            "edition gate not clean ({} finding(s))",
                            m.edition.static_findings.len() + m.edition.lints.len()
                        ));
                    }
                    if !m.doctor.cycle_advice.is_empty() {
                        out.push(format!(
                            "{} dependency cycle(s) to cut",
                            m.doctor.cycle_advice.len()
                        ));
                    }
                } else {
                    out.push("no gate model loaded (remote / no checkout)".into());
                }
            }
            WizardStep::Rehearse => {
                let p = self.rehearse_progress();
                if let Some(e) = &p.error {
                    out.push(format!("rehearsal failed: {e}"));
                } else if !p.done {
                    out.push("rehearsal has not run — run stage --execute into /sparring".into());
                }
            }
            WizardStep::Promote => {}
        }
        if out.is_empty() {
            out.push("step gate is not green".into());
        }
        out
    }

    /// Can the irreversible promote job be RUN? It needs Rehearse green (the
    /// rehearsal must have passed, or Rehearse's override armed) AND the typed
    /// confirm phrase. This is the hard gate on the only mutating-to-crates.io tier.
    pub fn can_run_promote(&self, gate: Option<&GateModel>) -> bool {
        let rehearse_ok = matches!(
            self.gate_status(WizardStep::Rehearse, gate),
            GateStatus::Green
        ) || self.override_armed(WizardStep::Rehearse);
        rehearse_ok && self.promote_confirmed()
    }

    /// Advance to the next step IF allowed. Returns the new current step. Clears the
    /// promote confirm buffer on leaving a step (re-typed per arrival at Promote).
    pub fn advance(&mut self, gate: Option<&GateModel>) -> WizardStep {
        if self.can_advance(self.current, gate) {
            if let Some(next) = self.current.next() {
                self.current = next;
            }
        }
        self.current
    }

    /// Jump back one step (always allowed — going back is never gated).
    pub fn back(&mut self) -> WizardStep {
        let idx = self.current.index();
        if idx > 0 {
            self.current = WizardStep::ORDER[idx - 1];
        }
        self.current
    }

    /// Reset the wizard to step 1, clearing overrides + the async jobs + confirm.
    pub fn reset(&mut self) {
        self.current = WizardStep::Advise;
        self.overrides = [false; 4];
        self.promote_confirm_input.clear();
        *self.rehearse.lock().unwrap() = JobProgress::default();
        *self.promote.lock().unwrap() = JobProgress::default();
        *self.fix.lock().unwrap() = JobProgress::default();
    }

    /// Hand out the Rehearse job handle for a background thread to fill. The thread
    /// owns the `Arc`; the wizard reads it each frame via [`Self::rehearse_progress`].
    pub fn rehearse_handle(&self) -> Arc<Mutex<JobProgress>> {
        Arc::clone(&self.rehearse)
    }

    /// Hand out the Promote job handle.
    pub fn promote_handle(&self) -> Arc<Mutex<JobProgress>> {
        Arc::clone(&self.promote)
    }

    /// Test/inject seam: seed the Rehearse job state directly (no background thread,
    /// no real stage run), so the state machine + UI gating can be exercised with
    /// the rehearse tier "already passed/failed" in tests.
    #[doc(hidden)]
    pub fn inject_rehearse_for_test(&self, ok: bool, lines: Vec<String>) {
        let mut g = self.rehearse.lock().unwrap();
        g.running = false;
        g.done = true;
        g.ok = ok;
        g.lines = lines;
        g.error = (!ok).then(|| "seeded rehearse failure".to_string());
    }

    /// The suggested FIX moves the wizard surfaces (step 2) — derived from the
    /// shared gate model, NOT new logic: bump each held/behind repo → the skew
    /// target, cut each cycle's `via` edge (the doctor's `cycle_advice`), and
    /// `cargo fix --edition` for each edition finding. Pure; empty when clean.
    pub fn fix_moves(gate: Option<&GateModel>) -> Vec<String> {
        let Some(m) = gate else { return Vec::new() };
        let mut out = Vec::new();
        for c in &m.doctor.skew {
            let bump: Vec<&str> = c.bump_repos();
            if !bump.is_empty() {
                out.push(format!(
                    "bump `{}` → {} in: {}",
                    c.crate_name,
                    c.target,
                    bump.join(", ")
                ));
            }
        }
        for a in &m.doctor.cycle_advice {
            out.push(format!(
                "cut `{} → {}`: {}",
                a.cut_from, a.cut_to, a.rationale
            ));
        }
        for f in &m.edition.static_findings {
            out.push(format!(
                "cargo fix --edition for `{}`: {}",
                f.package, f.issue
            ));
        }
        out
    }

    /// The wizard's slice of `state_json` (LAW #6): current step + per-step gate
    /// status / can_advance / blockers / override-armed, the promote confirm state,
    /// the suggested fix moves, and the async tier job progress. So the robot test
    /// reads the step + gating back as data, no pixels.
    pub fn state_json(&self, gate: Option<&GateModel>) -> serde_json::Value {
        let steps: Vec<serde_json::Value> = WizardStep::ORDER
            .iter()
            .map(|s| {
                serde_json::json!({
                    "step": s.name(),
                    "label": s.label(),
                    "index": s.index(),
                    "gate_status": self.gate_status(*s, gate).as_str(),
                    "can_advance": self.can_advance(*s, gate),
                    "blockers": self.blockers(*s, gate),
                    "override_armed": self.override_armed(*s),
                    "is_current": *s == self.current,
                })
            })
            .collect();
        let rehearse = self.rehearse_progress();
        let promote = self.promote_progress();
        serde_json::json!({
            "current_step": self.current.name(),
            "current_index": self.current.index(),
            "steps": steps,
            // The active step's gating, surfaced flat for the common assertion.
            "current_gate_status": self.gate_status(self.current, gate).as_str(),
            "current_can_advance": self.can_advance(self.current, gate),
            "current_blockers": self.blockers(self.current, gate),
            "override_armed": self.override_armed(self.current),
            // Step 2 — the suggested fix moves (derived from the gate model) +
            // the record of the last "apply fixes" run (the bumps it applied).
            "fix_moves": Self::fix_moves(gate),
            "fix_applied": self.fix_progress(),
            // Step 4 — the irreversible promote's typed hard-confirm.
            "promote": {
                "confirm_phrase": self.promote_confirm_phrase,
                "confirm_input": self.promote_confirm_input,
                "confirmed": self.promote_confirmed(),
                "can_run": self.can_run_promote(gate),
                "progress": promote,
            },
            // Step 3 — the rehearsal job progress (stage --execute into /sparring).
            "rehearse": rehearse,
        })
    }
}

/// Map an async job's progress to a step gate status.
fn job_status(p: &JobProgress) -> GateStatus {
    if !p.done {
        GateStatus::Pending
    } else if p.ok {
        GateStatus::Green
    } else {
        GateStatus::Red
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::release::doctor::{
        CrateSkew, CycleAdvice, DoctorReport, RepoCrateStatus, RepoDirty, RepoEdge, SkewStatus,
        TopoReport,
    };
    use nornir_release_core::edition::{EditionFinding, EditionReport};

    /// A RED gate model: nornir held back by a transitive pin + edition not clean +
    /// one cycle (the arrow-58 case).
    fn red_gate() -> GateModel {
        let doctor = DoctorReport {
            dirty: vec![RepoDirty {
                repo: "nornir".into(),
                dirty: true,
                error: None,
            }],
            skew: vec![CrateSkew {
                crate_name: "arrow".into(),
                target: "58.3.0".into(),
                diverged: true,
                entries: vec![
                    RepoCrateStatus {
                        repo: "znippy".into(),
                        version: "58.3.0".into(),
                        status: SkewStatus::Ok,
                        held_by_transitive_pin: false,
                    },
                    RepoCrateStatus {
                        repo: "nornir".into(),
                        version: "57".into(),
                        status: SkewStatus::Behind,
                        held_by_transitive_pin: true,
                    },
                ],
            }],
            topo: TopoReport {
                order: vec!["znippy".into(), "nornir".into()],
                cycle: vec![],
            },
            blast: Default::default(),
            cycle_advice: vec![CycleAdvice {
                members: vec!["nornir".into(), "znippy".into()],
                cut_from: "nornir".into(),
                cut_to: "znippy".into(),
                via: vec!["znippy-common".into()],
                rationale: "extract znippy-common into a leaf crate".into(),
            }],
            repo_edges: vec![RepoEdge {
                from: "nornir".into(),
                to: "znippy".into(),
                via: vec!["znippy-common".into()],
            }],
            patch_forks: vec![],
            promote_blocked: vec![],
            ..Default::default()
        };
        let edition = EditionReport {
            static_findings: vec![EditionFinding {
                package: "nornir".into(),
                issue: "edition = \"2021\" — want \"2024\"".into(),
            }],
            lints: vec![],
            lint_pass_ran: false,
            msrv_advisories: vec![],
            msrv_verify_errors: vec![],
            docs_errors: vec![],
        };
        GateModel::new(doctor, edition)
    }

    /// A GREEN gate model: clean DAG, nothing held, edition clean.
    fn green_gate() -> GateModel {
        let doctor = DoctorReport {
            dirty: vec![RepoDirty {
                repo: "znippy".into(),
                dirty: false,
                error: None,
            }],
            skew: vec![],
            topo: TopoReport {
                order: vec!["znippy".into(), "nornir".into()],
                cycle: vec![],
            },
            blast: Default::default(),
            cycle_advice: vec![],
            repo_edges: vec![RepoEdge {
                from: "nornir".into(),
                to: "znippy".into(),
                via: vec!["znippy-common".into()],
            }],
            patch_forks: vec![],
            promote_blocked: vec![],
            ..Default::default()
        };
        let edition = EditionReport {
            static_findings: vec![],
            lints: vec![],
            lint_pass_ran: false,
            msrv_advisories: vec![],
            msrv_verify_errors: vec![],
            docs_errors: vec![],
        };
        GateModel::new(doctor, edition)
    }

    #[test]
    fn fresh_wizard_starts_on_advise() {
        let w = WizardModel::new();
        assert_eq!(w.current, WizardStep::Advise);
        assert_eq!(w.current.index(), 0);
    }

    #[test]
    fn cannot_advance_past_a_red_advise_without_override() {
        let w = WizardModel::new();
        let g = red_gate();
        assert_eq!(w.gate_status(WizardStep::Advise, Some(&g)), GateStatus::Red);
        assert!(
            !w.can_advance(WizardStep::Advise, Some(&g)),
            "red Advise blocks advance"
        );
        let blockers = w.blockers(WizardStep::Advise, Some(&g));
        assert!(!blockers.is_empty(), "blockers surfaced: {blockers:?}");
        assert!(
            blockers.iter().any(|b| b.contains("nornir")),
            "names the held repo"
        );
    }

    #[test]
    fn override_lets_a_red_step_advance() {
        let mut w = WizardModel::new();
        let g = red_gate();
        assert!(!w.can_advance(WizardStep::Advise, Some(&g)));
        w.set_override(WizardStep::Advise, true);
        assert!(
            w.can_advance(WizardStep::Advise, Some(&g)),
            "the explicit override unblocks it"
        );
        assert_eq!(w.advance(Some(&g)), WizardStep::Fix, "advanced to Fix");
    }

    #[test]
    fn green_gate_advances_advise_and_fix_freely() {
        let mut w = WizardModel::new();
        let g = green_gate();
        assert_eq!(
            w.gate_status(WizardStep::Advise, Some(&g)),
            GateStatus::Green
        );
        assert!(w.can_advance(WizardStep::Advise, Some(&g)));
        assert_eq!(w.advance(Some(&g)), WizardStep::Fix);
        assert!(
            w.can_advance(WizardStep::Fix, Some(&g)),
            "Fix is clear once Advise is green"
        );
        assert_eq!(w.advance(Some(&g)), WizardStep::Rehearse);
    }

    #[test]
    fn rehearse_is_pending_until_the_job_runs_then_green() {
        let mut w = WizardModel::new();
        let g = green_gate();
        // Pending before any rehearsal → blocks advance (no override).
        assert_eq!(
            w.gate_status(WizardStep::Rehearse, Some(&g)),
            GateStatus::Pending
        );
        assert!(
            !w.can_advance(WizardStep::Rehearse, Some(&g)),
            "pending rehearse blocks"
        );
        // Seed a passed rehearsal (the inject seam, no real stage run).
        w.inject_rehearse_for_test(true, vec!["published znippy → /sparring".into()]);
        assert_eq!(
            w.gate_status(WizardStep::Rehearse, Some(&g)),
            GateStatus::Green
        );
        assert!(
            w.can_advance(WizardStep::Rehearse, Some(&g)),
            "a green rehearsal advances"
        );
    }

    #[test]
    fn failed_rehearse_is_red_and_blocks() {
        let w = WizardModel::new();
        let g = green_gate();
        w.inject_rehearse_for_test(false, vec!["build failed".into()]);
        assert_eq!(
            w.gate_status(WizardStep::Rehearse, Some(&g)),
            GateStatus::Red
        );
        assert!(!w.can_advance(WizardStep::Rehearse, Some(&g)));
        let blockers = w.blockers(WizardStep::Rehearse, Some(&g));
        assert!(
            blockers.iter().any(|b| b.contains("failed")),
            "{blockers:?}"
        );
    }

    #[test]
    fn promote_requires_a_typed_confirm() {
        let mut w = WizardModel::new();
        let g = green_gate();
        // Rehearse passed so the only thing left is the confirm.
        w.inject_rehearse_for_test(true, vec![]);
        assert!(
            !w.can_run_promote(Some(&g)),
            "no confirm typed → promote is locked"
        );
        w.promote_confirm_input = "promote".into(); // wrong case
        assert!(!w.promote_confirmed(), "the confirm is case-sensitive");
        assert!(!w.can_run_promote(Some(&g)));
        w.promote_confirm_input = "PROMOTE".into();
        assert!(w.promote_confirmed());
        assert!(
            w.can_run_promote(Some(&g)),
            "rehearse green + typed confirm unlocks promote"
        );
    }

    #[test]
    fn promote_locked_until_rehearse_passes_even_with_confirm() {
        let mut w = WizardModel::new();
        let g = green_gate();
        w.promote_confirm_input = "PROMOTE".into();
        assert!(w.promote_confirmed());
        // Rehearse never ran → still locked despite the typed confirm.
        assert!(!w.can_run_promote(Some(&g)), "rehearse must pass first");
        // Override the rehearse gate → now the confirm is sufficient.
        w.set_override(WizardStep::Rehearse, true);
        assert!(w.can_run_promote(Some(&g)));
    }

    #[test]
    fn promote_is_terminal_never_advances() {
        let w = WizardModel::new();
        let g = green_gate();
        assert!(
            !w.can_advance(WizardStep::Promote, Some(&g)),
            "Promote has no next tier"
        );
    }

    #[test]
    fn fix_moves_are_derived_from_the_gate_model() {
        let g = red_gate();
        let moves = WizardModel::fix_moves(Some(&g));
        assert!(
            moves
                .iter()
                .any(|m| m.contains("bump `arrow`") && m.contains("58.3.0"))
        );
        assert!(moves.iter().any(|m| m.contains("cut `nornir → znippy`")));
        assert!(moves.iter().any(|m| m.contains("cargo fix --edition")));
        // A clean model has nothing to fix.
        assert!(WizardModel::fix_moves(Some(&green_gate())).is_empty());
    }

    #[test]
    fn record_fix_applied_surfaces_in_state_json() {
        let w = WizardModel::new();
        let g = red_gate();
        // Before any apply, the fix record is empty/not-done.
        assert!(!w.fix_progress().done);
        assert_eq!(w.state_json(Some(&g))["fix_applied"]["done"], false);
        // Apply two bumps → the record carries them.
        w.record_fix_applied(
            true,
            vec!["bumped arrow → 58.3.0 in nornir (1 file)".into()],
            None,
        );
        let fp = w.fix_progress();
        assert!(fp.done && fp.ok);
        assert_eq!(fp.lines.len(), 1);
        let js = w.state_json(Some(&g));
        assert_eq!(js["fix_applied"]["done"], true);
        assert_eq!(js["fix_applied"]["ok"], true);
        assert!(!js["fix_applied"]["lines"].as_array().unwrap().is_empty());
        // reset clears it.
        let mut w = w;
        w.reset();
        assert!(!w.fix_progress().done, "reset clears the fix record");
    }

    #[test]
    fn state_json_carries_step_and_gating() {
        let w = WizardModel::new();
        let g = red_gate();
        let js = w.state_json(Some(&g));
        assert_eq!(js["current_step"], "Advise");
        assert_eq!(js["current_gate_status"], "red");
        assert_eq!(js["current_can_advance"], false);
        assert_eq!(js["steps"].as_array().unwrap().len(), 4);
        assert_eq!(js["steps"][0]["step"], "Advise");
        assert_eq!(js["steps"][3]["step"], "Promote");
        assert_eq!(js["promote"]["confirmed"], false);
        assert!(!js["fix_moves"].as_array().unwrap().is_empty());
    }
}