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());
}
}