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

nordisk/nornir

src/viz/nornir_dashboard.rs

//! 🧬 **nornir RELEASE DASHBOARD** — go/no-go release state in ONE glance.
//!
//! A **Release button** + a **gate-overlaid dependency graph** beside it. The
//! button reflects the gate verdict (green/go, red+disabled/no-go) and the graph
//! shows WHY: each repo chip is badged from the shared [`gate`](super::gate) model
//! (dirty 🟡 · behind · ⛔ held-by-transitive-pin · edition-fail), and every
//! dependency cycle is drawn with its suggested **cut edge** dashed + the
//! rationale. Clicking a node scopes the release to that repo and lights its blast
//! radius (the downstream subtree) via the SHARED [`graph_render::draw_graph`] —
//! the SAME renderer the 🏛 Architecture board uses (no duplicated canvas).
//!
//! The gate model is the shared [`gate::GateModel`] (the 🚀 Release pane's exact
//! computation + `gate_json` shape) so the dashboard never recomputes or reshapes
//! the verdict. Node→history reuses the app's already-loaded [`Timeline`].

use std::path::PathBuf;

use eframe::egui::{self, RichText, ScrollArea, Vec2};

use super::facett_theme::{AMBER, GREEN, RED, Theme, ThemeExt};
use super::gate::GateModel;
use super::graph_render::{
    self, Decorations, GraphEdge, GraphModel, GraphNode, GraphView, NodeDecoration,
};
use super::model::Timeline;
use super::release_wizard::{GateStatus, WizardModel, WizardStep};
use crate::release::doctor::DepPolicy;

/// Column / row pitch for the repo-graph layout (world units, before zoom).
const COL_W: f32 = 230.0;
const ROW_H: f32 = 70.0;

/// 🧬 The release-dashboard state. Owns the gate inputs (local-mode checkout +
/// repos + policy), the cached shared gate model, the canvas view (pan/zoom +
/// selection), the release-scope (clicked node), and the last Release-button run.
pub struct NornirDashboard {
    /// Gate inputs: workspace checkout root + repos (local mode only — remote has
    /// no checkout to scan, so the dashboard shows the gate as unavailable there).
    gate_repos: Vec<(String, PathBuf)>,
    policy: DepPolicy,
    /// `true` in local mode (a checkout exists to scan); `false` remote.
    local: bool,
    /// The cached SHARED gate model — computed once per load/reload, NOT per frame.
    gate: Option<GateModel>,
    gate_error: Option<String>,
    /// `true` once a gate compute has been attempted (so we don't recompute every
    /// frame). Reset by `reload`.
    loaded: bool,
    /// The release scope: the repo a node-click pinned the Release button to
    /// (`None` = whole workspace / advisory `release doctor`).
    scoped_repo: Option<String>,
    /// The canvas pan/zoom + selected node id (drives the blast-radius highlight).
    view: GraphView,
    /// The last Release-button result line (advisory run summary).
    last_run: Option<String>,
    /// 🧙 The guided RELEASE WIZARD the Release button opens — a gated walk over
    /// the existing release tiers (Advise → Fix → Rehearse → Promote). Orchestration
    /// only; it reads the SHARED gate model + calls the existing engines.
    wizard: WizardModel,
    /// Whether the wizard panel is open (the Release button toggles it on).
    wizard_open: bool,
    theme: Theme,
}

impl Default for NornirDashboard {
    fn default() -> Self {
        Self {
            gate_repos: Vec::new(),
            policy: DepPolicy::default(),
            local: false,
            gate: None,
            gate_error: None,
            loaded: false,
            scoped_repo: None,
            view: GraphView::default(),
            last_run: None,
            wizard: WizardModel::new(),
            wizard_open: false,
            theme: Theme::default(),
        }
    }
}

impl NornirDashboard {
    pub fn set_palette(&mut self, t: Theme) {
        self.theme = t;
    }

    /// Wire the gate inputs (local mode only). Repo checkouts are `root/<repo>`;
    /// repos without a `Cargo.toml` are skipped. Drops the cache so the next draw
    /// recomputes. Mirrors `ReleaseTabState::set_gate_inputs`.
    pub fn set_gate_inputs(&mut self, root: &std::path::Path, repos: &[String], policy: DepPolicy) {
        self.gate_repos = repos
            .iter()
            .map(|name| (name.clone(), root.join(name)))
            .filter(|(_, p)| p.join("Cargo.toml").exists())
            .collect();
        self.policy = policy;
        self.local = true;
        self.gate = None;
        self.gate_error = None;
        self.loaded = false;
    }

    /// Remote mode: no checkout to scan — the gate is unavailable (the graph still
    /// renders from an injected model in tests / the gate section shows a note).
    pub fn set_remote(&mut self) {
        self.local = false;
        self.reload();
    }

    /// Drop the cached gate so the next draw recomputes (workspace switch / reload).
    pub fn reload(&mut self) {
        self.loaded = false;
        self.gate = None;
        self.gate_error = None;
        self.scoped_repo = None;
        self.view.clear_selection();
        self.wizard.reset();
        self.wizard_open = false;
    }

    /// Compute (and cache) the shared gate model once per load — NOT per repaint.
    /// Static-only edition pass (the perf caveat); local mode only.
    fn ensure_gate(&mut self) {
        if self.loaded {
            return;
        }
        self.loaded = true;
        if self.gate_repos.is_empty() {
            return;
        }
        match GateModel::compute(&self.gate_repos, &self.policy, false) {
            Ok(m) => {
                self.gate = Some(m);
                self.gate_error = None;
            }
            Err(e) => self.gate_error = Some(e),
        }
    }

    /// Test-only: inject a gate model directly (no checkout to scan), so the robot
    /// / inject-assert harness reads `state_json` back. Mirrors the Release pane's
    /// `inject_gate_for_test`.
    #[doc(hidden)]
    pub fn inject_gate_for_test(
        &mut self,
        doctor: crate::release::doctor::DoctorReport,
        edition: nornir_release_core::edition::EditionReport,
    ) {
        if self.gate_repos.is_empty() {
            self.gate_repos.push(("nornir".into(), PathBuf::from(".")));
        }
        self.local = true;
        self.gate = Some(GateModel::new(doctor, edition));
        self.gate_error = None;
        self.loaded = true;
    }

    /// Drive a node click from a test (parity with a pointer click on the graph):
    /// scope the release to `repo` + light its blast radius. Returns whether the
    /// repo is a node in the current graph.
    #[doc(hidden)]
    pub fn select_repo_for_test(&mut self, repo: &str) -> bool {
        let in_graph = self
            .gate
            .as_ref()
            .map(|m| self.repo_nodes(m).iter().any(|r| r == repo))
            .unwrap_or(false);
        if in_graph {
            self.scoped_repo = Some(repo.to_string());
            self.view.selected = Some(repo.to_string());
        }
        in_graph
    }

    /// Test/robot-drive seam: open the wizard (what the Release button does), so
    /// the inject-assert harness reads `state_json()["release_wizard"]` back without
    /// a pointer click. Parity with clicking 🚀 Release.
    #[doc(hidden)]
    pub fn open_wizard_for_test(&mut self) {
        self.wizard_open = true;
    }

    /// Test/robot-drive seam: arm/disarm the override for a wizard step (the
    /// explicit "advance anyway" past a red gate).
    #[doc(hidden)]
    pub fn wizard_set_override_for_test(&mut self, step: &str, armed: bool) -> bool {
        match WizardStep::from_name(step) {
            Some(s) => {
                self.wizard.set_override(s, armed);
                true
            }
            None => false,
        }
    }

    /// Test/robot-drive seam: advance the wizard one step IF its gate allows it
    /// (green or overridden). Returns the new current step name.
    #[doc(hidden)]
    pub fn wizard_advance_for_test(&mut self) -> String {
        let step = self.wizard.advance(self.gate.as_ref());
        step.name().to_string()
    }

    /// Test/robot-drive seam: drive the Fix step's "apply fixes" action (the
    /// `apply_skew_bumps` + re-advise path) without a pointer click. Returns the
    /// recorded fix lines so a test can assert what was bumped. With injected
    /// repos that have no on-disk checkout, this exercises the orchestration and
    /// the record/re-advise path (the bump itself is unit-tested in `cargo.rs`).
    #[doc(hidden)]
    pub fn wizard_apply_fixes_for_test(&mut self) -> Vec<String> {
        self.apply_fixes();
        self.wizard.fix_progress().lines
    }

    /// Test/robot-drive seam: seed the Rehearse tier as already passed/failed (no
    /// real stage run), so the gating + promote path is exercisable in tests.
    #[doc(hidden)]
    pub fn wizard_inject_rehearse_for_test(&mut self, ok: bool, lines: Vec<String>) {
        self.wizard.inject_rehearse_for_test(ok, lines);
    }

    /// Test/robot-drive seam: type the promote confirm phrase.
    #[doc(hidden)]
    pub fn wizard_set_promote_confirm_for_test(&mut self, input: &str) {
        self.wizard.promote_confirm_input = input.to_string();
    }

    /// The set of repo node names in the graph (topo order ∪ edge endpoints ∪
    /// skew/dirty repos), deterministic.
    fn repo_nodes(&self, m: &GateModel) -> Vec<String> {
        use std::collections::BTreeSet;
        let mut set: BTreeSet<String> = BTreeSet::new();
        for r in &m.doctor.topo.order {
            set.insert(r.clone());
        }
        for r in &m.doctor.topo.cycle {
            set.insert(r.clone());
        }
        for e in &m.doctor.repo_edges {
            set.insert(e.from.clone());
            set.insert(e.to.clone());
        }
        for d in &m.doctor.dirty {
            set.insert(d.repo.clone());
        }
        for c in &m.doctor.skew {
            for e in &c.entries {
                set.insert(e.repo.clone());
            }
        }
        // Keep topo order first (deps-first), then any extras alphabetically.
        let mut ordered: Vec<String> = m.doctor.topo.order.clone();
        for r in set {
            if !ordered.contains(&r) {
                ordered.push(r);
            }
        }
        ordered
    }

    /// Build the SHARED graph model (repo nodes laid out by topo column) + the gate
    /// DECORATIONS overlay (per-node badge/ring + each cycle's dashed cut edge). The
    /// decoration layer is domain-agnostic data the shared renderer paints — facett
    /// needn't know what a "release gate" is.
    fn build_graph(&self, m: &GateModel) -> (GraphModel, Decorations) {
        let theme = self.theme;
        let repos = self.repo_nodes(m);
        // Column = topo rank (deps-first → left); unknown repos pushed right.
        let col_of = |r: &str| -> usize { repos.iter().position(|x| x == r).unwrap_or(0) };
        let total_w = COL_W * (repos.len().saturating_sub(1)) as f32;

        // Per-repo gate facts for the badges/rings.
        let dirty: std::collections::BTreeSet<&str> = m
            .doctor
            .dirty
            .iter()
            .filter(|d| d.dirty)
            .map(|d| d.repo.as_str())
            .collect();
        let held: std::collections::BTreeSet<String> = m.held_back().into_iter().collect();
        let behind: std::collections::BTreeSet<&str> = m
            .doctor
            .skew
            .iter()
            .flat_map(|c| c.entries.iter())
            .filter(|e| e.status != crate::release::doctor::SkewStatus::Ok)
            .map(|e| e.repo.as_str())
            .collect();
        // The edition gate is scoped to the FIRST repo (mirrors the gate compute).
        let edition_fail_repo = (!m.edition.is_clean())
            .then(|| m.doctor.topo.order.first().cloned())
            .flatten();

        let mut nodes = Vec::new();
        let mut deco_nodes = std::collections::HashMap::new();
        for (i, repo) in repos.iter().enumerate() {
            let x = -total_w / 2.0 + col_of(repo) as f32 * COL_W;
            // Stagger rows so same-column repos don't overlap.
            let y = -((repos.len() as f32) * ROW_H) / 2.0 + i as f32 * ROW_H * 0.5;
            nodes.push(GraphNode {
                id: repo.clone(),
                label: repo.clone(),
                fill: theme.node_fill,
                stroke: theme.node_stroke,
                pos: egui::Pos2::new(x, y),
            });

            // The badge string + the ring colour for this repo's WORST gate fact.
            let mut badges = String::new();
            let mut ring = None;
            if held.contains(repo) {
                badges.push('⛔');
                ring = Some(RED);
            }
            if edition_fail_repo.as_deref() == Some(repo.as_str()) {
                badges.push('✗');
                ring = Some(RED);
            }
            if behind.contains(repo.as_str()) && ring.is_none() {
                badges.push('▼');
                ring = Some(AMBER);
            }
            if dirty.contains(repo.as_str()) {
                badges.push('🟡');
                if ring.is_none() {
                    ring = Some(AMBER);
                }
            }
            if ring.is_none() {
                ring = Some(GREEN); // clean repo → green go ring.
            }
            if !badges.is_empty() || ring.is_some() {
                deco_nodes.insert(
                    repo.clone(),
                    NodeDecoration {
                        ring,
                        badge: (!badges.is_empty()).then_some(badges),
                        badge_color: ring,
                    },
                );
            }
        }

        // Base edges (repo → dependency), neutral palette.
        let mut edges = Vec::new();
        for e in &m.doctor.repo_edges {
            edges.push(GraphEdge {
                from: e.from.clone(),
                to: e.to.clone(),
                color: theme.edge,
                dashed: false,
                label: None,
            });
        }

        // Decoration EDGES: each dependency cycle's suggested cut, dashed + the
        // rationale, painted on top in amber.
        let mut deco_edges = Vec::new();
        for a in &m.doctor.cycle_advice {
            deco_edges.push(GraphEdge {
                from: a.cut_from.clone(),
                to: a.cut_to.clone(),
                color: AMBER,
                dashed: true,
                label: Some(format!("✂ cut · {}", a.rationale)),
            });
        }

        (
            GraphModel { nodes, edges },
            Decorations {
                nodes: deco_nodes,
                edges: deco_edges,
            },
        )
    }

    /// Render the dashboard. `tl` is the app's loaded timeline (for node→history);
    /// `Err` / empty is fine — the history panel just shows "no releases recorded".
    pub fn draw(&mut self, ui: &mut egui::Ui, tl: Option<&Timeline>) {
        self.ensure_gate();
        let theme = self.theme;

        ui.heading("🧬 nornir — RELEASE DASHBOARD");
        ui.label(
            RichText::new(
                "go/no-go at a glance: the Release button + the gate-overlaid dependency graph",
            )
            .color(theme.text_dim),
        );
        ui.separator();

        // ── The Release button (reflects the gate verdict) ────────────────────
        let gate_ok = self.gate.as_ref().map(|m| m.gate_ok()).unwrap_or(true);
        let scope = self.scoped_repo.clone();
        ui.horizontal_wrapped(|ui| {
            let label = match &scope {
                Some(r) => format!("🚀 Release — {r}"),
                None => "🚀 Release (doctor — advisory)".to_string(),
            };
            // The Release button OPENS the guided WIZARD (NOT a one-shot run) — it
            // walks the existing tiers as gates. Always enabled (the wizard's first
            // gate is Advise, which itself reflects the verdict); a no-go is shown
            // on the button + graph and blocks the wizard from advancing.
            let btn = egui::Button::new(RichText::new(&label).color(if gate_ok {
                theme.text
            } else {
                RED
            }));
            let resp = ui.add(btn);
            if resp.clicked() {
                self.wizard_open = true;
                self.last_run = Some(match &scope {
                    Some(r) => format!("opened the release wizard scoped to {r}"),
                    None => "opened the release wizard (workspace)".to_string(),
                });
            }
            if gate_ok {
                ui.colored_label(GREEN, "✅ go");
            } else {
                ui.colored_label(RED, "⛔ no-go — see the graph");
            }
            if self.wizard_open && ui.button("✖ close wizard").clicked() {
                self.wizard_open = false;
            }
            if scope.is_some() && ui.button("✖ clear scope").clicked() {
                self.scoped_repo = None;
                self.view.clear_selection();
            }
            if ui.button("⊙ fit").clicked() {
                self.view.fit();
            }
        });
        if let Some(run) = &self.last_run {
            ui.colored_label(theme.text_dim, format!("· {run}"));
        }

        // ── The guided RELEASE WIZARD (opened by the Release button) ───────────
        // The gate-overlaid graph below shows state at each step (the #15 overlay).
        if self.wizard_open {
            ui.separator();
            self.draw_wizard(ui);
        }

        // ── The gate-overlaid dependency graph beside it ──────────────────────
        // Gather what we need before the borrow of `self` for the canvas split.
        let model_deco = self.gate.as_ref().map(|m| self.build_graph(m));
        let clicked_repo;
        match model_deco {
            Some((model, deco)) if !model.nodes.is_empty() => {
                // Legend.
                ui.horizontal_wrapped(|ui| {
                    ui.label("legend:");
                    ui.colored_label(RED, "⛔ held / ✗ edition");
                    ui.colored_label(AMBER, "▼ behind · 🟡 dirty · ✂ suggested cut");
                    ui.colored_label(GREEN, "✅ clean");
                    ui.label("· click a repo → scope the release + light its blast radius");
                });
                let resp = graph_render::draw_graph(
                    ui,
                    &model,
                    &deco,
                    &mut self.view,
                    theme.bg,
                    theme.text,
                    theme.selection(),
                    theme.text_dim,
                );
                clicked_repo = resp.clicked_node;
            }
            _ => {
                clicked_repo = None;
                ui.add_space(12.0);
                if !self.local {
                    ui.weak(
                        "the gate-overlaid graph is local-only — launch the viz over a workspace \
                         checkout so `release doctor` can scan it.",
                    );
                } else if let Some(err) = &self.gate_error {
                    ui.colored_label(RED, format!("gate/doctor failed: {err}"));
                } else {
                    ui.weak("(computing the gate model…)");
                }
            }
        }
        // A node click scopes the release to that repo (the blast radius is lit by
        // the renderer's downstream highlight via `view.selected`, set in-place).
        if let Some(repo) = clicked_repo {
            self.scoped_repo = Some(repo);
        }

        // ── Node → history ("checkout button history") ────────────────────────
        if let Some(repo) = self.scoped_repo.clone() {
            ui.separator();
            self.draw_history(ui, &repo, tl);
        }
    }

    /// 🧙 Render the guided RELEASE WIZARD — the stepper + the active step's gate +
    /// blockers + advance control. Orchestration UI only: it reads the SHARED gate
    /// model and drives the pure [`WizardModel`] state machine; the slow/external
    /// tiers (Rehearse/Promote) run on a background thread behind explicit buttons.
    fn draw_wizard(&mut self, ui: &mut egui::Ui) {
        let theme = self.theme;
        let gate = self.gate.as_ref();
        // Deferred intents (collected under the immutable `gate` borrow, acted on
        // after it ends so the &mut-self spawns don't conflict with the borrow).
        let mut want_rehearse = false;
        let mut want_promote = false;
        let mut want_apply_fixes = false;

        ui.horizontal_wrapped(|ui| {
            ui.heading("🧙 Release wizard");
            ui.label(
                RichText::new("each step is a gate that must be green to advance")
                    .color(theme.text_dim),
            );
            if ui.button("↺ restart").clicked() {
                self.wizard.reset();
            }
        });

        // ── The stepper: every tier badged by its gate status, current marked. ──
        ui.horizontal_wrapped(|ui| {
            for step in WizardStep::ORDER {
                let st = self.wizard.gate_status(step, gate);
                let (glyph, col) = match st {
                    GateStatus::Green => ("✅", GREEN),
                    GateStatus::Red => ("⛔", RED),
                    GateStatus::Pending => ("·", theme.text_dim),
                };
                let mut txt = RichText::new(format!("{glyph} {}", step.label())).color(col);
                if step == self.wizard.current {
                    txt = txt.strong().underline();
                }
                ui.label(txt);
                if step.next().is_some() {
                    ui.label(RichText::new("→").color(theme.text_dim));
                }
            }
        });
        ui.separator();

        // ── The active step's body ──────────────────────────────────────────
        let current = self.wizard.current;
        match current {
            WizardStep::Advise => {
                ui.label("Advise — `release doctor`: dirty trees, skew, ⛔ transitive-pins, cycle cuts, the edition gate. Problems light up on the graph below.");
                match gate {
                    Some(m) if m.gate_ok() => {
                        ui.colored_label(GREEN, "✅ advisory clean — nothing blocks a release");
                    }
                    Some(_) => {
                        ui.colored_label(
                            RED,
                            "⛔ blocking problems — see Fix (and the graph overlay)",
                        );
                    }
                    None => {
                        ui.colored_label(
                            theme.text_dim,
                            "· no gate model (remote / no checkout to scan)",
                        );
                    }
                }
            }
            WizardStep::Fix => {
                ui.label("Fix — apply the suggested moves, then re-run Advise until clear:");
                let moves = WizardModel::fix_moves(gate);
                if moves.is_empty() {
                    ui.colored_label(GREEN, "✅ nothing to fix");
                } else {
                    for m in &moves {
                        ui.label(format!("  💡 {m}"));
                    }
                    // The APPLY action (task #31): turn the skew advice into real
                    // version-bump edits via `apply_skew_bumps`, then re-run Advise
                    // so the gates re-evaluate. Local mode only (needs a checkout).
                    if self.local && !self.gate_repos.is_empty() {
                        if ui
                            .button("🔧 apply fixes (bump skew → target, re-advise)")
                            .on_hover_text(
                                "Applies the coordinated version bumps for every skewed crate, \
                                 then recomputes the gate (release doctor) against the edits.",
                            )
                            .clicked()
                        {
                            want_apply_fixes = true;
                        }
                    } else {
                        ui.colored_label(
                            theme.text_dim,
                            "· apply needs a local checkout (remote: bump via the CLI)",
                        );
                    }
                }
                // The record of the last apply (what got bumped / any error).
                let fp = self.wizard.fix_progress();
                for line in &fp.lines {
                    ui.monospace(format!("  {line}"));
                }
                if let Some(e) = &fp.error {
                    ui.colored_label(RED, format!("  ⚠ {e}"));
                } else if fp.done && fp.ok && !fp.lines.is_empty() {
                    ui.colored_label(GREEN, "  ✅ fixes applied — Advise re-evaluated");
                }
            }
            WizardStep::Rehearse => {
                ui.label("Rehearse — `release stage --execute` into the embedded-holger /sparring registry (publish order + per-dependent build-verify).");
                let p = self.wizard.rehearse_progress();
                if p.running {
                    ui.colored_label(AMBER, "⏳ rehearsing into /sparring…");
                    ui.ctx()
                        .request_repaint_after(std::time::Duration::from_millis(500));
                } else if ui.button("▶ run rehearsal (stage --execute)").clicked() {
                    want_rehearse = true;
                }
                for line in &p.lines {
                    ui.monospace(format!("  {line}"));
                }
                if let Some(e) = &p.error {
                    ui.colored_label(RED, format!("  ⚠ {e}"));
                }
            }
            WizardStep::Promote => {
                ui.colored_label(
                    RED,
                    "Promote — `release promote` to crates.io in topo order. IRREVERSIBLE.",
                );
                let phrase = self.wizard.promote_confirm_phrase.clone();
                ui.horizontal_wrapped(|ui| {
                    ui.label(format!("type `{phrase}` to confirm:"));
                    ui.text_edit_singleline(&mut self.wizard.promote_confirm_input);
                });
                let p = self.wizard.promote_progress();
                if p.running {
                    ui.colored_label(AMBER, "⏳ promoting to crates.io…");
                    ui.ctx()
                        .request_repaint_after(std::time::Duration::from_millis(500));
                } else {
                    let can = self.wizard.can_run_promote(gate);
                    let btn =
                        egui::Button::new(RichText::new("🚀 PROMOTE to crates.io").color(if can {
                            RED
                        } else {
                            theme.text_dim
                        }));
                    if ui.add_enabled(can, btn).clicked() {
                        want_promote = true;
                    }
                    if !can {
                        ui.colored_label(
                            theme.text_dim,
                            "· rehearsal must be green + the confirm typed",
                        );
                    }
                }
                for line in &p.lines {
                    ui.monospace(format!("  {line}"));
                }
                if let Some(e) = &p.error {
                    ui.colored_label(RED, format!("  ⚠ {e}"));
                }
            }
        }

        // ── The gate + advance / override / back controls ─────────────────────
        ui.separator();
        let blockers = self.wizard.blockers(current, gate);
        let can_advance = self.wizard.can_advance(current, gate);
        ui.horizontal_wrapped(|ui| {
            if self.wizard.current.index() > 0 && ui.button("← back").clicked() {
                self.wizard.back();
            }
            if current.next().is_some() {
                let next_label = current.next().map(|n| n.label()).unwrap_or("");
                let btn = egui::Button::new(RichText::new(format!("advance → {next_label}")));
                if ui.add_enabled(can_advance, btn).clicked() {
                    self.wizard.advance(gate);
                }
                if !can_advance {
                    // The explicit override — advance past a red/pending gate anyway.
                    let mut armed = self.wizard.override_armed(current);
                    if ui
                        .checkbox(&mut armed, "⚠ override (advance anyway)")
                        .changed()
                    {
                        self.wizard.set_override(current, armed);
                    }
                }
            }
        });
        if !blockers.is_empty() {
            for b in &blockers {
                ui.colored_label(RED, format!("  ⛔ {b}"));
            }
        }

        // The `gate` immutable borrow ends here — now act on the deferred intents
        // (the slow/external tiers run on a background thread, not this paint thread).
        if want_apply_fixes {
            self.apply_fixes();
        }
        if want_rehearse {
            self.spawn_rehearsal();
        }
        if want_promote {
            self.spawn_promote();
        }
    }

    /// Fix step: report the doctor's skew advice as the exact version-bump EDITS it
    /// implies — and hand the ACT to the forge.
    ///
    /// This used to call `dwarves::release_doctor::cargo::apply_skew_bumps` in-process
    /// and rewrite every affected `Cargo.toml` from nornir's paint loop. Rewriting a
    /// manifest is a constructive operation, and under PRE-PROMPT §L5a those belong to
    /// the forge; the warehouse owner hosts the PANE, not the edit. Doing it here also
    /// meant nornir had to path-dep `dwarves`, which made `nornir -> dwarves -> nornir`
    /// a cargo cycle and is why the forge could never compile nornir in.
    ///
    /// So the computation stays (it is a pure read over the already-loaded
    /// [`CrateSkew`](nornir_release_core::model::CrateSkew) — same numbers, same repos,
    /// same targets) and the write leaves. The operator gets the concrete plan plus the
    /// one verb that performs it. Nothing is hidden and nothing is silently skipped:
    /// `state_json["fix_applied"].ok` is FALSE, because nothing was applied.
    fn apply_fixes(&mut self) {
        let Some(skew) = self.gate.as_ref().map(|m| m.doctor.skew.clone()) else {
            self.wizard.record_fix_applied(
                false,
                Vec::new(),
                Some("no gate model loaded (remote / no checkout)".into()),
            );
            return;
        };
        // The PLAN, read straight off the skew PODs: which crate, to what version, in
        // which repos. `bump_repos()` is the same hint set the engine iterates.
        let known: std::collections::BTreeSet<&str> =
            self.gate_repos.iter().map(|(n, _)| n.as_str()).collect();
        let mut lines: Vec<String> = Vec::new();
        for c in &skew {
            for repo in c.bump_repos() {
                if !known.contains(repo) {
                    continue;
                }
                lines.push(format!(
                    "would bump {} → {} in {repo}",
                    c.crate_name, c.target
                ));
            }
        }
        if lines.is_empty() {
            self.wizard.record_fix_applied(
                false,
                vec!["no skew bumps pending (nothing behind on disk)".into()],
                None,
            );
            return;
        }
        let ws = self
            .workspace_root()
            .map(|p| p.display().to_string())
            .unwrap_or_else(|| "<workspace>".into());
        lines.push(String::new());
        lines.push(format!("run:  dwarves release doctor --fix {ws}"));
        self.wizard.record_fix_applied(
            false,
            lines,
            Some(
                "the manifest edits are the forge's to make (L5a) — nornir shows the plan, \
                 `dwarves release doctor --fix` writes it"
                    .into(),
            ),
        );
    }

    /// The workspace root the stage rehearsal writes its `.nornir/stage` data dir
    /// under — the parent of the repo checkouts (`root/<repo>` ← `gate_repos`).
    fn workspace_root(&self) -> Option<PathBuf> {
        self.gate_repos
            .first()
            .and_then(|(_, p)| p.parent().map(|p| p.to_path_buf()))
    }

    /// Spawn the Rehearse tier on a BACKGROUND THREAD (never the paint thread):
    /// build the stage plan from the gathered repo graphs + `release::stage::execute`
    /// into a fresh embedded-holger /sparring registry, streaming the outcome into
    /// the wizard's job handle. No-op when there's no local checkout to stage.
    fn spawn_rehearsal(&mut self) {
        let handle = self.wizard.rehearse_handle();
        {
            let mut g = handle.lock().unwrap();
            *g = super::release_wizard::JobProgress {
                running: true,
                ..Default::default()
            };
        }
        let repos = self.gate_repos.clone();
        let Some(ws_root) = self.workspace_root() else {
            let mut g = handle.lock().unwrap();
            g.running = false;
            g.done = true;
            g.ok = false;
            g.error = Some("no local checkout to rehearse (remote / no workspace root)".into());
            return;
        };
        std::thread::spawn(move || {
            let result = (|| -> anyhow::Result<crate::release::stage::StageOutcome> {
                let graphs = repos
                    .iter()
                    .map(|(name, path)| crate::release::doctor::gather_repo_graph(name, path))
                    .collect::<anyhow::Result<Vec<_>>>()?;
                let http = "127.0.0.1:18464";
                let grpc = "127.0.0.1:18465";
                let registry = format!("sparse+http://{http}/sparring/index/");
                let plan = crate::release::stage::plan_stage(&graphs, &registry, "release/wizard");
                if !plan.is_executable() {
                    anyhow::bail!(
                        "plan not executable (dependency cycle: {})",
                        plan.cycle.join(", ")
                    );
                }
                let data_dir = ws_root.join(".nornir/stage");
                let repo_paths: std::collections::BTreeMap<String, PathBuf> =
                    repos.iter().cloned().collect();
                let holger_bin = PathBuf::from("holger-server");
                // The viz wizard rehearses over gate_repos with no repo-config in scope,
                // so no private set is available here — pass empty (skip nothing), same
                // as before the #3 private-skip param was added to the CLI path.
                let private = std::collections::BTreeSet::new();
                crate::release::stage::execute(
                    &plan,
                    &repo_paths,
                    &private,
                    &holger_bin,
                    &data_dir,
                    grpc,
                    http,
                )
            })();
            let mut g = handle.lock().unwrap();
            g.running = false;
            g.done = true;
            match result {
                Ok(out) => {
                    g.ok = out.errors.is_empty();
                    // Shared renderer (`StageOutcome::report_lines`) — this used
                    // to be hand-rolled here and dropped `already_staged`, so a
                    // rehearsal re-run with no version bump showed an empty log.
                    g.lines.extend(out.report_lines());
                    if !g.ok {
                        g.error = Some(format!("{} step(s) failed", out.errors.len()));
                    }
                }
                Err(e) => {
                    g.ok = false;
                    g.error = Some(format!("{e:#}"));
                }
            }
        });
    }

    /// Promote tier: run the patch-fork PROMOTE GATE and report its verdict — and hand
    /// the crates.io publish itself to the forge.
    ///
    /// This used to `dwarves::release_doctor::publish::publish_all` from a background
    /// thread inside the viz: nornir uploading crates to crates.io in-process. Publishing
    /// is the most constructive operation there is, and under PRE-PROMPT §L5a it belongs
    /// to the forge; the warehouse owner hosts the PANE, not the upload. It was also one
    /// of the call sites forcing nornir's `dwarves` path-dep, which made
    /// `nornir -> dwarves -> nornir` a cargo cycle and blocked the fat forge entirely.
    ///
    /// What survives is the whole JUDGEMENT, unchanged and still on a background thread:
    /// gather the repo + its workspace siblings, classify `[patch.crates-io]` forks
    /// foreign-vs-own, and name every crate that transitively rides a foreign fork and
    /// therefore may not ship. That is a filesystem read
    /// ([`compute_promote_block`](nornir_release_core::model::compute_promote_block)) and
    /// it is exactly what an operator needs before promoting. Then it names the verb.
    ///
    /// `ok` is TRUE when the gate passes — the gate really did run and really did pass;
    /// the job body says plainly that nothing was published.
    fn spawn_promote(&mut self) {
        let handle = self.wizard.promote_handle();
        {
            let mut g = handle.lock().unwrap();
            *g = super::release_wizard::JobProgress {
                running: true,
                ..Default::default()
            };
        }
        // Promote the FIRST repo's workspace (the scope) — the topo order within it
        // is derived by the publish engine. Scoped repo takes precedence.
        let ws_root = self.workspace_root();
        let scope = self.scoped_repo.clone();
        let target = scope
            .as_ref()
            .and_then(|r| self.gate_repos.iter().find(|(n, _)| n == r))
            .or_else(|| self.gate_repos.first())
            .map(|(_, p)| p.clone());
        let Some(repo_root) = target else {
            let mut g = handle.lock().unwrap();
            g.running = false;
            g.done = true;
            g.ok = false;
            g.error = Some("no local checkout to promote".into());
            return;
        };
        std::thread::spawn(move || {
            // CRATE-precise PROMOTE GATE (task #32): gather the repo + its workspace
            // siblings, classify `[patch.crates-io]` forks foreign-vs-own, and hold every
            // crate that transitively needs a FOREIGN fork. Publishing strips the patch,
            // so a held crate would ship against a stock dep it cannot build with — the
            // arrow-58 `[patch] iceberg = { path = "../iceberg-arrow58" }` trap. Own-crate
            // dev overrides stay publishable. Filesystem-only; no network, no mutation.
            let mut repos: Vec<(String, std::path::PathBuf)> =
                vec![("repo".to_string(), repo_root.clone())];
            if let Some(ws) = &ws_root {
                if let Ok(entries) = std::fs::read_dir(ws) {
                    for e in entries.flatten() {
                        let p = e.path();
                        if p.is_dir() && p != repo_root && p.join("Cargo.toml").exists() {
                            let n = p
                                .file_name()
                                .and_then(|s| s.to_str())
                                .unwrap_or("")
                                .to_string();
                            repos.push((n, p));
                        }
                    }
                }
            }
            let block = crate::release::doctor::compute_promote_block(
                repos.iter().map(|(n, p)| (n.clone(), p.as_path())),
            );
            let direct_for_report: Vec<crate::release::doctor::PatchForkBlock> = block
                .forks
                .iter()
                .filter(|b| b.is_foreign_fork)
                .cloned()
                .collect();

            let mut g = handle.lock().unwrap();
            g.running = false;
            g.done = true;
            for b in &direct_for_report {
                g.lines.push(format!(
                    "⛔ promote-blocked (must be held from crates.io): {} rides {} → {} fork",
                    b.crate_name, b.patched_dep, b.source
                ));
            }
            for held in &block.blocked {
                g.lines.push(format!("   held transitively: {held}"));
            }
            if direct_for_report.is_empty() && block.blocked.is_empty() {
                g.lines
                    .push("promote gate: clean — no crate rides a foreign fork".into());
            }
            // The gate ran and rendered its verdict; the UPLOAD is the forge's.
            g.ok = direct_for_report.is_empty() && block.blocked.is_empty();
            g.lines.push(String::new());
            g.lines
                .push("nothing was published — the crates.io upload is the forge's (L5a)".into());
            g.lines.push(format!(
                "run:  dwarves workspace-release {} --execute",
                repo_root.display()
            ));
        });
    }

    /// The clicked node's git/release history — the per-repo lane from the app's
    /// loaded [`Timeline`] (sha · gate · published versions · when). REUSES the
    /// timeline the viz already has rather than re-querying release_lineage.
    fn draw_history(&self, ui: &mut egui::Ui, repo: &str, tl: Option<&Timeline>) {
        let theme = self.theme;
        ui.collapsing(
            RichText::new(format!("🕘 {repo} — release history")).strong().color(theme.accent),
            |ui| {
                let lane = tl.and_then(|t| t.lanes.iter().find(|l| l.repo == repo));
                match lane {
                    Some(lane) if !lane.nodes.is_empty() => {
                        ScrollArea::vertical().max_height(220.0).auto_shrink([false, false]).show(ui, |ui| {
                            // Newest first.
                            for n in lane.nodes.iter().rev() {
                                let (glyph, col) = match n.gate_status.as_str() {
                                    "pass" | "passed" | "ok" => ("✓", GREEN),
                                    "fail" | "failed" => ("✗", RED),
                                    _ => ("·", theme.text_dim),
                                };
                                let sha = &n.sha[..n.sha.len().min(12)];
                                let when = n.timestamp.format("%Y-%m-%d %H:%M").to_string();
                                let pubs = if n.published_versions.is_empty() {
                                    String::new()
                                } else {
                                    let v: Vec<String> = n
                                        .published_versions
                                        .iter()
                                        .map(|(c, ver)| format!("{c} {ver}"))
                                        .collect();
                                    format!("  📦 {}", v.join(", "))
                                };
                                ui.horizontal_wrapped(|ui| {
                                    ui.colored_label(col, glyph);
                                    ui.monospace(sha);
                                    ui.colored_label(theme.text_dim, when);
                                    if n.dirty {
                                        ui.colored_label(AMBER, "🟡 dirty");
                                    }
                                    if !pubs.is_empty() {
                                        ui.label(pubs);
                                    }
                                });
                            }
                        });
                    }
                    _ => {
                        ui.weak("no releases recorded for this repo yet — its history fills as `dwarves release` records to the warehouse.");
                    }
                }
            },
        );
    }

    /// The dashboard's slice of the app `state_json` (LAW #6) — present flag,
    /// button-enabled (the gate verdict), the scoped repo, the SHARED gate block,
    /// and the graph's node badges + the suggested cut edges. So the robot test
    /// asserts the gate overlay + button state WITHOUT pixels.
    pub fn state_json(&self) -> serde_json::Value {
        let gate = match &self.gate {
            Some(m) => m.gate_json(self.gate_error.as_deref()),
            None => super::gate::gate_json_absent(self.gate_error.as_deref()),
        };
        let gate_ok = self.gate.as_ref().map(|m| m.gate_ok()).unwrap_or(true);
        // The graph's node badges + the cut edges, as the overlay the user sees.
        let (node_badges, cut_edges) = match &self.gate {
            Some(m) => {
                let (_model, deco) = self.build_graph(m);
                let badges: Vec<serde_json::Value> = self
                    .repo_nodes(m)
                    .iter()
                    .filter_map(|r| {
                        deco.nodes.get(r).map(|d| {
                            serde_json::json!({
                                "repo": r,
                                "badge": d.badge,
                                "ring": d.ring.map(|c| format!("#{:02X}{:02X}{:02X}", c.r(), c.g(), c.b())),
                            })
                        })
                    })
                    .collect();
                let cuts: Vec<serde_json::Value> = deco
                    .edges
                    .iter()
                    .map(|e| serde_json::json!({ "from": e.from, "to": e.to, "label": e.label }))
                    .collect();
                (badges, cuts)
            }
            None => (Vec::new(), Vec::new()),
        };
        // The blast radius lit by the current selection (downstream subtree).
        let blast: Vec<String> = match (&self.gate, &self.view.selected) {
            (Some(m), Some(sel)) => {
                let (model, _deco) = self.build_graph(m);
                graph_render::downstream_of(&model, sel)
                    .into_iter()
                    .collect()
            }
            _ => Vec::new(),
        };
        serde_json::json!({
            "present": self.gate.is_some(),
            "local": self.local,
            // The Release button: enabled ⇔ the gate is OK (or remote/no-checkout,
            // where the heavy run lives in the CLI). RED + disabled on a no-go.
            "button_enabled": gate_ok || !self.local,
            "gate_ok": gate_ok,
            "scoped_repo": self.scoped_repo,
            "selected": self.view.selected,
            // The blast radius the selection lit (downstream repos to re-validate).
            "blast_radius": blast,
            // The SHARED gate block (the same shape the 🚀 Release pane ships).
            "gate": gate,
            // The graph overlay the user sees, as DATA: per-repo badges + rings.
            "node_badges": node_badges,
            // Each dependency cycle's suggested cut edge + rationale (dashed line).
            "cut_edges": cut_edges,
            "last_run": self.last_run,
            // 🧙 The guided RELEASE WIZARD (task #16): whether it's open + its step
            // state machine (current step, per-step gate status / can_advance /
            // blockers / override-armed, the promote typed-confirm, the suggested
            // fix moves, and the async tier job progress). So the robot test reads
            // the step + gating back as data — no pixels.
            "release_wizard": {
                "open": self.wizard_open,
                "machine": self.wizard.state_json(self.gate.as_ref()),
            },
            "palette": self.theme.name,
        })
    }
}

#[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};

    fn gate() -> (DoctorReport, EditionReport) {
        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: "znippy".into(),
                issue: "edition 2021 — want 2024".into(),
            }],
            lints: vec![],
            lint_pass_ran: false,
            msrv_advisories: vec![],
            msrv_verify_errors: vec![],
            docs_errors: vec![],
        };
        (doctor, edition)
    }

    #[test]
    fn dashboard_state_reflects_gate_overlay_and_button() {
        let mut d = NornirDashboard::default();
        let (doctor, edition) = gate();
        d.inject_gate_for_test(doctor, edition);
        let js = d.state_json();

        assert_eq!(js["present"], true);
        // edition NOT clean + nornir held back ⇒ no-go ⇒ button disabled (local).
        assert_eq!(js["gate_ok"], false);
        assert_eq!(
            js["button_enabled"], false,
            "a failing gate disables the Release button"
        );
        assert_eq!(js["gate"]["skew_count"], 1);
        assert_eq!(js["gate"]["held_back"][0], "nornir");

        // The graph overlay: nornir badged ⛔ (held); znippy badged ✗ (edition,
        // scoped to topo-first = znippy).
        let badges = js["node_badges"].as_array().unwrap();
        let nornir = badges.iter().find(|b| b["repo"] == "nornir").unwrap();
        assert!(
            nornir["badge"].as_str().unwrap().contains('⛔'),
            "nornir held badge"
        );
        let znippy = badges.iter().find(|b| b["repo"] == "znippy").unwrap();
        assert!(
            znippy["badge"].as_str().unwrap().contains('✗'),
            "edition fail badge"
        );

        // The cycle's suggested cut is a dashed overlay edge with its rationale.
        let cuts = js["cut_edges"].as_array().unwrap();
        assert_eq!(cuts.len(), 1);
        assert_eq!(cuts[0]["from"], "nornir");
        assert_eq!(cuts[0]["to"], "znippy");
        assert!(cuts[0]["label"].as_str().unwrap().contains("cut"));
    }

    #[test]
    fn clicking_a_node_scopes_release_and_lights_blast_radius() {
        let mut d = NornirDashboard::default();
        let (doctor, edition) = gate();
        d.inject_gate_for_test(doctor, edition);
        // Click nornir → scope to nornir, light its downstream (nornir → znippy).
        assert!(d.select_repo_for_test("nornir"));
        let js = d.state_json();
        assert_eq!(js["scoped_repo"], "nornir");
        assert_eq!(js["selected"], "nornir");
        let blast: Vec<String> = js["blast_radius"]
            .as_array()
            .unwrap()
            .iter()
            .map(|v| v.as_str().unwrap().to_string())
            .collect();
        assert!(blast.contains(&"nornir".to_string()));
        assert!(
            blast.contains(&"znippy".to_string()),
            "downstream blast lit: {blast:?}"
        );
    }

    #[test]
    fn fix_step_reports_the_bump_plan_and_hands_the_edit_to_the_forge() {
        // A real two-repo checkout with an arrow skew: `ahead` at 58.3.0, `behind`
        // at 57. The doctor surfaces the skew (skew_count = 1), and the Fix step
        // reports the exact edit that would close it — WITHOUT making it.
        //
        // This test used to assert the opposite: that Fix wrote `arrow = "58.3.0"`
        // into `behind/Cargo.toml` via `dwarves::release_doctor::cargo::apply_skew_bumps`,
        // from nornir's paint loop. Rewriting a manifest is a constructive operation
        // and belongs to the forge (PRE-PROMPT §L5a) — the warehouse owner hosts the
        // pane, not the edit. It also meant nornir path-dep'd `dwarves`, which made
        // `nornir -> dwarves -> nornir` a cargo cycle and is why the fat forge could
        // never compile nornir in.
        //
        // So the contract inverted, and the test with it. RED if the plan stops
        // naming the concrete crate/target/repo (the operator would be told "run
        // dwarves" with no idea what it would do), if the hand-off verb disappears
        // (a dead end instead of a route), or — most importantly — if the write
        // comes back (`ok` must stay FALSE, and the file must be byte-identical).
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        for (name, ver) in [("ahead", "58.3.0"), ("behind", "57")] {
            let dir = root.join(name);
            std::fs::create_dir_all(&dir).unwrap();
            std::fs::write(
                dir.join("Cargo.toml"),
                format!(
                    "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\narrow = \"{ver}\"\n"
                ),
            )
            .unwrap();
        }

        let mut d = NornirDashboard::default();
        d.set_gate_inputs(
            root,
            &["ahead".into(), "behind".into()],
            DepPolicy::default(),
        );
        d.ensure_gate();
        // The doctor surfaces the arrow skew across the two repos.
        let js = d.state_json();
        assert_eq!(
            js["gate"]["skew_count"], 1,
            "arrow skew detected: {}",
            js["gate"]
        );

        let before = std::fs::read_to_string(root.join("behind/Cargo.toml")).unwrap();

        // Drive the Fix step.
        let lines = d.wizard_apply_fixes_for_test();
        // (1) the PLAN is concrete: which crate, to what version, in which repo.
        assert!(
            lines
                .iter()
                .any(|l| l.contains("arrow") && l.contains("58.3.0") && l.contains("behind")),
            "the plan must name arrow → 58.3.0 in behind: {lines:?}"
        );
        // (2) the HAND-OFF names the verb that performs it — a route, not a dead end.
        assert!(
            lines
                .iter()
                .any(|l| l.contains("dwarves release doctor --fix")),
            "the plan must route the operator to the forge: {lines:?}"
        );
        // (3) NOTHING WAS WRITTEN. This is the assertion that keeps the constructive
        // operation on the forge's side of the line; if it ever goes green-by-writing
        // again, the crate edge is on its way back.
        let after = std::fs::read_to_string(root.join("behind/Cargo.toml")).unwrap();
        assert_eq!(
            after, before,
            "Fix must not edit a manifest — that is the forge's"
        );
        let js = d.state_json();
        assert_eq!(
            js["gate"]["skew_count"], 1,
            "the skew is still open — nothing was fixed"
        );
        let machine = &js["release_wizard"]["machine"];
        assert_eq!(machine["fix_applied"]["done"], true, "the step ran");
        assert_eq!(
            machine["fix_applied"]["ok"], false,
            "…and honestly reports that it applied nothing (RAGNARÖK: a no-op is never green)",
        );
    }

    #[test]
    fn clean_workspace_is_go_and_button_enabled() {
        let mut d = NornirDashboard::default();
        let doctor = DoctorReport {
            dirty: vec![RepoDirty {
                repo: "a".into(),
                dirty: false,
                error: None,
            }],
            skew: vec![],
            topo: TopoReport {
                order: vec!["a".into()],
                cycle: vec![],
            },
            blast: Default::default(),
            cycle_advice: vec![],
            repo_edges: vec![],
            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![],
        };
        d.inject_gate_for_test(doctor, edition);
        let js = d.state_json();
        assert_eq!(js["gate_ok"], true);
        assert_eq!(js["button_enabled"], true);
    }
}