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

nordisk/edda

crates/nornir-appshell/src/lib.rs

//! # nornir-appshell
//!
//! The two **pure, egui-FREE app-shell models** every suite app's *About* card and
//! *Tools* menu used to hand-roll — the shared half of the constellation-wide
//! JetBrains-style common shell (an About · a Tools menu · a shared lifecycle).
//!
//! ## Why it exists (LAW #5 — DRY)
//! Four apps each rolled their OWN About surface and korp↔modgunn twinned the Tools
//! menu shell:
//!
//! | app | About today | Tools today |
//! |-----|-------------|-------------|
//! | **korp** | `about.rs` const-blob (`WORDMARK`/`TAGLINE`/`SUBTITLE`/…) + egui window | `tools.rs` `ToolEntry`/`entries()`/`state_json` |
//! | **holger** | `about.rs` const-blob + `FRAMEWORK[]` + egui window (LFS-tolerant logo) | — |
//! | **modgunn** | `about.rs` — already a `struct Identity` (CLI `modgunn about`) | `tools.rs` `ToolEntry`/`entries()`/`state_json` |
//! | **nornir** | inline egui popup | — |
//!
//! The **identity data** and the **menu structure** are pure data with a JSON
//! oracle — no egui, no domain — so a third consumer (dwarves/ordning/nornir) wants
//! them as-is (LAW #5a: leaf = the dep-light primitive). This crate owns those two
//! models; each app KEEPS its domain (its `enum ToolEffect` + `entries()` builder,
//! its container/demo specs, its `ServiceSpec`) and — a later wave — the egui About
//! *widget* moves to a `facett-about` leaf that renders an [`Identity`].
//!
//! ## What is here
//! * [`Identity`] — the About card's data + [`Identity::state_json`] (the oracle
//!   korp/holger/modgunn About surfaces assert). `logo_png` is an
//!   `Option<Vec<u8>>` so an app with no logo — or holger, whose embedded bytes are
//!   an un-pulled git-lfs pointer, not a PNG — is handled without a panic.
//! * [`ToolEntry`] / [`ToolMenu`] — the generic Tools-menu structure +
//!   [`ToolMenu::state_json`]. The app's `ToolEffect` enum + `entries()` builder
//!   FILL these; the effect/handler stays domain-side.
//!
//! Both models are `serde` round-trippable (`Serialize + Deserialize`), so the
//! oracle a CLI dumps can be read back and asserted out of process.

use serde::{Deserialize, Serialize};

/// Functional-status emit bridge (a no-op unless the `testmatrix` feature is on).
pub mod selftest;

/// An app's **identity** — the data an About surface (CLI `<app> about`, or the
/// shared `facett-about` card that lands in a later wave) renders. Pure,
/// serde-round-trippable, **egui-free**.
///
/// Lifted + generalised from modgunn's `about.rs` `struct Identity` (which was
/// already this shape, minus the GUI-only fields korp/holger carried as consts):
/// `subtitle` is korp's `SUBTITLE` / modgunn's `role`; `framework` is holger's
/// `FRAMEWORK[]` ("built on the shared nordisk framework"); `engines` is modgunn's
/// two sharply-split engines; `links` carries any extra `(label, url)` an app wants
/// on the card (nornir's endpoint/workspace, a docs pointer, …).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Identity {
    /// Crate / binary name (korp `"korp"`, modgunn `"modgunn"`).
    pub name: String,
    /// The wordmark / display name (korp `"Korp"`, modgunn `"Móðguðr"`).
    pub display_name: String,
    /// Semantic version — an app passes `env!("CARGO_PKG_VERSION")`. This is the
    /// **UI / crate** version.
    pub version: String,
    /// The connected **server / engine** version — the version of the backend this
    /// UI is talking to, distinct from [`version`](Self::version) (the UI's own).
    /// korp fills it from its cached `Health` reply in THIN mode (korp-server's
    /// version) or the embedded engine crate version in FAT mode. Empty string until
    /// known — the card then skips the `server v…` line. Set via
    /// [`with_server_version`](Self::with_server_version).
    #[serde(default)]
    pub server_version: String,
    /// The primary tagline (the crate description's essence).
    pub tagline: String,
    /// A secondary one-line description (korp's `SUBTITLE`, modgunn's `role`).
    /// Empty string when an app has none.
    pub subtitle: String,
    /// The brand logo PNG bytes (`include_bytes!` of the asset), or `None` when the
    /// app has no logo. **LFS-tolerant:** the bytes may be a git-lfs *pointer* on a
    /// checkout where the blob was not pulled (holger's case) — that is still
    /// `Some(..)` and [`has_logo`](Self::has_logo) is `true`; the *renderer* decodes
    /// best-effort and falls back to the text identity, never a panic.
    #[serde(default)]
    pub logo_png: Option<Vec<u8>>,
    /// The accessible name on the logo image — the AccessKit label a robot asserts
    /// to prove the logo painted in the About window.
    pub logo_alt: String,
    /// SSH repository URL (`ssh://git@git.gunnar.rs:2222/nordisk/<app>.git`).
    pub repository: String,
    /// Extra `(label, url)` links to show on the card (docs, endpoint, …). Empty
    /// when none.
    #[serde(default)]
    pub links: Vec<(String, String)>,
    /// The shared framework components the app CONSUMES ("built on …") — holger's
    /// `FRAMEWORK[]`. Empty when the app names none.
    #[serde(default)]
    pub framework: Vec<String>,
    /// App-specific engine/role rows (modgunn's two engines). Empty when none.
    #[serde(default)]
    pub engines: Vec<String>,
}

impl Identity {
    /// A minimal identity: name, display name, version, and tagline; everything else
    /// empty. An app fills the remaining fields with struct-update syntax
    /// (`Identity { logo_png: Some(..), framework: .., ..Identity::new(..) }`).
    pub fn new(
        name: impl Into<String>,
        display_name: impl Into<String>,
        version: impl Into<String>,
        tagline: impl Into<String>,
    ) -> Self {
        Identity {
            name: name.into(),
            display_name: display_name.into(),
            version: version.into(),
            server_version: String::new(),
            tagline: tagline.into(),
            subtitle: String::new(),
            logo_png: None,
            logo_alt: String::new(),
            repository: String::new(),
            links: Vec::new(),
            framework: Vec::new(),
            engines: Vec::new(),
        }
    }

    /// Builder: set the connected **server / engine** version — the version shown as
    /// a `server v…` line on the About card, a sibling of the UI `v…` line. An empty
    /// value leaves the line off (the default). korp fills this from its cached
    /// `Health` reply (thin) or the embedded engine version (fat).
    pub fn with_server_version(mut self, v: impl Into<String>) -> Self {
        self.server_version = v.into();
        self
    }

    /// `true` when a logo is present with non-empty bytes — even if those bytes are
    /// an un-pulled git-lfs pointer rather than a real PNG (matches holger's
    /// `!LOGO_PNG.is_empty()`; whether the bytes *decode* is the renderer's concern).
    pub fn has_logo(&self) -> bool {
        self.logo_png.as_ref().is_some_and(|b| !b.is_empty())
    }

    /// The About surface's headless **oracle**: the identity as JSON — what a CLI
    /// `<app> about --json` prints and a robot asserts. `open` reports whether the
    /// About window is showing (a GUI passes its toggle; a CLI passes `true`).
    ///
    /// The key set is the union every existing About asserts: `open` · `name` ·
    /// `wordmark` (= `display_name`, holger/korp's key) · `display_name` · `version`
    /// · `server_version` (the connected backend's version, empty when unknown) ·
    /// `tagline` · `subtitle` · `logo_alt` · `has_logo` · `repository` · `links` ·
    /// `framework` · `engines`. Keeping both `wordmark` and `display_name` lets a
    /// korp/holger robot (which reads `wordmark`) and a modgunn robot (which reads
    /// `display_name`) both assert against the same oracle unchanged.
    pub fn state_json(&self, open: bool) -> serde_json::Value {
        serde_json::json!({
            "open": open,
            "name": self.name,
            "wordmark": self.display_name,
            "display_name": self.display_name,
            "version": self.version,
            "server_version": self.server_version,
            "tagline": self.tagline,
            "subtitle": self.subtitle,
            "logo_alt": self.logo_alt,
            "has_logo": self.has_logo(),
            "repository": self.repository,
            "links": self.links,
            "framework": self.framework,
            "engines": self.engines,
        })
    }
}

/// One entry in a 🧰 **Tools** menu — the generic, domain-free shape korp's and
/// modgunn's `tools.rs` both hand-rolled. A human `title` + `hover`, the stable
/// `effect_key` of the app's own `ToolEffect` (the app KEEPS the enum), the
/// `view_target` the resulting data is VIEWed through (korp's `view_tab`, modgunn's
/// `view_verb`), and the container the effect starts, if any.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolEntry {
    /// Menu label (usually with a leading glyph).
    pub title: String,
    /// Hover help — what data this generates/fills and where to view it.
    pub hover: String,
    /// The **stable key** of the app's `ToolEffect` for this entry (korp
    /// `"falkor_demo_graph"`, modgunn `"demo_advisory_feed"`). The app maps this key
    /// back to its domain effect; this model never names the effect's behaviour.
    pub effect_key: String,
    /// Where the resulting data is VIEWed — korp's tab key (`"graph"`), modgunn's
    /// CLI verb (`"warehouse scan"`).
    pub view_target: String,
    /// The infra container this effect starts first, if any (`None` for effects that
    /// land data directly). korp's `needs_container` axis.
    #[serde(default)]
    pub needs_container: Option<String>,
}

impl ToolEntry {
    /// A tool entry with no container (the common case — data lands directly). Add a
    /// container with [`with_container`](Self::with_container).
    pub fn new(
        title: impl Into<String>,
        hover: impl Into<String>,
        effect_key: impl Into<String>,
        view_target: impl Into<String>,
    ) -> Self {
        ToolEntry {
            title: title.into(),
            hover: hover.into(),
            effect_key: effect_key.into(),
            view_target: view_target.into(),
            needs_container: None,
        }
    }

    /// Builder: name the infra container this effect starts first.
    pub fn with_container(mut self, container: impl Into<String>) -> Self {
        self.needs_container = Some(container.into());
        self
    }
}

/// A 🧰 **Tools** menu — an ordered list of [`ToolEntry`]s tagged with the app's
/// `workspace` (the jera job axis). The one source of truth a GUI renders and a
/// robot drives; the app fills it from its own `entries()` builder over its
/// `ToolEffect` enum.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct ToolMenu {
    /// The app's workspace tag (korp `"korp"`, modgunn `"modgunn"`).
    pub workspace: String,
    /// The menu entries, in stable render order.
    pub entries: Vec<ToolEntry>,
}

impl ToolMenu {
    /// A menu for `workspace` with `entries`.
    pub fn new(workspace: impl Into<String>, entries: Vec<ToolEntry>) -> Self {
        ToolMenu { workspace: workspace.into(), entries }
    }

    /// Every entry's `effect_key`, in menu order — the enumeration a robot walks.
    pub fn keys(&self) -> Vec<&str> {
        self.entries.iter().map(|e| e.effect_key.as_str()).collect()
    }

    /// The entry whose `effect_key` matches `key`, if any — how the host resolves a
    /// picked/menu-clicked effect key back to its entry.
    pub fn find(&self, key: &str) -> Option<&ToolEntry> {
        self.entries.iter().find(|e| e.effect_key == key)
    }

    /// The Tools-menu roster as JSON — the headless oracle a robot asserts (each
    /// entry's effect key · the container it needs · the view target), plus the last
    /// effect key the user picked. Same shape korp's + modgunn's `state_json` dumped.
    pub fn state_json(&self, last_picked: Option<&str>) -> serde_json::Value {
        serde_json::json!({
            "workspace": self.workspace,
            "entries": self.entries.iter().map(|e| serde_json::json!({
                "title": e.title,
                "effect": e.effect_key,
                "needs_container": e.needs_container,
                "view_target": e.view_target,
            })).collect::<Vec<_>>(),
            "last_picked": last_picked,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A korp-shaped identity fixture (a small non-PNG logo blob stands in for the
    /// embedded asset — `has_logo` only cares that bytes are present).
    fn korp_identity() -> Identity {
        Identity {
            subtitle: "Surveillance + back office over Spark / FalkorDB / Iceberg / maps.".into(),
            logo_png: Some(b"\x89PNG-fake-korp-logo".to_vec()),
            logo_alt: "Korp logo — Hugin & Munin ravens".into(),
            repository: "ssh://git@git.gunnar.rs:2222/nordisk/korp.git".into(),
            framework: vec!["jera — the shared job manager".into()],
            ..Identity::new(
                "korp",
                "Korp",
                env!("CARGO_PKG_VERSION"),
                "Hugin (thought) watches the live now — Munin (memory) keeps the history.",
            )
        }
    }

    /// **RED-when-broken: the Identity oracle carries the About surface's identity.**
    /// `state_json` must advertise the name, the (non-blank) version, the wordmark,
    /// the logo alt-text + `has_logo`, and the shared framework list — the surface a
    /// `<app> about` robot asserts. If a field dropped out of the oracle, or
    /// `has_logo` lied about the embedded bytes, this is red. Verified RED by
    /// neutralizing (temporarily asserting `js["name"] == "WRONG"` fails the test).
    #[test]
    fn identity_state_json_carries_name_version_framework_and_logo() {
        let id = korp_identity();
        let js = id.state_json(true);

        assert_eq!(js["open"], serde_json::json!(true));
        assert_eq!(js["name"], "korp");
        // wordmark (korp/holger key) and display_name (modgunn key) both present.
        assert_eq!(js["wordmark"], "Korp");
        assert_eq!(js["display_name"], "Korp");
        // The version is stamped and never blank.
        assert_eq!(js["version"], env!("CARGO_PKG_VERSION"));
        assert!(!id.version.is_empty(), "the About presents a real version");
        // The logo is observable: alt-text + has_logo (the fake bytes are non-empty).
        assert_eq!(js["logo_alt"], "Korp logo — Hugin & Munin ravens");
        assert_eq!(js["has_logo"], serde_json::json!(true));
        // The shared framework list is present + non-empty (the reuse story is shown).
        let fw = js["framework"].as_array().expect("framework list");
        assert!(!fw.is_empty(), "About names the shared framework it is built on");

        crate::assert_emit!(
            "nornir-appshell/identity",
            "state_json_carries_name_version_framework_logo",
            js["name"] == "korp"
                && js["version"] == env!("CARGO_PKG_VERSION")
                && js["has_logo"] == serde_json::json!(true)
                && !fw.is_empty(),
            "Identity.state_json: name={} version={} has_logo={} framework_len={}",
            js["name"], js["version"], js["has_logo"], fw.len()
        );
    }

    /// The Identity round-trips through serde unchanged — the oracle a CLI dumps can
    /// be read back into an owned `Identity` out of process (logo `Option<Vec<u8>>`
    /// and all). If a field stopped (de)serializing, this is red.
    #[test]
    fn identity_round_trips_through_serde() {
        let id = korp_identity();
        let s = serde_json::to_string(&id).unwrap();
        let back: Identity = serde_json::from_str(&s).unwrap();
        assert_eq!(id, back, "Identity survives a serde round-trip");
        assert_eq!(back.logo_png, id.logo_png, "the logo Option<Vec<u8>> round-trips");
    }

    /// The logo `Option` is honoured both ways: an app with no logo is `has_logo ==
    /// false` (and the oracle says so), while present-but-not-a-PNG bytes (holger's
    /// un-pulled LFS pointer) still count as `has_logo == true` — the renderer, not
    /// this model, decides whether they decode.
    #[test]
    fn logo_option_handled_none_and_lfs_pointer() {
        // No logo at all.
        let bare = Identity::new("dwarves", "dwarves", "0.0.0", "the forge");
        assert!(!bare.has_logo());
        assert_eq!(bare.state_json(false)["has_logo"], serde_json::json!(false));

        // Present but an LFS pointer (not a real PNG) — still "has a logo".
        let lfs = Identity {
            logo_png: Some(b"version https://git-lfs.github.com/spec/v1\n".to_vec()),
            ..Identity::new("holger", "holger", "0.0.0", "artifact registry")
        };
        assert!(lfs.has_logo(), "an un-pulled LFS pointer is still a present logo");
        assert_eq!(lfs.state_json(false)["has_logo"], serde_json::json!(true));
    }

    fn korp_menu() -> ToolMenu {
        ToolMenu::new(
            "korp",
            vec![
                ToolEntry::new(
                    "🕸 FalkorDB — load demo graph",
                    "Start FalkorDB and fill it with the demo population graph",
                    "falkor_demo_graph",
                    "graph",
                )
                .with_container("falkordb"),
                ToolEntry::new(
                    "🌐 Populate — Sweden open data → Iceberg",
                    "Land the Sweden open-data catalogue into Iceberg",
                    "populate_open_data",
                    "tables",
                ),
            ],
        )
    }

    /// **RED-when-broken: the ToolMenu enumerates its entries and a selected entry
    /// resolves its id in the oracle.** The menu walks its entries in order, `find`
    /// resolves a picked effect key back to the entry (carrying its container +
    /// view target), and `state_json(Some(key))` reports that key as `last_picked`
    /// with every entry. If enumeration dropped an entry, `find` mismatched, or the
    /// last-pick stopped surfacing, this is red. Verified RED by neutralizing
    /// (asserting `last_picked == "nope"` fails).
    #[test]
    fn toolmenu_enumerates_and_resolves_selected_entry() {
        let menu = korp_menu();

        // Enumerate: keys in stable order.
        assert_eq!(menu.keys(), vec!["falkor_demo_graph", "populate_open_data"]);

        // Resolve a selected entry by its key — it carries its domain axes.
        let picked = menu.find("falkor_demo_graph").expect("the FalkorDB entry resolves");
        assert_eq!(picked.needs_container.as_deref(), Some("falkordb"));
        assert_eq!(picked.view_target, "graph");
        assert!(menu.find("no_such_effect").is_none(), "an unknown key resolves to None");

        // The oracle: every entry + the selected key as last_picked.
        let js = menu.state_json(Some("falkor_demo_graph"));
        assert_eq!(js["workspace"], "korp");
        assert_eq!(js["entries"].as_array().unwrap().len(), 2);
        assert_eq!(js["entries"][0]["effect"], "falkor_demo_graph");
        assert_eq!(js["entries"][0]["needs_container"], "falkordb");
        assert_eq!(js["last_picked"], "falkor_demo_graph");
        // With no pick, last_picked is null.
        assert!(menu.state_json(None)["last_picked"].is_null());

        crate::assert_emit!(
            "nornir-appshell/toolmenu",
            "enumerates_and_resolves_selected_entry",
            menu.keys().len() == 2
                && picked.needs_container.as_deref() == Some("falkordb")
                && js["last_picked"] == "falkor_demo_graph",
            "ToolMenu: keys={:?} last_picked={}",
            menu.keys(), js["last_picked"]
        );
    }

    /// The ToolMenu round-trips through serde unchanged (the whole menu, including
    /// each entry's `Option<String>` container).
    #[test]
    fn toolmenu_round_trips_through_serde() {
        let menu = korp_menu();
        let s = serde_json::to_string(&menu).unwrap();
        let back: ToolMenu = serde_json::from_str(&s).unwrap();
        assert_eq!(menu, back);
    }
}

// ─── inject-and-assert wiring test (feature ON only) ─────────────────────────
#[cfg(all(test, feature = "testmatrix"))]
mod wiring {
    use super::*;

    /// Exercise `nornir-appshell`'s real model surfaces, emit an honest row per
    /// surface, then drain the process-global functional buffer and assert the rows
    /// landed — proving the emit bridge is wired end to end. Includes a deliberately
    /// RED expectation observed as `false`, proving the emitter records honest
    /// failures (verified by neutralizing).
    #[test]
    fn emits_honest_rows_for_appshell_surfaces() {
        let id = Identity {
            logo_png: Some(b"\x89PNG-fake".to_vec()),
            framework: vec!["jera".into()],
            ..Identity::new("korp", "Korp", "1.2.3", "tagline")
        };
        let js = id.state_json(true);
        assert_emit!(
            "nornir-appshell",
            "identity_oracle_name_version_logo",
            js["name"] == "korp" && js["version"] == "1.2.3" && js["has_logo"] == serde_json::json!(true),
            "name={} version={} has_logo={}",
            js["name"], js["version"], js["has_logo"]
        );

        let menu = ToolMenu::new(
            "korp",
            vec![ToolEntry::new("t", "h", "eff", "graph").with_container("falkordb")],
        );
        let mjs = menu.state_json(Some("eff"));
        assert_emit!(
            "nornir-appshell",
            "toolmenu_oracle_entries_and_pick",
            menu.keys() == vec!["eff"] && mjs["last_picked"] == "eff",
            "keys={:?} last_picked={}",
            menu.keys(), mjs["last_picked"]
        );

        // RED surface: a WRONG expectation must be observed as false, proving the
        // emitter records honest failures (verified by neutralizing the check).
        let wrong = id.has_logo() && js["has_logo"] == serde_json::json!(false);
        crate::selftest::emit(
            "nornir-appshell",
            "has_logo_not_inverted",
            !wrong,
            &format!("has_logo={}", js["has_logo"]),
        );

        let rows = nornir_testmatrix::drain_functional_rows();
        let has = |t: &str, pass: bool| {
            rows.iter().any(|r| {
                r.suite == "nornir-appshell" && r.test_name == t && (r.status == "pass") == pass
            })
        };
        assert!(has("identity_oracle_name_version_logo", true), "rows={rows:?}");
        assert!(has("toolmenu_oracle_entries_and_pick", true), "rows={rows:?}");
        assert!(has("has_logo_not_inverted", true), "rows={rows:?}");
        assert!(rows.iter().all(|r| r.aspect == "functional"), "rows={rows:?}");
    }
}