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:?}");
}
}