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

nordisk/dwarves

dwarves-gui/src/release.rs

//! **The Release tab** — the release of the **SELECTED workspace**, in two halves:
//!
//! 1. **The three-graph CONVERGENCE** (task #6 + #28) — the SAME constellation DAG in
//!    THREE states (LOCAL manifest · SPARRING Gate-B rehearsal · CRATES_IO published)
//!    that a release drives to CONVERGE, on the REUSED `facett-releaseview` 3-plane
//!    viewer (via `dwarves_embed::release_panel_at` — no viewer twin, L5).
//! 2. **The SHOOTING-RAMP band** — the state the convergence graph alone cannot show:
//!    which repos are sitting on a ramp branch right now, what the saved bump plan is,
//!    what **Gate A** (static) and **Gate B** (the `/sparring` rehearsal) said, and — the
//!    loud one — how many crates a fail-stopped `--execute` already got LIVE and
//!    immutable on crates.io. All of it read through the shared, always-compiled,
//!    strictly READ-ONLY [`dwarves::release_state`] leaf.
//!
//! ## This pane cannot release anything
//! `release_state` is a `.git/HEAD` parse plus three file reads — no process is spawned,
//! no repo is written, and the lean `dwarves` dep this crate carries links none of the
//! `release` feature's publish machinery. There is deliberately **no button here that
//! can publish**: a UI that can accidentally shoot at crates.io is a defect.
//!
//! ## Where the convergence comes from (the ladder)
//! A `workspace-release` run writes its convergence output to the shared
//! `dwarves-workspace-release-convergence.json`; a per-workspace run writes the
//! `…-convergence-<ws>.json` twin. The pane reads **the workspace's own file first, the
//! shared run file second, and only then the nordisk fixture** — so a real run is never
//! invisible just because it wrote the shared path. `source` in `state_json` tells the
//! three apart (`workspace-run` · `release-run` · `fixture`), which is what the gate
//! asserts on.

use std::path::PathBuf;

use dwarves::release_state::ReleaseRunState;
use facett_core::{Facet, Panel};

/// Which section of the ramp band is expanded (the pane's one real interaction).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Band {
    /// The gate verdicts (Gate A static · Gate B `/sparring` rehearsal).
    Gates,
    /// Which repos are on a ramp branch, and which kind.
    Ramp,
    /// The saved bump plan: deps-first order + crate→ship-version graph.
    Plan,
}

impl Band {
    /// The stable lower-case tag used in `state_json` / the JSON seam.
    pub fn key(self) -> &'static str {
        match self {
            Band::Gates => "gates",
            Band::Ramp => "ramp",
            Band::Plan => "plan",
        }
    }

    fn parse(s: &str) -> Option<Band> {
        Some(match s {
            "gates" => Band::Gates,
            "ramp" => Band::Ramp,
            "plan" => Band::Plan,
            _ => return None,
        })
    }
}

/// Where the rendered convergence came from.
///
/// ## The nordisk fixture is gone (audit 2026-09-10)
/// There used to be a third, drawing kind here — `Fixture` — and with no release run
/// anywhere it is what the tab rendered: `dwarves_embed::release_fixture()`, a
/// four-crate chain (skade green · dwarves and holger RED "needs-release" · nornir
/// AMBER "bump-drift") whose version numbers were invented. A pane whose whole job is
/// to answer *which crates need publishing* was answering it before any release had
/// run. `release_fixture` is `#[cfg(test)]` now, and this variant no longer draws
/// anything: it is the NAMED absence, and it says which file it wanted.
// Each variant names a kind of run (or its absence); the shared `Run` is the meaning.
#[allow(clippy::enum_variant_names)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ConvSource {
    /// The SELECTED workspace's own convergence file (a run for this workspace).
    WorkspaceRun,
    /// The shared file a `workspace-release` run writes (a real run, unnamed workspace).
    ReleaseRun,
    /// **No run anywhere.** Not a fallback graph — an empty viewer under a line naming
    /// the convergence file that was not written. Zero nodes, and no verdict.
    NoRun,
}

impl ConvSource {
    fn key(self) -> &'static str {
        match self {
            ConvSource::WorkspaceRun => "workspace-run",
            ConvSource::ReleaseRun => "release-run",
            ConvSource::NoRun => "empty",
        }
    }
}

/// **Lower a [`ReleaseConvergence`] onto the shared DAG shape** the graph engine paints.
///
/// The edge direction is the model's own (`from` depends on `to`), so the DAG reads
/// left-to-right from dependents to dependencies — the order a release publishes in,
/// reversed. Node colour is the crate's [`ConvergeStatus`]; the badge is the local
/// version, and a crate that is not converged carries an emphasis ring, because "which
/// crates are still off" is the whole question this graph is for.
///
/// [`ReleaseConvergence`]: facett_releaseview::model::ReleaseConvergence
/// [`ConvergeStatus`]: facett_releaseview::model::ConvergeStatus
pub fn convergence_to_dag(
    conv: &facett_releaseview::model::ReleaseConvergence,
) -> crate::dag_graph::DagSpec {
    use crate::dag_graph::{DagNode, DagSpec};
    use facett_graphview::Color;
    use facett_releaseview::model::ConvergeStatus;

    let fill = |s: ConvergeStatus| match s {
        ConvergeStatus::Converged => Color::rgb(0x3c, 0x7a, 0x4e),
        ConvergeStatus::NeedsRelease => Color::rgb(0x8f, 0x33, 0x33),
        ConvergeStatus::LockDesync => Color::rgb(0x8f, 0x4a, 0x2a),
        ConvergeStatus::BumpDrift => Color::rgb(0x8a, 0x6d, 0x1f),
        ConvergeStatus::Publishing => Color::rgb(0x2f, 0x5c, 0x8a),
    };
    let nodes: Vec<DagNode> = conv
        .nodes
        .iter()
        .map(|n| DagNode {
            id: n.krate.clone(),
            // The three versions ARE the state — putting only the name in the box would
            // make a converged and a diverged node look the same.
            label: format!(
                "{}  L{} · S{} · C{}",
                n.krate, n.local_ver, n.sparring_ver, n.cratesio_ver
            ),
            fill: fill(n.status),
            ring: (!n.status.is_converged()).then(|| Color::rgb(0xe0, 0xa8, 0x00)),
            badge: (!n.status.is_converged()).then(|| n.status.label().to_string()),
        })
        .collect();
    let edges: Vec<(String, String)> = conv
        .edges
        .iter()
        .filter_map(|e| {
            let from = conv.nodes.get(e.from)?.krate.clone();
            let to = conv.nodes.get(e.to)?.krate.clone();
            Some((from, to))
        })
        .collect();
    DagSpec {
        nodes,
        edges,
        consumes: Vec::new(),
    }
}

/// The constellation root the ramp scan walks — every sibling checkout of this repo.
/// Ladder: `$DWARVES_CONSTELLATION_ROOT` → the parent of the repo root the Pipeline tab
/// resolved (`…/git/dwarves` → `…/git`, where the polyrepo constellation lives).
pub fn constellation_root() -> PathBuf {
    if let Some(r) = std::env::var("DWARVES_CONSTELLATION_ROOT")
        .ok()
        .filter(|r| !r.trim().is_empty())
    {
        return PathBuf::from(r);
    }
    let repo = crate::pipeline::runa_repo_root();
    repo.parent().map(|p| p.to_path_buf()).unwrap_or(repo)
}

/// The Release tab: the reused release-convergence pane bound to the selected workspace,
/// under a shooting-ramp / gate band read live off disk. A [`Facet`] the tabbed host
/// mounts like any other pane.
pub struct ReleaseTab {
    /// The reused `facett-releaseview` pane for the current workspace (empty when no
    /// release run has written a convergence file — never a fixture).
    inner: Box<dyn Panel>,
    /// The active workspace the pane is bound to (`""` = none selected).
    workspace: String,
    /// Where `inner`'s convergence came from.
    conv_source: ConvSource,
    /// The convergence file this binding resolved to — the one the pane loaded, or,
    /// under [`ConvSource::NoRun`], the one it wanted and did not find. Named on
    /// screen and in `state_json` so an absence points at something an operator can
    /// go look at, instead of being drawn over.
    conv_path: PathBuf,
    /// The read-only release-run state (ramps · plan · gates · what is already live).
    run: ReleaseRunState,
    /// The constellation root the ramp scan reads.
    root: PathBuf,
    /// Which band section is expanded.
    band: Band,
    /// The SAME convergence `inner` is showing, as a graph on the shared
    /// `facett-graphview` engine. Two renders of ONE model — the graph is built from
    /// [`dwarves_embed::load_convergence_at`] on the same ladder the pane loads, so the
    /// two halves cannot disagree about which release they are drawing.
    graph: crate::dag_graph::DagPane,
    /// Which render of the convergence is on screen.
    conv_view: ConvView,
    /// The GENERATION watch over the constellation's checkouts — see
    /// `crate::generation::git_heads_generation`. The ramp scan is re-run when a HEAD
    /// moves, never when a button is pressed.
    watch: crate::generation::Watch,
}

/// Seconds between checkout-HEAD probes for the ramp scan.
const RAMP_PROBE_S: f64 = 2.0;

/// Which render of the release convergence the tab is showing.
///
/// Two renders, one model. They answer different questions and neither replaces the
/// other: the 3-plane view is about *how far from converged each crate is*; the graph
/// is about *what depends on what*, which the plane render cannot show because it
/// spends its geometry on the three version states instead of on the DAG.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConvView {
    /// The reused `facett-releaseview` pane (3-plane / tri-badge).
    Planes,
    /// The dependency DAG on the shared `facett-graphview` engine.
    Graph,
}

impl ConvView {
    fn key(self) -> &'static str {
        match self {
            ConvView::Planes => "planes",
            ConvView::Graph => "graph",
        }
    }
    fn parse(s: &str) -> Option<Self> {
        Some(match s {
            "planes" | "releaseview" => ConvView::Planes,
            "graph" | "graphview" => ConvView::Graph,
            _ => return None,
        })
    }
}

impl ReleaseTab {
    /// Bind to `workspace` (may be `""` = none), load its convergence pane and scan the
    /// constellation's release state now.
    pub fn new(workspace: impl Into<String>) -> Self {
        Self::rooted(workspace, constellation_root())
    }

    /// Bind with an explicit constellation root (tests / a host that already resolved it).
    pub fn rooted(workspace: impl Into<String>, root: impl Into<PathBuf>) -> Self {
        let root = root.into();
        let mut me = Self {
            inner: dwarves_embed::release_panel(None),
            workspace: String::new(),
            conv_source: ConvSource::NoRun,
            conv_path: dwarves_embed::default_convergence_path(),
            run: ReleaseRunState::default(),
            root,
            band: Band::Gates,
            graph: crate::dag_graph::DagPane::new("Release DAG"),
            conv_view: ConvView::Planes,
            watch: crate::generation::Watch::new(RAMP_PROBE_S),
        };
        me.bind(&workspace.into());
        me
    }

    /// The tab with nothing selected — the honest EMPTY tab (tests / the shell before
    /// a warehouse is picked). It was called `fixture()` and it drew one; it draws the
    /// named absence over the convergence path now.
    #[allow(dead_code)]
    pub fn unbound() -> Self {
        Self::new("")
    }

    /// **Re-bind to `workspace`** — resolve its convergence file, reload the reused pane
    /// from it, and re-read the release-run state. Never panics.
    pub fn bind(&mut self, workspace: &str) {
        self.workspace = workspace.to_string();
        let named = workspace.trim();
        // The ladder: this workspace's own run file → the shared run file → NOTHING.
        // The third rung used to be the nordisk fixture; it is now the named absence,
        // and the path below is what gets named.
        let ws_path: PathBuf = dwarves_embed::workspace_convergence_path(workspace);
        let shared: PathBuf = dwarves_embed::default_convergence_path();
        let (path, source) = if !named.is_empty() && ws_path.is_file() {
            (ws_path, ConvSource::WorkspaceRun)
        } else if !named.is_empty() && shared.is_file() {
            // A real `workspace-release` run wrote the shared path — reporting "no run"
            // instead would hide an actual release. (An unnamed selection keeps the old
            // behaviour: the shared path IS its path, resolved above.)
            (shared, ConvSource::ReleaseRun)
        } else {
            (ws_path, ConvSource::NoRun)
        };
        self.conv_source = source;
        self.conv_path = path.clone();
        // `release_panel_at` loads the file, or — when it is absent — hands back the
        // pane carrying `facett_core::empty::EmptyState` over that path (never a
        // fixture). The ONE reused construction path; no viewer re-implemented here.
        self.inner = dwarves_embed::release_panel_at(&path);
        // The graph is the SAME model on the SAME ladder — one load path, two renders.
        let conv = dwarves_embed::load_convergence_at(&path);
        self.graph.set_spec(&convergence_to_dag(&conv));
        self.rescan();
    }

    /// Re-read the release-run state: the gate verdicts a run reported, layered over a
    /// LIVE scan of the constellation's ramp branches / changelist / checkpoint.
    pub fn rescan(&mut self) {
        self.run = ReleaseRunState::load_or_scan(&self.workspace, &self.root);
    }

    /// The workspace the release pane is bound to (`""` = none → fixture).
    pub fn workspace(&self) -> &str {
        &self.workspace
    }

    /// The read-only release-run state the band renders.
    pub fn run_state(&self) -> &ReleaseRunState {
        &self.run
    }

    /// The one-line status the band header shows for the current run.
    fn headline(&self) -> String {
        let live = self.run.live_crate_count();
        if live > 0 {
            // PLAIN NUMBERS (a nordisk law) — and the loudest fact wins the header.
            return format!("⛔ {live} crate(s) LIVE on crates.io from a fail-stopped run");
        }
        if self.run.is_mid_ramp() {
            let names: Vec<&str> = self.run.on_ramp().iter().map(|r| r.repo.as_str()).collect();
            return format!("● mid-ramp: {} repo(s) — {}", names.len(), names.join(", "));
        }
        "○ no ramp open — every repo is on its base branch".to_string()
    }
}

impl Facet for ReleaseTab {
    fn title(&self) -> &str {
        "Release"
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        // GENERATION, not a press: re-read the ramp branches + gate report when a
        // checkout's HEAD or the persisted release-state file has actually moved. An
        // unchanged constellation costs one `read_dir` + one `stat` per checkout every
        // `RAMP_PROBE_S` and no scan at all.
        let now_s = ui.input(|i| i.time);
        let root = self.root.clone();
        if self
            .watch
            .moved(now_s, || crate::generation::git_heads_generation(&root))
        {
            self.rescan();
        }

        // Both halves, in order — the shape this pane keeps when mounted ALONE (the unit
        // tests below box it bare). In the shell it is a `SplitPanel`: the three-graph
        // convergence viewer and the shooting-ramp band become two scene panes with a
        // draggable divider between them.
        self.ramp_ui(ui);
        ui.separator();
        self.convergence_ui(ui);
    }

    fn state_json(&self) -> serde_json::Value {
        self.folded_state_json()
    }

    fn update_json(&mut self, msg_json: &str) {
        self.apply_msg(msg_json);
    }
}

impl ReleaseTab {
    /// **The shooting-RAMP band — the inspector half.** Which workspace's release this
    /// is, where the graph came from, the headline, and the Gates / Ramp / Bump-plan
    /// detail behind the band selector.
    ///
    /// This is why the pane decomposes: the band's detail GROWS — a constellation with
    /// many members lists a ramp line per repo, a bump plan a line per crate — and it sat
    /// ABOVE the convergence viewer in one vertical flow, so every line it grew came
    /// straight out of the graph's height, with no divider to take it back.
    /// Returns the pane's own observable state.
    fn ramp_ui(&mut self, ui: &mut egui::Ui) -> serde_json::Value {
        // An honest header: which workspace's release this is, and WHICH run produced
        // the graph below (PLAIN words — a nordisk law). With no run there is no graph,
        // and the header is where that is said, by name: the convergence file that was
        // never written. It used to read "○ fixture (no release run yet)" above four
        // invented crates — a caption cannot un-draw a red "needs-release" badge.
        ui.horizontal(|ui| {
            if self.workspace.is_empty() {
                ui.label("Release: no workspace selected");
            } else {
                ui.label(format!("Release: {}", self.workspace));
            }
            ui.separator();
            match self.conv_source {
                ConvSource::WorkspaceRun => {
                    ui.label("● workspace release run");
                }
                ConvSource::ReleaseRun => {
                    ui.label("● release run (shared output)");
                }
                ConvSource::NoRun => {
                    ui.label(format!(
                        "○ no release run — nothing to converge. Wanted {}, which no run \
                         has written. Run one: `dwarves release --features facett-adapter`",
                        self.conv_path.display()
                    ));
                }
            }
        });

        // ── the SHOOTING-RAMP band ────────────────────────────────────────────────
        ui.separator();
        ui.label(self.headline());
        let mut want: Option<Band> = None;
        // Kept so the band switch below reads unchanged; nothing in the UI sets it any
        // more (the generation drive above owns re-reading).
        let rescan = false;
        ui.horizontal(|ui| {
            for (b, label) in [
                (Band::Gates, "Gates"),
                (Band::Ramp, "Ramp"),
                (Band::Plan, "Bump plan"),
            ] {
                if ui.selectable_label(self.band == b, label).clicked() {
                    want = Some(b);
                }
            }
            // NO ⟳ Rescan button (owner 2026-08-25: SCAN is not a user action). The
            // ramp state is re-read when the CHECKOUTS move — `<root>/<repo>/.git/HEAD`
            // is the file git rewrites on `checkout`, which is exactly the input to this
            // scan. See the generation drive at the top of `ui`.
        });
        match self.band {
            Band::Gates => {
                ui.label(format!(
                    "Gate A (static): {} — {}",
                    self.run.gates.gate_a.label(),
                    self.run.gates.gate_a_detail
                ));
                ui.label(format!(
                    "Gate B (/sparring rehearsal): {} — {}",
                    self.run.gates.gate_b.label(),
                    self.run.gates.gate_b_detail
                ));
                if let Some(p) = &self.run.partial {
                    ui.label(format!(
                        "already live ({}): {}",
                        p.published.len(),
                        p.published.join(", ")
                    ));
                    if let Some(f) = &p.failed_at {
                        ui.label(format!("fail-stopped at: {f}"));
                    }
                }
            }
            Band::Ramp => {
                for r in &self.run.ramps {
                    let mark = if r.kind.on_ramp() { "●" } else { "○" };
                    ui.label(format!(
                        "{mark} {}: {} ({})",
                        r.repo,
                        r.branch,
                        r.kind.label()
                    ));
                }
                if self.run.ramps.is_empty() {
                    ui.label("no member checkouts found under the constellation root");
                }
            }
            Band::Plan => match &self.run.plan {
                Some(p) => {
                    ui.label(format!(
                        "{} · bump {} · branch {}",
                        p.date, p.bump, p.branch
                    ));
                    ui.label(format!(
                        "deps-first order ({}): {}",
                        p.order.len(),
                        p.order.join(" → ")
                    ));
                    for (k, v) in &p.ship_versions {
                        ui.label(format!("  {k} → {v}"));
                    }
                }
                None => {
                    ui.label("no saved changelist — no dated release day is open");
                }
            },
        }
        if let Some(b) = want {
            self.band = b;
        }
        if rescan {
            self.rescan();
        }
        serde_json::json!({
            "band": self.band.key(),
            "headline": self.headline(),
            "source": self.conv_source.key(),
            "release_run": self.run.state_json(),
        })
    }

    /// **The three-graph CONVERGENCE — the master half.** The reused
    /// `facett-releaseview` viewer (LOCAL · SPARRING · CRATES_IO), and nothing else.
    fn convergence_ui(&mut self, ui: &mut egui::Ui) {
        // Pick the render — two views of ONE model. The toggle is the pane's second
        // real interaction (after the band selector).
        ui.horizontal(|ui| {
            ui.label("Convergence:");
            if ui
                .selectable_label(self.conv_view == ConvView::Planes, "3-plane")
                .clicked()
            {
                self.conv_view = ConvView::Planes;
            }
            if ui
                .selectable_label(self.conv_view == ConvView::Graph, "Dependency DAG")
                .clicked()
            {
                self.conv_view = ConvView::Graph;
            }
            if self.conv_view == ConvView::Graph {
                ui.separator();
                // The measurement, on screen: nodes drawn and the last frame's cost. No
                // node cap, and the number that proves it is visible, not buried.
                let r = self.graph.report();
                let us = r["last_frame_us"].as_u64().unwrap_or(0);
                ui.weak(format!(
                    "{} node(s) · {} edge(s) · {:.1} ms/frame · no node cap",
                    self.graph.node_count(),
                    self.graph.edge_count(),
                    us as f64 / 1000.0,
                ));
            }
        });
        ui.separator();
        match self.conv_view {
            ConvView::Planes => self.inner.ui(ui),
            ConvView::Graph => self.graph.ui(ui),
        }
    }

    fn folded_state_json(&self) -> serde_json::Value {
        // Fold the reused ReleaseView pane's own state (planes · nodes · statuses ·
        // converged) and ADD (a) the binding oracle the gate reads and (b) the WHOLE
        // release-run state, so a robot can assert which repos are ramped / what the
        // gates said / how many crates are live — the RESULT, not a literal `true`.
        let mut v = self.inner.state_json();
        if let Some(obj) = v.as_object_mut() {
            obj.insert(
                "selected_workspace".into(),
                serde_json::json!(self.workspace),
            );
            obj.insert("release_target".into(), serde_json::json!(self.workspace));
            obj.insert("source".into(), serde_json::json!(self.conv_source.key()));
            // The convergence file this binding resolved to — loaded when `source` is a
            // run, and, when `source` is `"empty"`, the file that was NOT written. A
            // drive asserting the absence can then assert WHAT is absent, which is the
            // only thing that distinguishes an honest empty pane from a broken one.
            obj.insert(
                "convergence_path".into(),
                serde_json::json!(self.conv_path.display().to_string()),
            );
            obj.insert("band".into(), serde_json::json!(self.band.key()));
            // Which render of the convergence is showing, and what the GRAPH render
            // actually drew — node/edge counts, the measured frame cost, and the
            // explicit `node_cap: null` (there is none; nornir's CallGraph caps at 160).
            obj.insert("conv_view".into(), serde_json::json!(self.conv_view.key()));
            obj.insert("graph".into(), self.graph.report());
            obj.insert("headline".into(), serde_json::json!(self.headline()));
            obj.insert(
                "constellation_root".into(),
                serde_json::json!(self.root.display().to_string()),
            );
            // The read-only shooting-ramp state, verbatim from the shared leaf.
            obj.insert("release_run".into(), self.run.state_json());
            // The safety oracle: this pane exposes NO publish affordance, by construction.
            obj.insert("can_publish".into(), serde_json::json!(false));
        }
        v
    }

    fn apply_msg(&mut self, msg_json: &str) {
        if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) {
            // The selector's broadcast (the SAME `{"set_workspace":…}` every tab gets):
            // re-bind the release pane to the newly-selected workspace.
            if let Some(ws) = v.get("set_workspace").and_then(|t| t.as_str()) {
                self.bind(ws);
                return;
            }
            if let Some(b) = v
                .get("release_band")
                .and_then(|t| t.as_str())
                .and_then(Band::parse)
            {
                self.band = b;
                return;
            }
            if let Some(cv) = v
                .get("conv_view")
                .and_then(|t| t.as_str())
                .and_then(ConvView::parse)
            {
                self.conv_view = cv;
                return;
            }
            if v.get("rescan").and_then(|r| r.as_bool()) == Some(true) {
                self.rescan();
                return;
            }
        }
        // Anything else forwards to the reused pane's own seam (view toggles, etc.).
        self.inner.update_json(msg_json);
    }
}

/// **The Release body DECOMPOSES**: the convergence viewer is the master, the
/// shooting-ramp band the detail — two scene panes with a draggable divider, each with
/// its own chrome bar.
impl crate::tabbed::SplitPanel for ReleaseTab {
    fn ui_main(&mut self, ui: &mut egui::Ui) {
        self.convergence_ui(ui);
    }

    fn ui_inspector(&mut self, ui: &mut egui::Ui) -> serde_json::Value {
        self.ramp_ui(ui)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use dwarves::release_state::{GateState, GateVerdict, PartialRelease};
    use facett_releaseview::model::{CrateConvergence, ReleaseConvergence};
    use std::path::Path;

    /// Emit this tab's coverage-board cell (reuse edda's testmatrix — never a local
    /// harness).
    fn cell(name: &str, ok: bool, detail: &str) {
        #[cfg(feature = "testmatrix")]
        nornir_testmatrix::functional_status("dwarves-gui", name, ok, detail);
        let _ = (name, ok, detail);
    }

    /// Point `std::env::temp_dir()` (and therefore EVERY convergence / release-state path)
    /// at a private scratch directory, so a test can never see — or be seen by — a real
    /// release run's artifacts on this box. Returns the scratch root; the caller restores
    /// `TMPDIR` when done. Must be held under [`crate::ENV_LOCK`].
    fn scratch_tmp(tag: &str) -> (PathBuf, Option<String>) {
        let prev = std::env::var("TMPDIR").ok();
        let dir =
            std::env::temp_dir().join(format!("dwarves-gui-release-{tag}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        unsafe { std::env::set_var("TMPDIR", &dir) };
        (dir, prev)
    }

    fn restore_tmp(prev: Option<String>) {
        unsafe {
            match prev {
                Some(v) => std::env::set_var("TMPDIR", v),
                None => std::env::remove_var("TMPDIR"),
            }
        }
    }

    /// A fake constellation member checkout on `branch`.
    fn fake_repo(root: &Path, name: &str, branch: &str) {
        let git = root.join(name).join(".git");
        std::fs::create_dir_all(&git).unwrap();
        std::fs::write(git.join("HEAD"), format!("ref: refs/heads/{branch}\n")).unwrap();
    }

    /// Write a tiny, DISTINCT convergence JSON so a test can prove the pane loaded THAT
    /// file rather than the 4-node nordisk fixture.
    fn seed_convergence(path: &Path, title: &str) {
        let conv = ReleaseConvergence::new(title).with_node(CrateConvergence::new(
            "only-crate",
            "1.0.0",
            "1.0.0",
            "1.0.0",
        ));
        std::fs::write(path, serde_json::to_vec(&conv).unwrap()).unwrap();
    }

    /// RED-when-broken (the AUDIT gate, 2026-09-10): with NO selection — or a workspace
    /// that never ran a release — the Release tab draws NOTHING and says so BY NAME.
    ///
    /// This test is the inverse of the one it replaces. That one asserted
    /// `source == "fixture"` and `node_count == 4`: the nordisk fixture, four real crate
    /// names carrying invented versions, two of them badged RED "needs-release", under a
    /// caption reading "○ fixture". The caption was honest and the pixels were not — the
    /// pane's entire job is to answer *which crates need publishing*, and it was
    /// answering before any release had run.
    ///
    /// So the assertions here are: zero nodes, `source:"empty"`, the convergence file
    /// NAMED (both in the tab's own `convergence_path` and in the reused pane's
    /// `convergence_empty` block), and — still — no invented gate verdict.
    #[test]
    fn no_selection_draws_no_convergence_and_names_the_missing_file() {
        let _g = crate::ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
        let (dir, prev) = scratch_tmp("empty");
        // `scratch_tmp` repoints TMPDIR, so both convergence paths resolve under `dir`
        // and neither exists — the "no run anywhere" state, hermetically.
        let t = ReleaseTab::rooted("", &dir);
        let s = Facet::state_json(&t);
        let named = s["convergence_path"].as_str().unwrap_or("").to_string();
        let empty_block = &s["convergence_empty"];
        let ok = s["source"] == "empty"
            && s["selected_workspace"] == ""
            && s["component"] == "ReleaseView"
            && s["node_count"] == 0
            && !named.is_empty()
            && empty_block["empty"] == true
            && !empty_block["feed"].as_str().unwrap_or("").is_empty()
            && s["release_run"]["gate_a"] == "not-run"
            && s["release_run"]["live_crate_count"] == 0
            && s["can_publish"] == false;
        assert_eq!(
            s["source"], "empty",
            "no run anywhere ⇒ the NAMED absence, not a fixture"
        );
        assert_eq!(s["node_count"], 0, "no run ⇒ no crates drawn: {s}");
        assert!(
            !named.is_empty(),
            "the absence names the convergence file it wanted: {s}"
        );
        assert_eq!(
            empty_block["empty"], true,
            "the reused pane carries its EmptyState: {s}"
        );
        assert_eq!(
            s["release_run"]["gate_a"], "not-run",
            "no run ⇒ no invented gate verdict"
        );
        assert_eq!(
            s["can_publish"], false,
            "the Release pane exposes NO publish affordance"
        );
        // And the deleted fixture's crate/version pairs must not be reachable from the
        // state at all — the pin that keeps them from creeping back in as a "demo".
        let dump = s.to_string();
        for gone in ["needs-release", "bump-drift", "0.6.9", "nordisk release-3"] {
            assert!(
                !dump.contains(gone),
                "the nordisk fixture leaked back in (`{gone}`): {dump}"
            );
        }
        restore_tmp(prev);
        let _ = std::fs::remove_dir_all(&dir);

        cell(
            "release_no_run_names_the_absence",
            ok,
            "with no release run anywhere the Release tab draws ZERO nodes and NAMES what is missing — source:\"empty\", the convergence file it wanted in convergence_path, and the reused facett-releaseview pane's own EmptyState block — instead of the deleted nordisk 4-crate FIXTURE whose invented versions badged two real crates RED \"needs-release\" before any release had run (audit 2026-09-10); gate_a stays \"not-run\" and can_publish:false",
        );
    }

    /// RED-when-broken (the BINDING gate): selecting a workspace that HAS a release run
    /// makes the tab reflect THAT workspace's convergence file, and a run that wrote only
    /// the SHARED output path is still shown (the gap that made a real release invisible
    /// whenever a workspace was selected). The third rung of the ladder is no longer the
    /// fixture: with no run anywhere the tab reports `source:"empty"` and zero nodes.
    #[test]
    fn the_selected_workspace_and_the_shared_run_output_both_reach_the_viewer() {
        let _g = crate::ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
        let (dir, prev) = scratch_tmp("bind");

        let ws = "dwarves-shell-test-ws";
        seed_convergence(
            &dwarves_embed::workspace_convergence_path(ws),
            "dwarves-shell-test convergence",
        );

        let mut t = ReleaseTab::rooted("", &dir);
        // Bind via the SAME `{"set_workspace":…}` broadcast the selector sends.
        Facet::update_json(
            &mut t,
            &serde_json::json!({ "set_workspace": ws }).to_string(),
        );
        let s = Facet::state_json(&t);
        let bound_ok = s["selected_workspace"] == ws
            && s["release_target"] == ws
            && s["source"] == "workspace-run"
            && s["title"] == "dwarves-shell-test convergence"
            && s["node_count"] == 1
            && s["converged"] == true;
        assert_eq!(
            s["source"], "workspace-run",
            "a run for THIS workspace ⇒ its own file"
        );
        assert_eq!(s["node_count"], 1, "the seeded convergence, and only it");

        // A workspace with NO file of its own, while a real run wrote the SHARED output:
        // the run must still be visible (this used to silently show the fixture).
        let _ = std::fs::remove_file(dwarves_embed::workspace_convergence_path(ws));
        seed_convergence(
            &dwarves_embed::default_convergence_path(),
            "shared run convergence",
        );
        Facet::update_json(
            &mut t,
            &serde_json::json!({ "set_workspace": "no-run-ws" }).to_string(),
        );
        let s = Facet::state_json(&t);
        let shared_ok = s["source"] == "release-run"
            && s["title"] == "shared run convergence"
            && s["node_count"] == 1;
        assert_eq!(
            s["source"], "release-run",
            "a real run's shared output is NOT hidden"
        );
        assert_eq!(s["title"], "shared run convergence");

        // With no run anywhere, the ladder's last rung: the NAMED absence. It used to be
        // the nordisk fixture, told apart only by `source` — four invented crates drawn
        // in exactly the place a real convergence goes (deleted 2026-09-10).
        let _ = std::fs::remove_file(dwarves_embed::default_convergence_path());
        Facet::update_json(
            &mut t,
            &serde_json::json!({ "set_workspace": "still-no-run-ws" }).to_string(),
        );
        let s = Facet::state_json(&t);
        let empty_ok = s["source"] == "empty"
            && s["node_count"] == 0
            && s["convergence_empty"]["empty"] == true
            && s["convergence_path"]
                .as_str()
                .is_some_and(|p| !p.is_empty());
        assert_eq!(
            s["source"], "empty",
            "no run anywhere ⇒ the named absence: {s}"
        );
        assert_eq!(
            s["node_count"], 0,
            "and nothing drawn in a real convergence's place"
        );

        restore_tmp(prev);
        let _ = std::fs::remove_dir_all(&dir);

        cell(
            "release_binds_to_the_selected_workspace_convergence",
            bound_ok && shared_ok && empty_ok,
            "the Release tab re-binds over the {\"set_workspace\":…} broadcast and walks the convergence LADDER — the workspace's own run file, then the SHARED workspace-release output (previously invisible whenever a workspace was selected), then NOTHING (source:\"empty\", zero nodes, the wanted file named) where the nordisk fixture used to be — with `source` naming which of the three reached the reused 3-plane viewer",
        );
    }

    /// RED-when-broken (the MID-RAMP view — the whole point of the band): a constellation
    /// with one repo on a DATED release branch and one on an EPHEMERAL ramp reports
    /// exactly those two as on-ramp, names the bump plan from the saved changelist, and
    /// drives the band sections over the SAME JSON seam a robot uses. Asserted on the
    /// resulting state, never a literal `true`.
    #[test]
    fn a_release_mid_ramp_is_visible_with_its_bump_plan() {
        let _g = crate::ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
        let (dir, prev) = scratch_tmp("midramp");
        let cst = dir.join("constellation");
        fake_repo(&cst, "nornir", "release/2026-07-18");
        fake_repo(&cst, "holger", "release/ramp/run-42");
        fake_repo(&cst, "skade", "main");
        // The saved changelist the dated release day wrote (the bump plan).
        let nornir_docs = cst.join("nornir").join(".nornir");
        std::fs::create_dir_all(&nornir_docs).unwrap();
        std::fs::write(
            nornir_docs.join("release-2026-07-18-changelist.md"),
            "- date: 2026-07-18\n- root: workspace_nordisk\n- bump: patch\n- branch: release/2026-07-18\n\n\
             ## Deps-first publish order (2 step(s))\n1. skade\n2. holger\n\n\
             ## Ship-version graph (2 crate(s))\n- holger@0.6.9\n- skade@0.5.2\n",
        )
        .unwrap();

        let t = ReleaseTab::rooted("nordisk", &cst);
        let s = Facet::state_json(&t);
        let r = &s["release_run"];
        let ramp_ok = r["mid_ramp"] == true
            && r["member_count"] == 3
            && r["on_ramp"] == serde_json::json!(["holger", "nornir"])
            && r["bump"] == "patch"
            && r["release_branch"] == "release/2026-07-18"
            && r["planned_crate_count"] == 2
            && r["publish_order"] == serde_json::json!(["skade", "holger"])
            && r["ship_versions"]["skade"] == "0.5.2";
        assert_eq!(
            r["mid_ramp"], true,
            "a release IS open on this constellation"
        );
        assert_eq!(
            r["on_ramp"],
            serde_json::json!(["holger", "nornir"]),
            "exactly the ramped repos"
        );
        assert_eq!(
            r["ramps"][2]["kind"], "base",
            "skade is on main, not on a ramp"
        );
        assert_eq!(r["bump"], "patch", "the saved changelist names the bump");
        assert_eq!(
            r["publish_order"],
            serde_json::json!(["skade", "holger"]),
            "deps-first order"
        );
        assert!(
            s["headline"]
                .as_str()
                .unwrap()
                .contains("mid-ramp: 2 repo(s)"),
            "the header states the ramp plainly: {}",
            s["headline"]
        );

        // Drive the band over the seam (the pane's real interaction) and assert it moved.
        let mut t = t;
        assert_eq!(
            Facet::state_json(&t)["band"],
            "gates",
            "the band opens on the gates"
        );
        Facet::update_json(&mut t, r#"{"release_band":"plan"}"#);
        let band_ok = Facet::state_json(&t)["band"] == "plan";
        assert_eq!(Facet::state_json(&t)["band"], "plan");
        Facet::update_json(&mut t, r#"{"release_band":"ramp"}"#);
        let band_ok = band_ok && Facet::state_json(&t)["band"] == "ramp";
        assert_eq!(Facet::state_json(&t)["band"], "ramp");

        restore_tmp(prev);
        let _ = std::fs::remove_dir_all(&dir);

        cell(
            "release_mid_ramp_is_visible",
            ramp_ok && band_ok,
            "the Release tab's shooting-ramp band shows a release MID-FLIGHT: exactly the repos on a ramp branch (dated release/YYYY-MM-DD + ephemeral release/ramp/<run-id>, the untouched repo excluded), the saved changelist's bump level + deps-first order + crate→ship-version graph, and a headline naming the ramp — with the Gates/Ramp/Bump-plan sections driven over the {\"release_band\":…} seam",
        );
    }

    /// RED-when-broken (the GATE view + the SAFETY view): a run's reported Gate A / Gate B
    /// verdicts reach the pane, and a fail-stopped `--execute` that already put crates on
    /// crates.io is surfaced LOUDLY with the exact live count — the state this box is
    /// actually in. A pane that under-reports live crates is the dangerous failure.
    #[test]
    fn gate_verdicts_and_an_already_live_partial_release_are_surfaced() {
        let _g = crate::ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
        let (dir, prev) = scratch_tmp("gates");
        let cst = dir.join("constellation");
        fake_repo(&cst, "dwarves", "release/ramp/run-9");

        let ws = "gate-test-ws";
        let reported = ReleaseRunState {
            workspace: ws.to_string(),
            run_id: "ws-deadbeef".into(),
            execute: true,
            gates: GateState {
                gate_a: GateVerdict::Passed,
                gate_a_detail: "static gates green".into(),
                gate_b: GateVerdict::Failed,
                gate_b_detail: "the /sparring rehearsal did not fully publish the cascade".into(),
            },
            ramps: Vec::new(),
            plan: None,
            partial: Some(PartialRelease {
                published: vec![
                    "skade@0.5.2".into(),
                    "holger@0.6.9".into(),
                    "nornir-git@0.2.1".into(),
                ],
                failed_at: Some("dwarves/publish".into()),
                kept_ramps: vec![("dwarves".into(), "release/ramp/run-9".into())],
            }),
        };
        reported
            .write(&dwarves::release_state::workspace_release_state_path(ws))
            .unwrap();

        let t = ReleaseTab::rooted(ws, &cst);
        let s = Facet::state_json(&t);
        let r = &s["release_run"];
        let gates_ok = r["gate_a"] == "passed"
            && r["gate_b"] == "failed"
            && r["gate_b_detail"] == "the /sparring rehearsal did not fully publish the cascade"
            && r["run_id"] == "ws-deadbeef"
            && r["execute"] == true;
        let live_ok = r["live_crate_count"] == 3
            && r["failed_at"] == "dwarves/publish"
            && r["live_crates"]
                == serde_json::json!(["skade@0.5.2", "holger@0.6.9", "nornir-git@0.2.1"]);
        assert_eq!(r["gate_a"], "passed", "Gate A's verdict reaches the pane");
        assert_eq!(r["gate_b"], "failed", "Gate B's verdict reaches the pane");
        assert_eq!(
            r["live_crate_count"], 3,
            "EVERY already-live crate is counted"
        );
        assert!(
            s["headline"].as_str().unwrap().contains("3 crate(s) LIVE"),
            "a partial release is the LOUDEST thing in the header: {}",
            s["headline"]
        );
        // The live ramp scan still wins over the (empty) ramp set in the reported file.
        assert_eq!(
            r["member_count"], 1,
            "the ramps come from the live disk scan"
        );
        assert_eq!(r["mid_ramp"], true);
        assert_eq!(
            s["can_publish"], false,
            "still no publish affordance, mid-incident"
        );

        let _ = std::fs::remove_file(dwarves::release_state::workspace_release_state_path(ws));
        restore_tmp(prev);
        let _ = std::fs::remove_dir_all(&dir);

        cell(
            "release_gates_and_partial_release_surfaced",
            gates_ok && live_ok,
            "the Release tab surfaces the run's Gate A (static) and Gate B (/sparring rehearsal) verdicts with their detail lines, and renders a fail-stopped --execute's ALREADY-LIVE crates.io crates with the exact count + the fail point as the loudest line in the header — read-only, can_publish:false",
        );
    }

    /// **The Release DAG opens centred and a click never moves it** — rendered: the
    /// tab's own `ui` (the band, the toolbar, then the graph) beside a 320 px rail, the
    /// graph's box centred (±2 px) and wholly inside the part of its canvas the viewer
    /// sees; a real click on a node (📌 Stay) leaves it where it was. RED before: the
    /// pane's first spec drew at zoom 1 with world (0, 0) at the canvas centre — the
    /// chain hung off to the right.
    #[test]
    fn the_release_dag_opens_centred_and_a_click_never_moves_it() {
        use facett_releaseview::model::{CrateConvergence, DepEdge, ReleaseConvergence};
        let conv = ReleaseConvergence::new("release-dag-camera")
            .with_node(CrateConvergence::new("skade", "0.5.1", "0.5.1", "0.5.1"))
            .with_node(CrateConvergence::new("dwarves", "0.1.4", "0.1.4", "0.1.3"))
            .with_node(CrateConvergence::new("holger", "0.6.9", "0.6.9", "0.6.8"))
            .with_node(CrateConvergence::new("nornir", "0.5.3", "0.5.4", "0.5.4"))
            .with_edge(DepEdge::new(1, 0))
            .with_edge(DepEdge::new(2, 1))
            .with_edge(DepEdge::new(3, 2));
        let dir = tempfile::tempdir().unwrap();
        let mut tab = ReleaseTab::rooted("", dir.path());
        tab.graph.set_spec(&convergence_to_dag(&conv));
        tab.conv_view = ConvView::Graph;
        let cell = std::rc::Rc::new(std::cell::RefCell::new(tab));
        let c2 = cell.clone();
        let mut h = egui_kittest::Harness::builder()
            .with_size(egui::vec2(1400.0, 900.0))
            .with_step_dt(1.0 / 60.0)
            .build_ui(move |ui| {
                egui::Panel::left("rail").exact_size(320.0).show_inside(ui, |ui| {
                    ui.label("rail");
                });
                facett_core::Facet::ui(&mut *c2.borrow_mut(), ui);
            });
        for _ in 0..4 {
            h.step();
        }
        let (seen, b) = {
            let t = cell.borrow();
            (t.graph.last_visible().expect("painted"), t.graph.screen_box().expect("nodes"))
        };
        assert!((b.center() - seen.center()).length() <= 2.0, "the DAG is centred: box {b:?} in {seen:?}");
        assert!(seen.expand(0.5).contains_rect(b), "the whole DAG is visible: box {b:?} in {seen:?}");

        // 📌 Stay: a real click on `holger` selects it and moves nothing
        let at = |t: &ReleaseTab| {
            let p = t.graph.positions().into_iter().find(|(i, _)| i == "holger").unwrap().1;
            t.graph.world_to_screen(p).unwrap()
        };
        let before = at(&cell.borrow());
        let cam = cell.borrow().graph.camera();
        h.input_mut().events.push(egui::Event::PointerMoved(before));
        h.step();
        for pressed in [true, false] {
            h.input_mut().events.push(egui::Event::PointerButton { pos: before, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
            h.step();
        }
        for _ in 0..30 {
            h.step();
        }
        assert_eq!(cell.borrow().graph.selected(), Some("holger"), "the click landed on holger");
        assert_eq!(cell.borrow().graph.camera(), cam, "the camera did not move");
        assert_eq!(at(&cell.borrow()), before);
    }

    /// **The release convergence is ALSO a graph, on the shared engine — and the two
    /// renders are ONE model.** RED-when-broken: the graph's node count must equal the
    /// convergence's, the dependency edges must survive the lowering, the render must
    /// be measured (a real frame cost), and there must be no node cap.
    #[test]
    fn the_release_convergence_renders_as_a_measured_graph() {
        // A 4-crate chain built HERE, in the test. It used to be
        // `dwarves_embed::release_fixture()` — deleted on 2026-09-10 because the
        // shipped pane drew it whenever no release run was on disk.
        use facett_releaseview::model::{CrateConvergence, DepEdge, ReleaseConvergence};
        let conv = ReleaseConvergence::new("release-dag-test")
            .with_node(CrateConvergence::new("skade", "0.5.1", "0.5.1", "0.5.1"))
            .with_node(CrateConvergence::new("dwarves", "0.1.4", "0.1.4", "0.1.3"))
            .with_node(CrateConvergence::new("holger", "0.6.9", "0.6.9", "0.6.8"))
            .with_node(CrateConvergence::new("nornir", "0.5.3", "0.5.4", "0.5.4"))
            .with_edge(DepEdge::new(1, 0))
            .with_edge(DepEdge::new(2, 1))
            .with_edge(DepEdge::new(3, 2));
        let spec = convergence_to_dag(&conv);
        assert_eq!(
            spec.nodes.len(),
            conv.nodes.len(),
            "every crate becomes a node"
        );
        assert_eq!(
            spec.edges.len(),
            conv.edges.len(),
            "every dependency becomes an edge"
        );

        let mut pane = crate::dag_graph::DagPane::new("Release DAG");
        pane.set_spec(&spec);
        assert_eq!(pane.node_count(), 4);
        assert_eq!(pane.edge_count(), 3);
        assert!(
            pane.report()["node_cap"].is_null(),
            "no node cap (nornir's CallGraph caps at 160)"
        );

        // MEASURE it: render a real headless frame and read the cost the pane recorded.
        let cell_pane = std::cell::RefCell::new(pane);
        let mut h = egui_kittest::Harness::builder()
            .with_size(egui::vec2(1000.0, 700.0))
            .build_ui(|ui| {
                cell_pane.borrow_mut().ui(ui);
            });
        h.step();
        h.step();
        let measured = cell_pane.borrow().last_frame_us();
        assert!(measured.is_some(), "the frame is timed, not asserted-fast");
        let us = measured.unwrap();

        cell(
            "release_convergence_renders_as_a_measured_graph",
            measured.is_some(),
            &format!(
                "the release convergence lowers 1:1 onto the shared facett-graphview engine (4 nodes / 3 dependency edges, same model as the 3-plane pane), renders a real headless frame measured at {} us, and reports node_cap:null — there is no MAX_NODES ceiling",
                us
            ),
        );
    }
}