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}