facett-core/src/interface.rs
//! **The APP SHAPE** — `App` / `Interface` / `Infrastructure`, the one runtime shape every
//! workspace_nordisk suite app wears.
//!
//! Designed 2026-08-02 with the owner; the full argument is in
//! `workspace_nordisk/.nornir/app-shape-design-2026-08-02.md`, and its build-time sibling
//! (four atoms per suite member) is `.nornir/SUITE-MODEL.md`.
//!
//! # The bug this exists to make impossible
//!
//! An app stores a CHOICE and then never lets that choice decide what is drawn under it.
//! The selection is written to a field and read back only where it is *displayed* —
//! nothing downstream comes FROM it. So a pane offers controls that do not apply to what
//! is selected, and hides ones that do.
//!
//! The owner, having hit it in two unrelated panes of one binary on one day: *"the korp map
//! 2d/3d choice should change settings below … do we see a pattern? infrastructure should
//! show correct choices/ui"*. Both instances were real, and the second was measured: korp's
//! `chosen_infra` had exactly ONE behavioural use in the entire UI — a filter hiding two
//! tabs — while **37 of 56 production `draw_*` fns named a backend directly**. Choosing an
//! embedded backend hid two tabs and changed nothing else.
//!
//! **A control that is offered is a promise that it applies.** 3-D lighting settings under
//! a 2-D map is not a missing feature; it is the UI asserting something false about its own
//! state. Same failure as two `enabled` flags that disagreed in one Settings pane, and as a
//! state file reporting a selected region while the select box rendered empty: **two pieces
//! of code work out the same answer separately, somebody has to remember to change both,
//! and eventually nobody does.** The cure is not a philosophy — delete the second copy.
//!
//! # The shape
//!
//! ```ignore
//! trait App { fn infrastructure(&self) -> &dyn Infrastructure; fn interface(&mut self) -> &mut dyn Interface; }
//! ```
//!
//! Two halves, and that is the whole top level. [`Interface`] is **recursive**: a tab and a view
//! mode are the same kind of node at different depths, so the controls under a choice ARE
//! [`Interface::selected`] rather than being looked up. [`Infra`] hands out faces; a face
//! returning `None` is *why* a tab does not exist, which is what replaces a
//! `is_spark_backend()`-style predicate with a capability answering for itself.
//!
//! # Why `Interface` is a supertrait of [`Facet`](crate::Facet), not a new pane contract
//!
//! `Facet` already carries `title` / `ui` / `state_json` / `update_json` / `severity` /
//! `component`. Redeclaring a `render` here would be a second way to draw a pane — the
//! twin this whole module exists to prevent (LAW 5). So `Interface: Facet` adds exactly three
//! methods and inherits the rest.
//!
//! All three are **defaulted**, so `impl Interface for MyPane {}` is a legal one-liner meaning
//! "a leaf: I have no sub-choices". Every addition to `Facet` this year has been defaulted
//! for the same reason and that precedent holds here.
//!
//! There is deliberately **no blanket `impl<T: Facet> Interface for T`**: it would make every
//! facet a leaf *and forbid any pane from overriding*, which is precisely the interesting
//! case. Opting in is one line and it is explicit.
use serde::{Deserialize, Serialize};
/// A stable machine key for one option — a tab id, a view mode, a layer. Not a label:
/// this is what `select` takes, what `state_json` reports, and what a robot driver
/// addresses, so it must survive a rename of the human-facing title.
pub type Key = String;
/// **What kind of thing a [`Control`] is** — enough for any renderer to draw it, and
/// nothing about how.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum ControlKind {
/// A free-text field (a URL, a path, a connection string).
Text {
/// Placeholder / help text shown when empty.
hint: String,
},
/// An on/off switch.
Toggle,
/// One-of-many, from a fixed list of [`Key`]s.
Choice {
/// The selectable options, in display order.
options: Vec<Key>,
},
/// A number with an inclusive range.
Number {
/// Smallest accepted value.
min: f64,
/// Largest accepted value.
max: f64,
},
/// A read-only fact — a measured count, a status line. Never editable.
Readout,
}
/// **One control, as DATA** — never a widget.
///
/// This is what lets an egui pane, a TUI, a robot driver and a web client render the same
/// set from one description. It is also what makes the guard possible: two choices can be
/// compared by their control ids, which is impossible if `controls()` were a pile of
/// immediate-mode calls.
///
/// The `id` is the stable address (see [`Key`]); `label` is human-facing and may change
/// freely.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Control {
/// Stable machine id — what a driver addresses and what a guard compares.
pub id: Key,
/// Human-facing text. Free to change without breaking anything.
pub label: String,
/// What kind of control this is.
pub kind: ControlKind,
/// The current value, rendered as a string (`"true"`, `"12"`, a URL). `None` when the
/// control has no value yet — which is DIFFERENT from an empty string, and must render
/// differently, for the same reason empty-vs-unreadable must.
pub value: Option<String>,
}
impl Control {
/// A read-only fact.
pub fn readout(id: impl Into<Key>, label: impl Into<String>, value: impl Into<String>) -> Control {
Control {
id: id.into(),
label: label.into(),
kind: ControlKind::Readout,
value: Some(value.into()),
}
}
/// An on/off switch.
pub fn toggle(id: impl Into<Key>, label: impl Into<String>, on: bool) -> Control {
Control {
id: id.into(),
label: label.into(),
kind: ControlKind::Toggle,
value: Some(on.to_string()),
}
}
/// A free-text field.
pub fn text(id: impl Into<Key>, label: impl Into<String>, hint: impl Into<String>, value: &str) -> Control {
Control {
id: id.into(),
label: label.into(),
kind: ControlKind::Text { hint: hint.into() },
value: (!value.is_empty()).then(|| value.to_string()),
}
}
}
/// **A node in the app's ui tree** — recursive, so a tab and a view mode are the same kind
/// of thing at different depths.
///
/// ```ignore
/// app.ui().select("map");
/// app.ui().selected()?.select("3d"); // the SAME call, one level down
/// ```
///
/// The controls under a choice are not looked up: they **are** [`selected`](Interface::selected).
/// Pick `3d` and you get the 3d node; its `Facet::ui` draws 3d's controls, and nothing else
/// can appear because nothing else was returned.
///
/// A leaf overrides nothing — `impl Interface for MyPane {}` is complete and means "no
/// sub-choices". See the module docs for why there is no blanket impl.
pub trait Interface: crate::Facet {
/// The choices available AT THIS LEVEL, in display order — tabs at the top, view modes
/// one level down.
///
/// This must be the **only** list. korp's tab strip, its state oracle and its own test
/// each carried a separate copy — 17, 16 and 9 entries — and the test compared its copy
/// to itself, so it was green forever while the first tab was unreachable. One list
/// cannot disagree with itself.
fn options(&self) -> Vec<Key> {
Vec::new()
}
/// Choose one of [`options`](Interface::options). Returns `false` for a key that is not on
/// offer — a caller that ignores the result is asking for the same silent-no-op class
/// this module exists to kill.
fn select(&mut self, _key: &str) -> bool {
false
}
/// The currently-selected child, or `None` for a leaf.
///
/// This is the load-bearing method. Rendering the selected child is what makes the
/// dependent ui change when the choice changes, with nothing to keep in sync.
fn selected(&mut self) -> Option<&mut dyn Interface> {
None
}
/// The controls this node offers — as DATA, so every renderer draws the same set and a
/// guard can compare two choices.
fn controls(&self) -> Vec<Control> {
Vec::new()
}
}
/// **One backend capability.** A face exists only if swapping [`Infra`] swaps the
/// implementation.
///
/// That rule caught two near-misses on the day it was written, both by measuring rather
/// than by taste: FalkorDB is a URL passed around under EVERY infra, and object storage is
/// the same embedded store under every infra. Neither is a choice, so neither is a face.
pub trait Face {
/// A short human name for the implementation actually in use (`"Iceberg"`,
/// `"Postgres"`) — for an honest status line, never for a branch.
fn label(&self) -> &str;
/// The controls this face offers, as DATA. Postgres vends a connection string;
/// Iceberg vends a warehouse path. This is why the settings BELOW an infra choice
/// change when the choice does.
fn controls(&self) -> Vec<Control> {
Vec::new()
}
}
/// **The swappable half of an app** — hands out [`Face`]s, and is the only thing that
/// knows which backend answered.
///
/// A `None` face is not a degraded state to apologise for: it is the REASON a surface does
/// not exist. That is what replaces a `is_spark_backend()`-shaped predicate scattered
/// across panes — the capability answers for itself, and there is nothing to keep in sync.
///
/// The concrete face traits (a relational/catalog face, a graph face) live with the app
/// that defines the operations, because `search` and `cypher` are domain calls, not
/// framework ones. What is shared is the SHAPE: ask, get `Option<&dyn Face>`, and never
/// name the implementation.
pub trait Infrastructure {
/// Which infra this is, for an honest status line (`"skade (embedded)"`).
fn label(&self) -> &str;
/// Every face this infra offers, by name — the generic surface a settings pane, a
/// robot driver or a status line can enumerate without knowing the app's domain.
///
/// Apps add typed accessors (`fn relation(&self) -> Option<&dyn RelationFace>`) on
/// their own trait; this is the untyped roster, so a pane can render "what does this
/// backend actually offer" without a match.
fn faces(&self) -> Vec<(&'static str, &dyn Face)> {
Vec::new()
}
}
/// **An app**: a ui tree and a swappable infra. The whole top level.
///
/// Named per suite member in the app's own crate (`KorpApp`, `NornirApp`), because the
/// name is the app's; this is the shape they share. A test or a robot driver holds
/// `&mut dyn App` instead of a struct private to a `main.rs` — which is not theoretical:
/// korp's `struct Korp` being bin-private meant 29 integration test files each redeclared
/// their own copy and drove THAT, with one reference to the real shell in the whole
/// directory.
pub trait App {
/// The swappable half.
fn infrastructure(&self) -> &dyn Infrastructure;
/// The ui tree's root.
fn interface(&mut self) -> &mut dyn Interface;
}
/// **Walk to the deepest selected node** — the leaf the user is actually looking at.
///
/// The path a robot driver reports and a bug report should name: `map → 3d → buildings`.
pub fn selected_path(root: &mut dyn Interface) -> Vec<Key> {
let mut path = Vec::new();
let mut node: &mut dyn Interface = root;
loop {
// `options` is the level's own list; the SELECTED key is whatever the child is.
// Ask the child for its title only after we know there is one.
let has_child = node.selected().is_some();
if !has_child {
return path;
}
node = node.selected().expect("checked");
path.push(crate::Facet::title(node).to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A two-level node: options `a`/`b`, each child a leaf with its OWN control.
struct Node {
title: String,
chosen: usize,
kids: Vec<Node>,
}
impl crate::Facet for Node {
fn title(&self) -> &str {
&self.title
}
fn ui(&mut self, _ui: &mut egui::Ui) {}
fn state_json(&self) -> serde_json::Value {
serde_json::json!({ "title": self.title })
}
}
impl Interface for Node {
fn options(&self) -> Vec<Key> {
self.kids.iter().map(|k| k.title.clone()).collect()
}
fn select(&mut self, key: &str) -> bool {
match self.kids.iter().position(|k| k.title == key) {
Some(i) => {
self.chosen = i;
true
}
None => false,
}
}
fn selected(&mut self) -> Option<&mut dyn Interface> {
self.kids.get_mut(self.chosen).map(|k| k as &mut dyn Interface)
}
fn controls(&self) -> Vec<Control> {
// Deliberately DIFFERENT per node, which is the property the guard checks.
vec![Control::readout(format!("{}_readout", self.title), &self.title, &self.title)]
}
}
fn tree() -> Node {
Node {
title: "root".into(),
chosen: 0,
kids: vec![
Node { title: "2d".into(), chosen: 0, kids: Vec::new() },
Node { title: "3d".into(), chosen: 0, kids: Vec::new() },
],
}
}
/// **THE GUARD THIS MODULE EXISTS FOR: a choice must change the ui.**
///
/// Snapshot the control ids for each option and require them to differ pairwise. This
/// is the assertion no suite app had on 2026-08-02, and its absence is why korp could
/// ship a 2D/3D switch that left the settings beneath it untouched, and an infra
/// chooser that hid two tabs and changed nothing else.
///
/// A `select` that silently does nothing, or a `selected` that returns the same node
/// for every key, both fail here.
#[test]
fn every_choice_yields_a_different_ui() {
let mut root = tree();
let opts = root.options();
assert!(opts.len() >= 2, "need two options to compare, got {opts:?}");
let mut seen: Vec<(Key, Vec<Key>)> = Vec::new();
for key in &opts {
assert!(root.select(key), "select({key}) refused a key its own options() offered");
let child = root.selected().expect("a selected option must yield a node");
let ids: Vec<Key> = child.controls().into_iter().map(|c| c.id).collect();
assert!(!ids.is_empty(), "option {key} yielded a node with NO controls");
seen.push((key.clone(), ids));
}
for (i, (ka, a)) in seen.iter().enumerate() {
for (kb, b) in seen.iter().skip(i + 1) {
assert_ne!(
a, b,
"options {ka:?} and {kb:?} render the SAME controls — the choice \
changed nothing, which is the bug this guard exists to catch"
);
}
}
}
/// `select` must REFUSE a key that is not on offer, rather than silently doing
/// nothing — a caller cannot tell those apart, and a driver that ignores the result
/// would report success for a click that never happened.
#[test]
fn selecting_an_unoffered_key_is_refused_not_ignored() {
let mut root = tree();
assert!(!root.select("no-such-mode"), "an unknown key must return false");
// And the selection is unchanged — a refused select must not move anything.
assert_eq!(crate::Facet::title(root.selected().expect("still selected")), "2d");
}
/// A leaf is a legal `Interface` with no overrides at all — the additive property that lets
/// existing panes adopt this one line at a time.
#[test]
fn a_leaf_needs_no_overrides_and_reports_no_choices() {
struct Leaf;
impl crate::Facet for Leaf {
fn title(&self) -> &str {
"leaf"
}
fn ui(&mut self, _ui: &mut egui::Ui) {}
fn state_json(&self) -> serde_json::Value {
serde_json::Value::Null
}
}
impl Interface for Leaf {}
let mut leaf = Leaf;
assert!(leaf.options().is_empty());
assert!(leaf.selected().is_none());
assert!(!leaf.select("anything"));
assert!(leaf.controls().is_empty());
}
/// The path a bug report should name — `map → 3d`, not "somewhere in the map tab".
#[test]
fn the_selected_path_names_the_leaf_the_user_is_looking_at() {
let mut root = tree();
assert!(root.select("3d"));
assert_eq!(selected_path(&mut root), vec!["3d".to_string()]);
assert!(root.select("2d"));
assert_eq!(selected_path(&mut root), vec!["2d".to_string()]);
}
/// A `Control` distinguishes "no value yet" from "empty value" — the same
/// empty-vs-unreadable distinction that has cost this codebase repeatedly.
#[test]
fn a_control_with_no_value_is_not_a_control_with_an_empty_one() {
let unset = Control::text("url", "URL", "https://…", "");
let set = Control::text("url", "URL", "https://…", "http://localhost:9000");
assert_eq!(unset.value, None, "an empty string means NO value, not a blank one");
assert_eq!(set.value.as_deref(), Some("http://localhost:9000"));
}
}