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

nordisk/dwarves

crates/dwarves-embed/src/releaseview.rs

//! **The dwarves RELEASE-GRAPH pane** (task #6 + #28) — the release graph, SEEN.
//!
//! The reuse-only half: it takes the neutral [`facett_releaseview::ReleaseConvergence`]
//! (the three graph states — LOCAL manifest · SPARRING Gate-B rehearsal · CRATES_IO
//! published — that a release drives to CONVERGE) and wraps the SHARED
//! [`facett_releaseview::ReleaseView`] as a `Box<dyn Panel>` any egui host drops into a
//! tab. The viewer is **reused verbatim** (L5) — the 3-plane hero (planes snap together
//! as crates converge) + the 2D tri-badge fallback. Nothing here re-implements a viewer.
//!
//! Where the model comes from:
//! * a **release run** (`dwarves workspace-release --features facett-adapter`) writes the
//!   convergence output (dwarves' `convergence_adapter` lowering the `graph_diff`
//!   before/after into the model) to [`default_convergence_path`]; [`release_panel`]
//!   loads it.
//! * a test / caller can hand a [`ReleaseConvergence`] straight in
//!   ([`panel_from_convergence`]).
//!
//! ## The nordisk fixture is gone (audit 2026-09-10)
//! With no convergence file this used to mount `release_fixture()`: a four-crate chain
//! — skade converged green, dwarves and holger RED "needs-release", nornir AMBER
//! "bump-drift", with version numbers (`0.1.4` vs `0.1.3`, `0.6.9` vs `0.6.8`) that
//! were entirely invented. A release pane whose whole job is to say *which crates need
//! publishing* was answering that question with made-up versions before any release run
//! had happened. It is deleted. With no file the pane now draws the shared
//! [`facett_core::empty::EmptyState`] NAMING the convergence file it wanted, and zero
//! nodes.
//!
//! The pane's GraphDiff before/after IS the convergence: `cratesio` = the `before`
//! (published) version, `local` = the `after` (planned) version — a bumped crate reads
//! red (needs-release), an all-agree graph green (converged, planes collapsed).

use std::path::{Path, PathBuf};

use facett_core::empty::EmptyState;
use facett_core::{Facet, Panel};
use facett_releaseview::model::ReleaseConvergence;
use facett_releaseview::ReleaseView;

/// The convergence feed, named the way an operator would go look it up.
const RELEASE_FEED: &str = "dwarves workspace-release convergence JSON";

/// The Release tab body: the SHARED [`ReleaseView`], plus the NAMED empty state to draw
/// when there is no convergence to show.
///
/// It exists only because a pane needs somewhere to hang "I have nothing, and here is
/// which file I wanted". The viewer is still reused verbatim — every render and every
/// message goes straight through, and the wrapper's `state_json` is the viewer's own
/// with one extra key.
struct ReleasePane {
    inner: ReleaseView,
    empty: Option<EmptyState>,
}

impl Facet for ReleasePane {
    fn title(&self) -> &str {
        Facet::title(&self.inner)
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        if let Some(e) = &self.empty {
            // No convergence ⇒ no planes, no crate rows, no colours. A release pane
            // that paints a red "needs-release" chip is making a claim.
            e.ui(ui);
            return;
        }
        Facet::ui(&mut self.inner, ui);
    }

    fn state_json(&self) -> serde_json::Value {
        // The viewer's own state passes through (component · planes · node_count · …),
        // so every existing oracle keeps reading exactly what it read before.
        let mut j = Facet::state_json(&self.inner);
        if let Some(e) = &self.empty {
            if let Some(obj) = j.as_object_mut() {
                obj.insert("convergence_empty".into(), e.state_json());
            }
        }
        j
    }

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

/// The temp file a `workspace-release` run writes its convergence output to — the
/// default the Release pane + `dwarves release watch` load. MUST match dwarves'
/// `default_convergence_path` (the emit site) so the pane sees the run's output.
pub fn default_convergence_path() -> PathBuf {
    std::env::temp_dir().join("dwarves-workspace-release-convergence.json")
}

/// The convergence file a `workspace-release` run writes **for a named workspace** —
/// the per-workspace analogue of [`default_convergence_path`], so the Release tab can
/// load the SELECTED workspace's own release state (not one global file). A sanitized
/// `&lt;temp&gt;/dwarves-workspace-release-convergence-&lt;name&gt;.json`; an empty name falls back
/// to the shared [`default_convergence_path`]. MUST match the release emit site so the
/// pane sees the run's output for that workspace.
pub fn workspace_convergence_path(workspace: &str) -> PathBuf {
    let name = workspace.trim();
    if name.is_empty() {
        return default_convergence_path();
    }
    // Keep the filename filesystem-safe: only `[A-Za-z0-9._-]`, everything else → `_`.
    let safe: String = name
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
                c
            } else {
                '_'
            }
        })
        .collect();
    std::env::temp_dir().join(format!("dwarves-workspace-release-convergence-{safe}.json"))
}

/// Wrap a [`ReleaseConvergence`] as the shared [`ReleaseView`] pane (`Box<dyn Panel>`).
/// The ONE construction path — `ReleaseView` is reused verbatim (its `Facet`→`Panel`
/// blanket makes it a drop-in tab).
pub fn panel_from_convergence(conv: ReleaseConvergence) -> Box<dyn Panel> {
    // A convergence with no crates in it is not a graph — it is the absence of one, and
    // it says so by name rather than rendering three empty planes.
    let empty = conv.nodes.is_empty().then(|| {
        EmptyState::no_rows(RELEASE_FEED, "the loaded convergence names no crates")
            .with_hint("`dwarves release --features facett-adapter`")
    });
    Box::new(ReleasePane {
        inner: ReleaseView::from_model(conv),
        empty,
    })
}

/// The pane for "there is no convergence to show", carrying the NAMED reason.
fn panel_empty(state: EmptyState) -> Box<dyn Panel> {
    Box::new(ReleasePane {
        inner: ReleaseView::from_model(ReleaseConvergence::new("release")),
        empty: Some(state),
    })
}

/// Load a persisted [`ReleaseConvergence`] JSON (a release run's convergence output, or a
/// fixture) into the pane. On a parse error the pane shows an EMPTY titled convergence
/// (the honest "nothing to converge yet" state) — never a panic.
pub fn panel_from_json(json: &str) -> Box<dyn Panel> {
    let conv = serde_json::from_str::<ReleaseConvergence>(json)
        .unwrap_or_else(|_| ReleaseConvergence::new("release"));
    panel_from_convergence(conv)
}

/// The dwarves UI **Release** tab: render the release run's convergence output as the
/// 3-plane pane. Reads `convergence` (default [`default_convergence_path`]); if the file
/// is absent or unreadable (no release run yet), the pane draws the NAMED empty state
/// over that path — never an invented convergence. Host-agnostic + offline.
pub fn release_panel(convergence: Option<PathBuf>) -> Box<dyn Panel> {
    let path = convergence.unwrap_or_else(default_convergence_path);
    match std::fs::read_to_string(&path) {
        Ok(json) => panel_from_json(&json),
        Err(e) => panel_empty(
            EmptyState::not_configured(RELEASE_FEED, format!("{}: {e}", path.display()))
                .with_hint("run a release (`dwarves release --features facett-adapter`)"),
        ),
    }
}

/// Load the pane from a specific convergence file path (helper for the CLI / a host that
/// already resolved the path). Missing/unreadable ⇒ the NAMED empty state.
pub fn release_panel_at(path: &Path) -> Box<dyn Panel> {
    release_panel(Some(path.to_path_buf()))
}

/// **The MODEL behind [`release_panel_at`]**, on the same ladder and with the same
/// fallback — so a second RENDER of the same release (e.g. the graph view) shows the
/// same convergence the 3-plane pane does, rather than loading its own file and
/// silently disagreeing with the pane beside it.
pub fn load_convergence_at(path: &Path) -> ReleaseConvergence {
    match std::fs::read_to_string(path) {
        Ok(json) => serde_json::from_str::<ReleaseConvergence>(&json).unwrap_or_else(|_| {
            // A file that exists but does not parse is NOT a fixture situation — the
            // honest state is an empty titled convergence, which renders as "nothing
            // to converge" rather than as somebody else's release.
            ReleaseConvergence::new("release")
        }),
        // No file is the SAME honest state as an unparseable one: an empty titled
        // convergence, which the pane renders as its named empty state. It used to be
        // `release_fixture()` — somebody else's release, drawn as yours.
        Err(_) => ReleaseConvergence::new("release"),
    }
}

/// A four-crate nordisk convergence for a TEST — one converged, two needs-release, one
/// bump-drift, so the viewer has all three verdicts to bucket.
///
/// **`#[cfg(test)]` since 2026-09-10.** As `pub fn release_fixture()` this was what the
/// SHIPPED pane drew with no release run on disk, complete with invented version
/// numbers for four real crates.
#[cfg(test)]
pub(crate) fn release_fixture() -> ReleaseConvergence {
    use facett_releaseview::model::{CrateConvergence, DepEdge};
    ReleaseConvergence::new("nordisk release-3")
        .with_node(CrateConvergence::new("skade", "0.5.1", "0.5.1", "0.5.1")) // converged
        .with_node(CrateConvergence::new("dwarves", "0.1.4", "0.1.4", "0.1.3")) // needs-release
        .with_node(CrateConvergence::new("holger", "0.6.9", "0.6.9", "0.6.8")) // needs-release
        .with_node(CrateConvergence::new("nornir", "0.5.3", "0.5.4", "0.5.4")) // bump-drift
        .with_edge(DepEdge::new(1, 0)) // dwarves → skade
        .with_edge(DepEdge::new(2, 1)) // holger → dwarves
        .with_edge(DepEdge::new(3, 2)) // nornir → holger
}

#[cfg(test)]
mod tests {
    use super::*;
    use facett_releaseview::model::{CrateConvergence, DepEdge};

    /// **THE NORDISK FIXTURE IS GONE** (audit 2026-09-10). With no release run on disk
    /// the pane used to draw `release_fixture()` — skade/dwarves/holger/nornir with
    /// invented versions, two of them RED "needs-release". A pane whose one job is to
    /// say which crates need publishing was answering with made-up numbers. Pins:
    ///
    ///   (a) a missing convergence file gives ZERO nodes, not four;
    ///   (b) the pane carries the NAMED empty state over the convergence file, cause
    ///       `not-configured`;
    ///   (c) none of the fixture's invented crate/version strings survives anywhere;
    ///   (d) a file that EXISTS but names no crates is a different cause (`no-rows`).
    ///
    /// RED-when-broken: restore any fallback convergence and (a)/(c) fail.
    #[test]
    fn a_missing_release_run_names_its_feed_never_a_fixture() {
        let pane = release_panel(Some(PathBuf::from(
            "/nonexistent/dwarves/release-conv.json",
        )));
        let st = pane.state_json();

        // (a) + (b)
        assert_eq!(
            st["node_count"], 0,
            "no release run ⇒ no crates on the graph: {st}"
        );
        assert_eq!(st["convergence_empty"]["empty"], true);
        assert_eq!(
            st["convergence_empty"]["feed"], RELEASE_FEED,
            "the empty state NAMES the convergence feed: {st}"
        );
        assert_eq!(st["convergence_empty"]["cause"], "not-configured");
        assert!(
            st["convergence_empty"]["detail"]
                .as_str()
                .unwrap()
                .contains("release-conv.json"),
            "…and names the path it looked at: {st}"
        );

        // (c) the fixture's invented facts appear NOWHERE.
        let whole = st.to_string();
        for gone in ["nordisk release-3", "0.6.9", "0.6.8", "0.1.3"] {
            assert!(
                !whole.contains(gone),
                "the deleted fixture's `{gone}` is gone: {whole}"
            );
        }

        // (d) an empty-but-present convergence is read-and-empty, not not-configured.
        let present = panel_from_convergence(ReleaseConvergence::new("release"));
        assert_eq!(
            present.state_json()["convergence_empty"]["cause"],
            "no-rows"
        );
    }

    /// ROBOT SCENARIO (task #6 + #28) — the **release-graph pane** driven over the remote
    /// seam (`Panel::state_json`), the SAME channel a `nornir-robotui` scenario and the
    /// thin client use. Feed a nordisk `ReleaseConvergence` fixture → assert the pane
    /// renders the THREE planes (LOCAL · SPARRING · CRATES_IO) and buckets each crate
    /// red/amber/green (needs-release · bump-drift · converged); then feed an ALL-AGREE
    /// convergence → the pane converges (fraction 1.0 ⇒ the planes collapse). This is the
    /// L4 UI proof that the reused `facett-releaseview` viewer receives the model and the
    /// convergence reaches the pane as data. Emits a `nornir-testmatrix` cell (reuse edda).
    #[test]
    fn robot_release_graph_pane_renders_planes_and_converges() {
        // (1) The diverged fixture: three planes present, one red + one amber + one green.
        let pane = panel_from_convergence(release_fixture());
        let st = pane.state_json();
        let planes = st["planes"].as_array().unwrap();
        let planes_ok = st["component"] == "ReleaseView"
            && planes.len() == 3
            && planes.iter().any(|p| p == "local")
            && planes.iter().any(|p| p == "sparring")
            && planes.iter().any(|p| p == "cratesio")
            && st["node_count"] == 4
            && st["edge_count"] == 3;
        // The three severity buckets all land (red · red · amber · green).
        let s = &st["statuses"];
        let buckets_ok = s["needs_release"] == 2   // dwarves + holger (crates.io behind)
            && s["bump_drift"] == 1                 // nornir (rehearsal ahead)
            && s["converged"] == 1                  // skade
            && st["converged"] == false;

        // (2) An all-agree convergence collapses: every plane equal ⇒ 100% converged.
        let agreed = ReleaseConvergence::new("nordisk — all shipped")
            .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.4"))
            .with_edge(DepEdge::new(1, 0));
        let done = panel_from_convergence(agreed);
        let ds = done.state_json();
        let collapsed_ok = ds["converged"] == true && ds["converged_fraction"] == 1.0;

        // (3) A missing convergence file yields ZERO nodes + the named empty state —
        // the tab is honest, not never-blank at the cost of being wrong.
        let fallback = release_panel(Some(PathBuf::from(
            "/nonexistent/dwarves/release-conv.json",
        )));
        let fb = fallback.state_json();
        let fallback_ok = fb["component"] == "ReleaseView"
            && fb["node_count"] == 0
            && fb["convergence_empty"]["feed"] == RELEASE_FEED;

        let drivable = planes_ok && buckets_ok && collapsed_ok && fallback_ok;
        assert!(
            drivable,
            "the release-graph pane renders the 3 planes + converges over the seam: {st}"
        );

        facett_core::testmatrix::emit(
            "dwarves-embed::releaseview",
            "release_graph_pane_renders_planes_and_converges",
            drivable,
            "release-graph pane (reused facett-releaseview ReleaseView) renders the 3 planes (LOCAL·SPARRING·CRATES_IO) + buckets red/amber/green; all-agree ⇒ converged/collapsed; missing file ⇒ zero nodes + the NAMED empty state over the convergence feed (the nordisk fixture was deleted 2026-09-10) — driven headless over Panel::state_json",
        );
    }

    /// A persisted convergence JSON round-trips into the pane (the release run's output
    /// path). RED-when-broken: if the pane stopped loading the serialized model, this reds.
    #[test]
    fn pane_loads_persisted_convergence_json() {
        let json = serde_json::to_string(&release_fixture()).unwrap();
        let pane = panel_from_json(&json);
        let st = pane.state_json();
        assert_eq!(st["node_count"], 4);
        assert_eq!(st["title"], "nordisk release-3");
        // Garbage in ⇒ empty titled pane, never a panic.
        let empty = panel_from_json("not json");
        assert_eq!(empty.state_json()["node_count"], 0);
    }
}