Skip to main content

facett_core/
interface.rs

1//! **The APP SHAPE** — `App` / `Interface` / `Infrastructure`, the one runtime shape every
2//! workspace_nordisk suite app wears.
3//!
4//! Designed 2026-08-02 with Rickard; the full argument is in
5//! `workspace_nordisk/.nornir/app-shape-design-2026-08-02.md`, and its build-time sibling
6//! (four atoms per suite member) is `.nornir/SUITE-MODEL.md`.
7//!
8//! # The bug this exists to make impossible
9//!
10//! An app stores a CHOICE and then never lets that choice decide what is drawn under it.
11//! The selection is written to a field and read back only where it is *displayed* —
12//! nothing downstream comes FROM it. So a pane offers controls that do not apply to what
13//! is selected, and hides ones that do.
14//!
15//! Rickard, having hit it in two unrelated panes of one binary on one day: *"the korp map
16//! 2d/3d choice should change settings below … do we see a pattern? infrastructure should
17//! show correct choices/ui"*. Both instances were real, and the second was measured: korp's
18//! `chosen_infra` had exactly ONE behavioural use in the entire UI — a filter hiding two
19//! tabs — while **37 of 56 production `draw_*` fns named a backend directly**. Choosing an
20//! embedded backend hid two tabs and changed nothing else.
21//!
22//! **A control that is offered is a promise that it applies.** 3-D lighting settings under
23//! a 2-D map is not a missing feature; it is the UI asserting something false about its own
24//! state. Same failure as two `enabled` flags that disagreed in one Settings pane, and as a
25//! state file reporting a selected region while the select box rendered empty: **two pieces
26//! of code work out the same answer separately, somebody has to remember to change both,
27//! and eventually nobody does.** The cure is not a philosophy — delete the second copy.
28//!
29//! # The shape
30//!
31//! ```ignore
32//! trait App { fn infrastructure(&self) -> &dyn Infrastructure; fn interface(&mut self) -> &mut dyn Interface; }
33//! ```
34//!
35//! Two halves, and that is the whole top level. [`Interface`] is **recursive**: a tab and a view
36//! mode are the same kind of node at different depths, so the controls under a choice ARE
37//! [`Interface::selected`] rather than being looked up. [`Infra`] hands out faces; a face
38//! returning `None` is *why* a tab does not exist, which is what replaces a
39//! `is_spark_backend()`-style predicate with a capability answering for itself.
40//!
41//! # Why `Interface` is a supertrait of [`Facet`](crate::Facet), not a new pane contract
42//!
43//! `Facet` already carries `title` / `ui` / `state_json` / `update_json` / `severity` /
44//! `component`. Redeclaring a `render` here would be a second way to draw a pane — the
45//! twin this whole module exists to prevent (LAW 5). So `Interface: Facet` adds exactly three
46//! methods and inherits the rest.
47//!
48//! All three are **defaulted**, so `impl Interface for MyPane {}` is a legal one-liner meaning
49//! "a leaf: I have no sub-choices". Every addition to `Facet` this year has been defaulted
50//! for the same reason and that precedent holds here.
51//!
52//! There is deliberately **no blanket `impl<T: Facet> Interface for T`**: it would make every
53//! facet a leaf *and forbid any pane from overriding*, which is precisely the interesting
54//! case. Opting in is one line and it is explicit.
55
56use serde::{Deserialize, Serialize};
57
58/// A stable machine key for one option — a tab id, a view mode, a layer. Not a label:
59/// this is what `select` takes, what `state_json` reports, and what a robot driver
60/// addresses, so it must survive a rename of the human-facing title.
61pub type Key = String;
62
63/// **What kind of thing a [`Control`] is** — enough for any renderer to draw it, and
64/// nothing about how.
65#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
66pub enum ControlKind {
67    /// A free-text field (a URL, a path, a connection string).
68    Text {
69        /// Placeholder / help text shown when empty.
70        hint: String,
71    },
72    /// An on/off switch.
73    Toggle,
74    /// One-of-many, from a fixed list of [`Key`]s.
75    Choice {
76        /// The selectable options, in display order.
77        options: Vec<Key>,
78    },
79    /// A number with an inclusive range.
80    Number {
81        /// Smallest accepted value.
82        min: f64,
83        /// Largest accepted value.
84        max: f64,
85    },
86    /// A read-only fact — a measured count, a status line. Never editable.
87    Readout,
88}
89
90/// **One control, as DATA** — never a widget.
91///
92/// This is what lets an egui pane, a TUI, a robot driver and a web client render the same
93/// set from one description. It is also what makes the guard possible: two choices can be
94/// compared by their control ids, which is impossible if `controls()` were a pile of
95/// immediate-mode calls.
96///
97/// The `id` is the stable address (see [`Key`]); `label` is human-facing and may change
98/// freely.
99#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
100pub struct Control {
101    /// Stable machine id — what a driver addresses and what a guard compares.
102    pub id: Key,
103    /// Human-facing text. Free to change without breaking anything.
104    pub label: String,
105    /// What kind of control this is.
106    pub kind: ControlKind,
107    /// The current value, rendered as a string (`"true"`, `"12"`, a URL). `None` when the
108    /// control has no value yet — which is DIFFERENT from an empty string, and must render
109    /// differently, for the same reason empty-vs-unreadable must.
110    pub value: Option<String>,
111}
112
113impl Control {
114    /// A read-only fact.
115    pub fn readout(id: impl Into<Key>, label: impl Into<String>, value: impl Into<String>) -> Control {
116        Control {
117            id: id.into(),
118            label: label.into(),
119            kind: ControlKind::Readout,
120            value: Some(value.into()),
121        }
122    }
123
124    /// An on/off switch.
125    pub fn toggle(id: impl Into<Key>, label: impl Into<String>, on: bool) -> Control {
126        Control {
127            id: id.into(),
128            label: label.into(),
129            kind: ControlKind::Toggle,
130            value: Some(on.to_string()),
131        }
132    }
133
134    /// A free-text field.
135    pub fn text(id: impl Into<Key>, label: impl Into<String>, hint: impl Into<String>, value: &str) -> Control {
136        Control {
137            id: id.into(),
138            label: label.into(),
139            kind: ControlKind::Text { hint: hint.into() },
140            value: (!value.is_empty()).then(|| value.to_string()),
141        }
142    }
143}
144
145/// **A node in the app's ui tree** — recursive, so a tab and a view mode are the same kind
146/// of thing at different depths.
147///
148/// ```ignore
149/// app.ui().select("map");
150/// app.ui().selected()?.select("3d");     // the SAME call, one level down
151/// ```
152///
153/// The controls under a choice are not looked up: they **are** [`selected`](Interface::selected).
154/// Pick `3d` and you get the 3d node; its `Facet::ui` draws 3d's controls, and nothing else
155/// can appear because nothing else was returned.
156///
157/// A leaf overrides nothing — `impl Interface for MyPane {}` is complete and means "no
158/// sub-choices". See the module docs for why there is no blanket impl.
159pub trait Interface: crate::Facet {
160    /// The choices available AT THIS LEVEL, in display order — tabs at the top, view modes
161    /// one level down.
162    ///
163    /// This must be the **only** list. korp's tab strip, its state oracle and its own test
164    /// each carried a separate copy — 17, 16 and 9 entries — and the test compared its copy
165    /// to itself, so it was green forever while the first tab was unreachable. One list
166    /// cannot disagree with itself.
167    fn options(&self) -> Vec<Key> {
168        Vec::new()
169    }
170
171    /// Choose one of [`options`](Interface::options). Returns `false` for a key that is not on
172    /// offer — a caller that ignores the result is asking for the same silent-no-op class
173    /// this module exists to kill.
174    fn select(&mut self, _key: &str) -> bool {
175        false
176    }
177
178    /// The currently-selected child, or `None` for a leaf.
179    ///
180    /// This is the load-bearing method. Rendering the selected child is what makes the
181    /// dependent ui change when the choice changes, with nothing to keep in sync.
182    fn selected(&mut self) -> Option<&mut dyn Interface> {
183        None
184    }
185
186    /// The controls this node offers — as DATA, so every renderer draws the same set and a
187    /// guard can compare two choices.
188    fn controls(&self) -> Vec<Control> {
189        Vec::new()
190    }
191}
192
193/// **One backend capability.** A face exists only if swapping [`Infra`] swaps the
194/// implementation.
195///
196/// That rule caught two near-misses on the day it was written, both by measuring rather
197/// than by taste: FalkorDB is a URL passed around under EVERY infra, and object storage is
198/// the same embedded store under every infra. Neither is a choice, so neither is a face.
199pub trait Face {
200    /// A short human name for the implementation actually in use (`"Iceberg"`,
201    /// `"Postgres"`) — for an honest status line, never for a branch.
202    fn label(&self) -> &str;
203
204    /// The controls this face offers, as DATA. Postgres vends a connection string;
205    /// Iceberg vends a warehouse path. This is why the settings BELOW an infra choice
206    /// change when the choice does.
207    fn controls(&self) -> Vec<Control> {
208        Vec::new()
209    }
210}
211
212/// **The swappable half of an app** — hands out [`Face`]s, and is the only thing that
213/// knows which backend answered.
214///
215/// A `None` face is not a degraded state to apologise for: it is the REASON a surface does
216/// not exist. That is what replaces a `is_spark_backend()`-shaped predicate scattered
217/// across panes — the capability answers for itself, and there is nothing to keep in sync.
218///
219/// The concrete face traits (a relational/catalog face, a graph face) live with the app
220/// that defines the operations, because `search` and `cypher` are domain calls, not
221/// framework ones. What is shared is the SHAPE: ask, get `Option<&dyn Face>`, and never
222/// name the implementation.
223pub trait Infrastructure {
224    /// Which infra this is, for an honest status line (`"skade (embedded)"`).
225    fn label(&self) -> &str;
226
227    /// Every face this infra offers, by name — the generic surface a settings pane, a
228    /// robot driver or a status line can enumerate without knowing the app's domain.
229    ///
230    /// Apps add typed accessors (`fn relation(&self) -> Option<&dyn RelationFace>`) on
231    /// their own trait; this is the untyped roster, so a pane can render "what does this
232    /// backend actually offer" without a match.
233    fn faces(&self) -> Vec<(&'static str, &dyn Face)> {
234        Vec::new()
235    }
236}
237
238/// **An app**: a ui tree and a swappable infra. The whole top level.
239///
240/// Named per suite member in the app's own crate (`KorpApp`, `NornirApp`), because the
241/// name is the app's; this is the shape they share. A test or a robot driver holds
242/// `&mut dyn App` instead of a struct private to a `main.rs` — which is not theoretical:
243/// korp's `struct Korp` being bin-private meant 29 integration test files each redeclared
244/// their own copy and drove THAT, with one reference to the real shell in the whole
245/// directory.
246pub trait App {
247    /// The swappable half.
248    fn infrastructure(&self) -> &dyn Infrastructure;
249    /// The ui tree's root.
250    fn interface(&mut self) -> &mut dyn Interface;
251}
252
253/// **Walk to the deepest selected node** — the leaf the user is actually looking at.
254///
255/// The path a robot driver reports and a bug report should name: `map → 3d → buildings`.
256pub fn selected_path(root: &mut dyn Interface) -> Vec<Key> {
257    let mut path = Vec::new();
258    let mut node: &mut dyn Interface = root;
259    loop {
260        // `options` is the level's own list; the SELECTED key is whatever the child is.
261        // Ask the child for its title only after we know there is one.
262        let has_child = node.selected().is_some();
263        if !has_child {
264            return path;
265        }
266        node = node.selected().expect("checked");
267        path.push(crate::Facet::title(node).to_string());
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    /// A two-level node: options `a`/`b`, each child a leaf with its OWN control.
276    struct Node {
277        title: String,
278        chosen: usize,
279        kids: Vec<Node>,
280    }
281
282    impl crate::Facet for Node {
283        fn title(&self) -> &str {
284            &self.title
285        }
286        fn ui(&mut self, _ui: &mut egui::Ui) {}
287        fn state_json(&self) -> serde_json::Value {
288            serde_json::json!({ "title": self.title })
289        }
290    }
291
292    impl Interface for Node {
293        fn options(&self) -> Vec<Key> {
294            self.kids.iter().map(|k| k.title.clone()).collect()
295        }
296        fn select(&mut self, key: &str) -> bool {
297            match self.kids.iter().position(|k| k.title == key) {
298                Some(i) => {
299                    self.chosen = i;
300                    true
301                }
302                None => false,
303            }
304        }
305        fn selected(&mut self) -> Option<&mut dyn Interface> {
306            self.kids.get_mut(self.chosen).map(|k| k as &mut dyn Interface)
307        }
308        fn controls(&self) -> Vec<Control> {
309            // Deliberately DIFFERENT per node, which is the property the guard checks.
310            vec![Control::readout(format!("{}_readout", self.title), &self.title, &self.title)]
311        }
312    }
313
314    fn tree() -> Node {
315        Node {
316            title: "root".into(),
317            chosen: 0,
318            kids: vec![
319                Node { title: "2d".into(), chosen: 0, kids: Vec::new() },
320                Node { title: "3d".into(), chosen: 0, kids: Vec::new() },
321            ],
322        }
323    }
324
325    /// **THE GUARD THIS MODULE EXISTS FOR: a choice must change the ui.**
326    ///
327    /// Snapshot the control ids for each option and require them to differ pairwise. This
328    /// is the assertion no suite app had on 2026-08-02, and its absence is why korp could
329    /// ship a 2D/3D switch that left the settings beneath it untouched, and an infra
330    /// chooser that hid two tabs and changed nothing else.
331    ///
332    /// A `select` that silently does nothing, or a `selected` that returns the same node
333    /// for every key, both fail here.
334    #[test]
335    fn every_choice_yields_a_different_ui() {
336        let mut root = tree();
337        let opts = root.options();
338        assert!(opts.len() >= 2, "need two options to compare, got {opts:?}");
339
340        let mut seen: Vec<(Key, Vec<Key>)> = Vec::new();
341        for key in &opts {
342            assert!(root.select(key), "select({key}) refused a key its own options() offered");
343            let child = root.selected().expect("a selected option must yield a node");
344            let ids: Vec<Key> = child.controls().into_iter().map(|c| c.id).collect();
345            assert!(!ids.is_empty(), "option {key} yielded a node with NO controls");
346            seen.push((key.clone(), ids));
347        }
348
349        for (i, (ka, a)) in seen.iter().enumerate() {
350            for (kb, b) in seen.iter().skip(i + 1) {
351                assert_ne!(
352                    a, b,
353                    "options {ka:?} and {kb:?} render the SAME controls — the choice \
354                     changed nothing, which is the bug this guard exists to catch"
355                );
356            }
357        }
358    }
359
360    /// `select` must REFUSE a key that is not on offer, rather than silently doing
361    /// nothing — a caller cannot tell those apart, and a driver that ignores the result
362    /// would report success for a click that never happened.
363    #[test]
364    fn selecting_an_unoffered_key_is_refused_not_ignored() {
365        let mut root = tree();
366        assert!(!root.select("no-such-mode"), "an unknown key must return false");
367        // And the selection is unchanged — a refused select must not move anything.
368        assert_eq!(crate::Facet::title(root.selected().expect("still selected")), "2d");
369    }
370
371    /// A leaf is a legal `Interface` with no overrides at all — the additive property that lets
372    /// existing panes adopt this one line at a time.
373    #[test]
374    fn a_leaf_needs_no_overrides_and_reports_no_choices() {
375        struct Leaf;
376        impl crate::Facet for Leaf {
377            fn title(&self) -> &str {
378                "leaf"
379            }
380            fn ui(&mut self, _ui: &mut egui::Ui) {}
381            fn state_json(&self) -> serde_json::Value {
382                serde_json::Value::Null
383            }
384        }
385        impl Interface for Leaf {}
386
387        let mut leaf = Leaf;
388        assert!(leaf.options().is_empty());
389        assert!(leaf.selected().is_none());
390        assert!(!leaf.select("anything"));
391        assert!(leaf.controls().is_empty());
392    }
393
394    /// The path a bug report should name — `map → 3d`, not "somewhere in the map tab".
395    #[test]
396    fn the_selected_path_names_the_leaf_the_user_is_looking_at() {
397        let mut root = tree();
398        assert!(root.select("3d"));
399        assert_eq!(selected_path(&mut root), vec!["3d".to_string()]);
400        assert!(root.select("2d"));
401        assert_eq!(selected_path(&mut root), vec!["2d".to_string()]);
402    }
403
404    /// A `Control` distinguishes "no value yet" from "empty value" — the same
405    /// empty-vs-unreadable distinction that has cost this codebase repeatedly.
406    #[test]
407    fn a_control_with_no_value_is_not_a_control_with_an_empty_one() {
408        let unset = Control::text("url", "URL", "https://…", "");
409        let set = Control::text("url", "URL", "https://…", "http://localhost:9000");
410        assert_eq!(unset.value, None, "an empty string means NO value, not a blank one");
411        assert_eq!(set.value.as_deref(), Some("http://localhost:9000"));
412    }
413}