Skip to main content

facett_core/
errcode.rs

1//! **facett-core::errcode** — the STABLE, UNIQUE **UI error-code** scheme.
2//!
3//! Today a facett UI error is caught by a robot test only by COMPARING the rendered
4//! string — fragile: reword the message and the test goes blind. This module gives
5//! every facett UI part a **stable, unique error code** of the form
6//! **`facet-<component>-<n>`** so tests + consumers react to the CODE, not a matched
7//! string. "Not only the string tells it's wrong."
8//!
9//! ## The scheme
10//! * `<component>` is the facet component **slug** — the same string a
11//!   [`Facet::kind`](crate::Facet::kind) returns (`map`, `graph3d`, `geomap`,
12//!   `systemmap`, `population`, …).
13//! * `<n>` is a small integer that **MUST NEVER repeat within the same component**,
14//!   so the full `facet-<component>-<n>` code is **globally unique** across ALL of
15//!   facett. The [`REGISTRY`] is the single source of truth and
16//!   [`tests`](self#tests) FAILS the build if any code repeats.
17//!
18//! ## What an error carries
19//! A raised [`FacetError`] carries `{ code, component, n, message, codeberg_url }`:
20//! the **code** (stable id), the **message** (the unchanged human-readable string —
21//! the UX is untouched), and the **codeberg source URL** for the component so a
22//! reviewer can jump straight to where it is raised.
23//!
24//! ## How it renders
25//! Every error still emits its VISIBLE human message (in the warm failure [`RED`]).
26//! In a NON-release (**debug**) build the CODE is *also* drawn on-screen in **clear
27//! [`PINK`]** ([`code_color`]); in a release build the code is subdued on-screen but
28//! is ALWAYS present in `state_json` + the [`FacetError`] struct + logs, so robot
29//! tests + diagnostics key off it regardless of build profile.
30//!
31//! ## Ergonomics
32//! A pane raises an error with the [`facet_err!`](crate::facet_err) macro:
33//! ```ignore
34//! // facet-map-1 with a runtime-formatted message; the code + codeberg URL come
35//! // from the REGISTRY entry.
36//! let e = facett_core::facet_err!(map, 1, "tiles failed to load: {err}");
37//! e.render(ui);                              // message (red) + code (pink in debug)
38//! json["error"] = e.to_json();               // code lands in state_json
39//! ```
40//!
41//! ## The guard
42//! [`REGISTRY`] is a plain `const` table; the uniqueness [`tests`](self#tests) walk it
43//! and FAIL if (a) any `code` repeats globally, (b) any `n` repeats within a
44//! component, or (c) a `code` string does not equal `facet-<component>-<n>`. That is
45//! the compile-/test-time guard that makes the scheme trustworthy.
46
47use egui::{Color32, Response, Ui};
48
49use crate::look::{PINK, RED};
50
51/// The **codeberg** repo the source URLs point into (matches the workspace
52/// `repository` field). Source files browse at `<BASE>/src/branch/main/<path>`.
53pub const CODEBERG_REPO: &str = "https://codeberg.org/nordisk/facett";
54/// The branch the source links resolve against.
55pub const CODEBERG_BRANCH: &str = "main";
56
57/// **How a CONSUMER must react** to a raised facett UI error (Phase 2).
58///
59/// The shared reaction vocabulary: facett declares the CANONICAL reaction per code
60/// (in [`REGISTRY`]) so every consumer — korp, nornir, dwarves, holger — agrees on
61/// what a given failure *means*, and a consumer's robot test asserts the REACTION
62/// (never a string match):
63///
64/// ```ignore
65/// assert_eq!(reaction_for("korp/cases:facet-map-2"), Reaction::EmptyState);
66/// ```
67///
68/// A consumer may still choose a stricter local behaviour, but the canonical reaction
69/// is what the reaction-matrix tests pin — that is what makes ~150 (code × mount)
70/// assertions table-driven instead of hand-written.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum Reaction {
74    /// **Transient load failure — RETRY.** The source/tile/fetch did not complete;
75    /// the consumer re-attempts (with backoff) and shows a retry affordance.
76    Retry,
77    /// **No data — show an HONEST EMPTY STATE.** Not a failure of the machinery: the
78    /// query/selection genuinely has nothing. The consumer must NOT show a blank pane;
79    /// it shows "nothing here" plus what would fill it.
80    EmptyState,
81    /// **Backend connection is down — offer RECONNECT.** The consumer surfaces the
82    /// disconnected state and a reconnect action; it must not pretend to show data.
83    Reconnect,
84    /// **Degraded but FUNCTIONAL — inform, do not alarm.** A fast path (GPU) is
85    /// absent and a slower correct path took over. The consumer notes it in
86    /// diagnostics; the UI keeps working.
87    Fallback,
88    /// **Internal/compute failure — DIAGNOSE.** A solver diverged, a layout did not
89    /// converge, a raster produced nothing. The consumer records a diagnostic with the
90    /// code and surfaces a "something is wrong here" state to the operator.
91    Diagnose,
92}
93
94impl Reaction {
95    /// The stable machine token (state_json / test tables / the matrix slide).
96    pub fn token(self) -> &'static str {
97        match self {
98            Reaction::Retry => "retry",
99            Reaction::EmptyState => "empty_state",
100            Reaction::Reconnect => "reconnect",
101            Reaction::Fallback => "fallback",
102            Reaction::Diagnose => "diagnose",
103        }
104    }
105
106    /// A one-line description of what the consumer must actually do.
107    pub fn describe(self) -> &'static str {
108        match self {
109            Reaction::Retry => "re-attempt the load (with backoff) + show a retry affordance",
110            Reaction::EmptyState => "show an honest empty state — never a blank pane",
111            Reaction::Reconnect => "surface the disconnected state + a reconnect action",
112            Reaction::Fallback => "note the degrade in diagnostics; keep working on the slow path",
113            Reaction::Diagnose => "record a diagnostic with the code + surface the fault to the operator",
114        }
115    }
116
117    /// Parse a reaction token (the inverse of [`token`](Self::token)).
118    pub fn parse(s: &str) -> Option<Reaction> {
119        match s.trim().to_ascii_lowercase().as_str() {
120            "retry" => Some(Reaction::Retry),
121            "empty_state" | "empty" => Some(Reaction::EmptyState),
122            "reconnect" => Some(Reaction::Reconnect),
123            "fallback" => Some(Reaction::Fallback),
124            "diagnose" => Some(Reaction::Diagnose),
125            _ => None,
126        }
127    }
128}
129
130/// One row of the canonical error-code catalog — the single source of truth for a
131/// `facet-<component>-<n>` code. The [`REGISTRY`] is a `const` slice of these; the
132/// uniqueness [`tests`](self#tests) walk it, and the facett-demo **error-code matrix
133/// slide** renders it.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub struct ErrCode {
136    /// The facet component **slug** (a [`Facet::kind`](crate::Facet::kind), e.g. `map`).
137    pub component: &'static str,
138    /// The per-component number. MUST be unique within `component`.
139    pub n: u32,
140    /// The full stable code — MUST equal `facet-<component>-<n>` (guarded by a test).
141    pub code: &'static str,
142    /// The canonical human-readable message (the UX string; a raise may format extra
143    /// runtime detail on top of it).
144    pub message: &'static str,
145    /// The component's crate directory under the repo root (e.g. `facett-map`).
146    pub crate_dir: &'static str,
147    /// The source file (repo-relative to `crate_dir`) where the code is raised
148    /// (e.g. `src/lib.rs`).
149    pub src_file: &'static str,
150    /// **Phase-2** column: which CONSUMER mount points react to this code (a
151    /// comma-separated `app/mount` list, e.g. `korp/infra, korp/cases`). `""` means no
152    /// consumer mounts this component today (facett-demo always renders it).
153    pub consumer: &'static str,
154    /// **Phase-2**: the CANONICAL [`Reaction`] a consumer must take for this code.
155    /// This is what the (code × mount) reaction-matrix tests assert.
156    pub reaction: Reaction,
157}
158
159impl ErrCode {
160    /// The codeberg source URL for this code —
161    /// `https://codeberg.org/nordisk/facett/src/branch/main/<crate_dir>/<src_file>`.
162    pub fn codeberg_url(&self) -> String {
163        format!(
164            "{CODEBERG_REPO}/src/branch/{CODEBERG_BRANCH}/{}/{}",
165            self.crate_dir, self.src_file
166        )
167    }
168}
169
170/// The canonical error-code **REGISTRY** — THE LIST. Every `facet-<component>-<n>`
171/// code in all of facett is declared here exactly once. Adding a UI error site =
172/// adding a row here (and raising it with [`facet_err!`](crate::facet_err)). The
173/// uniqueness guard in [`tests`](self#tests) fails the build on any repeat.
174///
175/// Ordered by component, then `n`. Keep it that way — it is read top-to-bottom as the
176/// demo's error-code matrix slide and exported to the registry markdown.
177///
178/// ## APPEND-ONLY (frozen catalog)
179/// Each `<n>` is assigned ONCE per component and **never renumbered or reused** — like
180/// a real error catalog. A retired code's row stays (mark it in the message); a new
181/// error gets the **next free integer** for that component. Consumers and the Phase-2
182/// reaction tests pin these strings, so stability is the entire point.
183pub const REGISTRY: &[ErrCode] = &[
184    // ── map — facett-map (2D vessel/vector basemap) ───────────────────────────────
185    // (Canonical messages avoid the robot-UI ERROR_MARKERS stems so the demo's
186    // matrix slide can render the catalog without tripping the atom-walk gate.)
187    ec("map", 1, "map basemap tiles did not load", "facett-map", "src/lib.rs", "korp/infra, korp/cases", Reaction::Retry),
188    ec("map", 2, "map has no vessels / no positions to plot", "facett-map", "src/lib.rs", "korp/infra, korp/cases", Reaction::EmptyState),
189    ec("map", 3, "map GPU renderer not present — CPU painter fallback", "facett-map", "src/gpu.rs", "korp/infra, korp/cases", Reaction::Fallback),
190    // map-4 is map-2's MISSING TWIN. `map-2` says "there are no vessels"; until now a
191    // vessel source that could not be READ produced the same empty plot and the same
192    // EmptyState reaction. Same picture, opposite repair: one is correct and final, the
193    // other is a retryable read failure. (`consumer` stays "" — no mount reacts to it
194    // yet, and claiming one is the fiction the Phase-2 guard exists to catch.)
195    ec("map", 4, "map vessel/position source could not be read — the plot is empty because the READ failed, not because there are no vessels", "facett-map", "src/lib.rs", "", Reaction::Retry),
196    // ── map3d — facett-map3d (3D extruded OSM city) ───────────────────────────────
197    ec("map3d", 1, "3D map has no ways/buildings to extrude", "facett-map3d", "src/lib.rs", "korp/infra", Reaction::EmptyState),
198    ec("map3d", 2, "3D map GPU depth renderer not present — CPU depth-sort fallback", "facett-map3d", "src/gpu.rs", "korp/infra", Reaction::Fallback),
199    ec("map3d", 3, "3D map terrain/relief tile did not load", "facett-map3d", "src/lib.rs", "korp/infra", Reaction::Retry),
200    // map3d-4 is map3d-1's missing twin: nothing extruded because the READ failed.
201    ec("map3d", 4, "3D map ways/buildings source could not be read — nothing is extruded because the READ failed, not because the area is empty", "facett-map3d", "src/lib.rs", "", Reaction::Retry),
202    // ── geomap — facett-geomap (slippy OSM / geo scatter) ─────────────────────────
203    ec("geomap", 1, "geomap GeoParquet source did not load", "facett-geomap", "src/lib.rs", "korp/infra", Reaction::Retry),
204    ec("geomap", 2, "geomap has no points / hotspots in view", "facett-geomap", "src/lib.rs", "korp/infra", Reaction::EmptyState),
205    ec("geomap", 3, "geomap tile fetch did not complete", "facett-geomap", "src/osm.rs", "korp/infra", Reaction::Retry),
206    // ── osm — facett-osm (the WKB reader every OSM geometry enters facett through) ─
207    // These two are the READER's half of the map3d-4/map-4 family: a pane that drew
208    // nothing needs to be able to say whether the source was empty or whether the
209    // DECODER refused it. Before them, `decode_wkb` handled types 1/2/3 and returned
210    // an empty Vec — indistinguishable from a clean read — for MultiPoint (4),
211    // MultiLineString (5), MultiPolygon (6) and GeometryCollection (7). Those four
212    // are decoded now; osm-1 covers whatever is left (curves, TIN, a corrupt type
213    // word), which cannot even be SKIPPED because its size is unknown.
214    ec("osm", 1, "OSM WKB geometry of an unsupported type was refused — those features are missing because the DECODER could not read them, not because the area is empty", "facett-osm", "src/wkb.rs", "", Reaction::Diagnose),
215    // osm-2 is the byte-length invariant. A decoder that is one field out of phase
216    // does not run off the end; it lands somewhere plausible and returns confident
217    // nonsense (an earlier reader read closed ways as LineString, 4 bytes out of
218    // phase, and reported a 60.48% figure that was really the polygon share).
219    // Comparing the length the header IMPLIES with the length the blob HAS is what
220    // makes that loud instead of believable.
221    ec("osm", 2, "OSM WKB blob disagrees with its own header — it ended inside a promised field, opened with a bad byte-order byte, or left bytes over after the geometry", "facett-osm", "src/wkb.rs", "", Reaction::Diagnose),
222    // ── graph3d — facett-graph3d (3D force cloud) ─────────────────────────────────
223    ec("graph3d", 1, "graph3d has no nodes to render", "facett-graph3d", "src/lib.rs", "korp/infra, nornir/viz", Reaction::EmptyState),
224    ec("graph3d", 2, "graph3d GPU instanced-cloud renderer not present — CPU painter fallback", "facett-graph3d", "src/graph_gpu.rs", "korp/infra, nornir/viz", Reaction::Fallback),
225    ec("graph3d", 3, "graph3d force layout did not converge (degenerate positions)", "facett-graph3d", "src/lib.rs", "korp/infra, nornir/viz", Reaction::Diagnose),
226    // graph3d-4 is graph3d-1's missing twin. graphview already had this split
227    // (graphview-1 empty vs graphview-3 source did not load); graph3d did not.
228    ec("graph3d", 4, "graph3d node source could not be read — the cloud is empty because the READ failed, not because the graph has no nodes", "facett-graph3d", "src/lib.rs", "", Reaction::Retry),
229    // ── graphview — facett-graphview (consolidated L0 render engine) ──────────────
230    ec("graphview", 1, "graphview has an empty scene — nothing to lay out", "facett-graphview", "src/metro.rs", "nornir/viz, dwarves/funnel", Reaction::EmptyState),
231    ec("graphview", 2, "graphview L0 CPU raster produced no pixels", "facett-graphview", "src/lib.rs", "nornir/viz, dwarves/funnel", Reaction::Diagnose),
232    ec("graphview", 3, "graphview graph source did not load", "facett-graphview", "src/falkor.rs", "nornir/viz, dwarves/funnel", Reaction::Retry),
233    // ── population — facett-demo pop_tab over knut-popsim (the Sverige showcase) ───
234    ec("population", 1, "population query returned no people at this scale/filter", "facett-demo", "src/pop_tab.rs", "", Reaction::EmptyState),
235    ec("population", 2, "population model could not build the sample", "facett-demo", "src/pop_model.rs", "", Reaction::Diagnose),
236    // ── systemmap — facett-graph3d SystemMap + facett-cfd fluid (🫧 System Map) ────
237    ec("systemmap", 1, "system map has no components/pipes to draw", "facett-graph3d", "src/pipes.rs", "", Reaction::EmptyState),
238    ec("systemmap", 2, "system map fluid solver diverged (non-finite state)", "facett-cfd", "src/lib.rs", "", Reaction::Diagnose),
239    // systemmap-3 is systemmap-1's missing twin: no components drawn because the
240    // topology could not be READ, not because the system has none.
241    ec("systemmap", 3, "system map topology source could not be read — nothing is drawn because the READ failed, not because the system is empty", "facett-graph3d", "src/pipes.rs", "", Reaction::Retry),
242    // ── syschart — facett-syschart (peers + badges system chart) ──────────────────
243    ec("syschart", 1, "system chart has no peers to show", "facett-syschart", "src/lib.rs", "holger/mannequin", Reaction::EmptyState),
244    ec("syschart", 2, "system chart peer reported an error badge", "facett-syschart", "src/lib.rs", "holger/mannequin", Reaction::Diagnose),
245    // syschart-3 is syschart-1's missing twin, and the most dangerous of the set: "no
246    // peers" and "I could not read the peer roster" both render as an empty chart, and
247    // the second means the monitoring surface itself is blind.
248    ec("syschart", 3, "system chart peer roster could not be read — the chart is empty because the READ failed, not because there are no peers", "facett-syschart", "src/lib.rs", "", Reaction::Retry),
249    // ── cfd — facett-cfd (gatling fluid engine) ───────────────────────────────────
250    ec("cfd", 1, "fluid step produced a non-finite cell (NaN/Inf)", "facett-cfd", "src/lib.rs", "", Reaction::Diagnose),
251    ec("cfd", 2, "fluid pipe network is empty — nothing to simulate", "facett-cfd", "src/lib.rs", "", Reaction::EmptyState),
252    // ── korp — facett-korp (korp caseworker mode host) ────────────────────────────
253    ec("korp", 1, "korp backend connection not established", "facett-korp", "src/lib.rs", "korp/cases, korp/analysis", Reaction::Reconnect),
254    ec("korp", 2, "korp case view has no cases to display", "facett-korp", "src/search.rs", "korp/cases, korp/analysis", Reaction::EmptyState),
255    // korp-3 is korp-2's missing twin — and it is the exact shape of the ⚒ Build Thing
256    // bug that recurred four times: the connection is UP, the query FAILED, and the
257    // pane showed "no cases". `korp-1` does not cover it (that is "not connected").
258    ec("korp", 3, "korp case query failed on a LIVE connection — the list is empty because the QUERY failed, not because there are no cases", "facett-korp", "src/search.rs", "", Reaction::Retry),
259    // korp-4/5/6 split what `korp-1` ("connection not established") lumped together.
260    // Refused, timed-out and rejected look identical to a user and need three different
261    // repairs: start the backend / wait or raise the deadline / fix the credential.
262    // Retrying a REJECTED identity forever is the classic wrong reaction, which is why
263    // korp-6 is Diagnose rather than Reconnect. korp-1 remains for a genuinely unknown
264    // cause, so nothing that already pins it changes meaning.
265    ec("korp", 4, "korp backend REFUSED the connection — nothing is listening at the endpoint", "facett-korp", "src/lib.rs", "", Reaction::Reconnect),
266    ec("korp", 5, "korp backend did not answer in time — it is listening but not responding", "facett-korp", "src/lib.rs", "", Reaction::Retry),
267    ec("korp", 6, "korp backend REJECTED the identity — connected, but not authorised; retrying will not help", "facett-korp", "src/lib.rs", "", Reaction::Diagnose),
268    // ── demo — facett-demo showcase host (the error-code showcase itself) ─────────
269    ec("demo", 1, "example error: a triggered demo condition (showcase)", "facett-demo", "src/errcode_tab.rs", "", Reaction::Diagnose),
270    ec("demo", 2, "example note: a triggered demo degrade (showcase)", "facett-demo", "src/errcode_tab.rs", "", Reaction::Fallback),
271    // ── gpu — facett-core adapter policy (PROCESS-wide, not a pane) ───────────────
272    // GFX_V2 Decision 0 dropped `Backends::GL`, so a host without a WebGPU-class
273    // device now gets NOTHING where it previously got a degraded WebGL picture.
274    // These two codes are what makes that absence STATED rather than a blank
275    // window. Reaction is Diagnose, never Fallback: the whole point of Decision 0
276    // is that there is no silent soft-render lane to fall back to. (The per-pane
277    // `*-GPU renderer not present — CPU painter fallback` codes above are a
278    // DIFFERENT thing — a working CPU lane took over. Do not conflate them.)
279    // `consumer` is "" because no mount reacts to these yet; declaring mounts that
280    // do not wire them is exactly the fiction the Phase-2 anti-fiction guard exists
281    // to catch.
282    //
283    // Their two states are NOT the same, and the registry's `status` column said
284    // `reserved` for both:
285    //
286    //   * `facet-gpu-1` IS raised today. `facett_wgpu_options`' `native_adapter_selector`
287    //     calls `gpu_unavailable_for` -> `classify_unavailable` when no adapter can be
288    //     chosen, records the typed value and surfaces its Display (code + remedy) as the
289    //     eframe bring-up error; `facet-wrapped`'s wasm canvas raises the same code.
290    //   * `facet-gpu-2` is **still genuinely reserved, and cannot fire.** The policy never
291    //     refuses a non-empty adapter list — it *flags* software/BMC and picks them as a
292    //     last resort (see `GpuUnavailable::AllRejected` and the invariant test
293    //     `the_policy_never_refuses_a_non_empty_adapter_list`). Kept for the day the
294    //     policy is made strict, at which point that test goes red and points here.
295    ec("gpu", 1, "no GPU adapter enumerated — facett has no render lane", "facett-core", "src/render/gpu/adapter_wgpu.rs", "", Reaction::Diagnose),
296    ec("gpu", 2, "every GPU adapter was rejected (software rasteriser / management console)", "facett-core", "src/render/gpu/adapter_wgpu.rs", "", Reaction::Diagnose),
297];
298
299/// `const fn` row builder — fills `code` from `component`+`n` is NOT possible in a
300/// `const` (no `format!`), so the `code` literal is passed by the [`REGISTRY`] rows
301/// via the `ec!`-shaped helper below; here we take the pre-formatted parts. Defaults
302/// `consumer` to `""` (Phase-2 fills it). Keeping this `const` lets [`REGISTRY`] stay
303/// a compile-time table.
304const fn ec(
305    component: &'static str,
306    n: u32,
307    message: &'static str,
308    crate_dir: &'static str,
309    src_file: &'static str,
310    consumer: &'static str,
311    reaction: Reaction,
312) -> ErrCode {
313    ErrCode { component, n, code: "", message, crate_dir, src_file, consumer, reaction }
314}
315
316// NOTE: `ec()` leaves `code` = "" because a `const fn` cannot `format!`. The public
317// accessor [`registry`] fills each row's `code` on first access from a `LazyLock`, so
318// callers always see the well-formed `facet-<component>-<n>`. The uniqueness test
319// asserts the derived code is what we expect.
320
321use std::sync::LazyLock;
322
323/// The [`REGISTRY`] with every `code` filled in (`facet-<component>-<n>`), computed
324/// once. Callers should use THIS (via [`registry`]) rather than the raw `REGISTRY`
325/// const, whose `code` fields are empty placeholders (a `const fn` cannot `format!`).
326static FILLED: LazyLock<Vec<ErrCode>> = LazyLock::new(|| {
327    REGISTRY
328        .iter()
329        .map(|e| ErrCode { code: leak_code(e.component, e.n), ..*e })
330        .collect()
331});
332
333/// The canonical registry, every `code` resolved. Read this, not the raw `REGISTRY`.
334pub fn registry() -> &'static [ErrCode] {
335    &FILLED
336}
337
338/// Format+leak `facet-<component>-<n>` into a `&'static str` (done once per row, at
339/// registry init — a bounded, one-time leak, not a hot path).
340fn leak_code(component: &str, n: u32) -> &'static str {
341    Box::leak(format!("facet-{component}-{n}").into_boxed_str())
342}
343
344/// Look up a registry row by its full `code` (`facet-map-1`).
345pub fn lookup(code: &str) -> Option<&'static ErrCode> {
346    registry().iter().find(|e| e.code == code)
347}
348
349/// Look up a registry row by `(component, n)`.
350pub fn lookup_cn(component: &str, n: u32) -> Option<&'static ErrCode> {
351    registry().iter().find(|e| e.component == component && e.n == n)
352}
353
354/// **The canonical [`Reaction`] for a code** — what a consumer MUST do when this
355/// facett UI error is raised (Phase 2). Accepts either a bare facett code
356/// (`facet-map-2`) or a consumer-MOUNTED code (`korp/cases:facet-map-2`): the mount
357/// prefix is stripped, because the reaction is a property of the CODE, and the mount
358/// only says *where* it happened. Unknown codes yield `None`.
359///
360/// ```ignore
361/// assert_eq!(reaction_for("korp/cases:facet-map-2"), Some(Reaction::EmptyState));
362/// assert_eq!(reaction_for("facet-map-2"),            Some(Reaction::EmptyState));
363/// ```
364pub fn reaction_for(code: &str) -> Option<Reaction> {
365    lookup(base_code(code)).map(|e| e.reaction)
366}
367
368/// Strip a consumer mount-prefix from a (possibly mounted) code:
369/// `"korp/cases:facet-map-2"` → `"facet-map-2"`. A bare code passes through.
370pub fn base_code(code: &str) -> &str {
371    match code.rsplit_once(':') {
372        Some((_, base)) => base,
373        None => code,
374    }
375}
376
377/// The consumer mount-prefix of a mounted code (`"korp/cases:facet-map-2"` →
378/// `Some("korp/cases")`), or `None` for a bare facett code.
379pub fn mount_of(code: &str) -> Option<&str> {
380    code.rsplit_once(':').map(|(m, _)| m)
381}
382
383/// Every consumer mount point declared for a code, parsed from its registry
384/// `consumer` column (`"korp/infra, korp/cases"` → `["korp/infra", "korp/cases"]`).
385/// Empty when no consumer mounts this component today.
386pub fn mounts_for(code: &str) -> Vec<&'static str> {
387    lookup(base_code(code))
388        .map(|e| {
389            e.consumer
390                .split(',')
391                .map(|s| s.trim())
392                .filter(|s| !s.is_empty())
393                .collect()
394        })
395        .unwrap_or_default()
396}
397
398/// The full **reaction matrix**: every `(mounted_code, base_code, mount, reaction)`
399/// the consumers must implement — the table the ~150 reaction tests are generated
400/// from. Codes with no consumer mount are omitted (facett-demo renders them, but no
401/// app reacts yet).
402pub fn reaction_matrix() -> Vec<(String, &'static str, &'static str, Reaction)> {
403    let mut out = Vec::new();
404    for e in registry() {
405        for m in mounts_for(e.code) {
406            out.push((format!("{m}:{}", e.code), e.code, m, e.reaction));
407        }
408    }
409    out
410}
411
412/// The codeberg source URL for a `code`, or the repo root if the code is unknown.
413pub fn codeberg_url(code: &str) -> String {
414    lookup(code).map(|e| e.codeberg_url()).unwrap_or_else(|| CODEBERG_REPO.to_string())
415}
416
417/// Every distinct component slug present in the registry, in first-seen order.
418pub fn components() -> Vec<&'static str> {
419    let mut out: Vec<&'static str> = Vec::new();
420    for e in registry() {
421        if !out.contains(&e.component) {
422            out.push(e.component);
423        }
424    }
425    out
426}
427
428/// The codes that are RAISED at a real pane site today (as opposed to catalogued +
429/// reserved for a load/GPU-fallback path that has no explicit failure branch yet).
430/// Kept here — beside the registry — so the markdown export and the demo can both
431/// mark the distinction, and so adding a wiring is a one-line change next to the row.
432pub const WIRED: &[&str] = &[
433    "facet-map-2",
434    "facet-map3d-1",
435    "facet-geomap-2",
436    "facet-graph3d-1",
437    "facet-graphview-1",
438    "facet-population-1",
439    "facet-systemmap-1",
440    "facet-syschart-1",
441    "facet-cfd-2",
442    "facet-korp-1",
443    "facet-korp-2",
444    "facet-demo-1",
445    "facet-demo-2",
446];
447
448/// Is this code raised at a real pane site today? (See [`WIRED`].)
449pub fn is_wired(code: &str) -> bool {
450    WIRED.contains(&base_code(code))
451}
452
453/// **Render THE LIST as markdown** from the compiled [`REGISTRY`] — the single source
454/// of truth. Used by the `errcode_registry_md` bin to (re)generate
455/// `.nornir/facett-error-codes-registry.md`; a freshness test compares the committed
456/// copy against this so the export can never drift.
457pub fn registry_markdown() -> String {
458    use std::fmt::Write as _;
459    let reg = registry();
460    let matrix = reaction_matrix();
461    let wired = reg.iter().filter(|e| is_wired(e.code)).count();
462    let reserved = reg.len() - wired;
463    let mut s = String::new();
464
465    s.push_str("# facett UI error-code registry — THE LIST\n\n");
466    s.push_str(
467        "<!-- GENERATED FILE — do not hand-edit.\n     Regenerate: cargo run -p facett-core --bin errcode_registry_md > .nornir/facett-error-codes-registry.md\n     Source of truth: facett-core/src/errcode.rs (errcode::REGISTRY). -->\n\n",
468    );
469    s.push_str(
470        "The canonical catalog of every `facet-<component>-<n>` code. **Append-only / frozen:**\nan `<n>` is assigned once and never reused. The uniqueness guard (`errcode` tests)\nfails the build on any repeat.\n\n",
471    );
472    let _ = writeln!(
473        s,
474        "* **Codeberg base:** `{CODEBERG_REPO}/src/branch/{CODEBERG_BRANCH}/`"
475    );
476    s.push_str("* **status:** `wired` = raised at a real pane site today (a11y code + `Severity::Error` when empty, `error_code` in `state_json`, a `severity()` override, pink code on painter panes). `reserved` = catalogued, unique, guard-checked and shown in the matrix slide, awaiting an explicit failure branch on that load / GPU-fallback path.\n");
477    s.push_str("* **reaction (Phase 2):** what a CONSUMER must do when the code is raised — the vocabulary is `facett_core::errcode::Reaction`. This is what the (code × mount) reaction tests assert, never a string match.\n");
478    s.push_str("* **consumer mounts:** the `app/surface` mount points that react. A consumer namespaces the code with its mount, e.g. `korp/cases:facet-map-2` vs `korp/infra:facet-map-2` — same facet code, distinct mount points a robot test pins apart.\n\n");
479    let _ = writeln!(
480        s,
481        "**Totals:** {} components, {} codes — **{wired} wired**, **{reserved} reserved**. Reaction matrix: **{} (code × mount) pairs**.\n",
482        components().len(),
483        reg.len(),
484        matrix.len()
485    );
486
487    s.push_str("| component | code | visible message | codeberg source | status | reaction (Phase 2) | consumer mounts |\n");
488    s.push_str("|-----------|------|-----------------|-----------------|--------|--------------------|------------------|\n");
489    for e in reg {
490        let mounts = if e.consumer.is_empty() { "— (none today)".to_string() } else { format!("`{}`", e.consumer) };
491        let _ = writeln!(
492            s,
493            "| {} | `{}` | {} | {}/{} | {} | `{}` — {} | {} |",
494            e.component,
495            e.code,
496            e.message,
497            e.crate_dir,
498            e.src_file,
499            if is_wired(e.code) { "wired" } else { "reserved" },
500            e.reaction.token(),
501            e.reaction.describe(),
502            mounts,
503        );
504    }
505
506    s.push_str("\n## The reaction MATRIX (Phase 2 test table)\n\n");
507    s.push_str(
508        "Every row below is one `(code × mount)` pair the consumer reaction tests assert.\nThe test harness is TABLE-DRIVEN over `errcode::reaction_matrix()`, so adding a code\n(or a mount) adds rows — never a hand-written copy.\n\n",
509    );
510    s.push_str("| mounted code | base code | mount | expected reaction |\n");
511    s.push_str("|--------------|-----------|-------|-------------------|\n");
512    for (mounted, base, mount, reaction) in &matrix {
513        let _ = writeln!(s, "| `{mounted}` | `{base}` | `{mount}` | `{}` |", reaction.token());
514    }
515
516    s.push_str("\n## How a consumer reacts\n\n");
517    s.push_str("```rust\n// The consumer catches a facett error, namespaces it with ITS mount, records the\n// MOUNTED code into its own diagnostics, and reacts per the canonical table.\nlet reaction = errcode::reaction_for(&mounted_code).expect(\"a registered code\");\nmatch reaction {\n    Reaction::EmptyState => show_honest_empty_state(),\n    Reaction::Retry      => retry_with_backoff(),\n    Reaction::Reconnect  => surface_reconnect_action(),\n    Reaction::Fallback   => note_degrade_and_keep_working(),\n    Reaction::Diagnose   => record_diagnostic_and_surface_fault(),\n}\n// The robot test pins the REACTION for the MOUNTED code — never a matched string:\nassert_eq!(reaction_for(\"korp/cases:facet-map-2\"), Some(Reaction::EmptyState));\n```\n");
518
519    s
520}
521
522/// **Export the whole catalog as JSON** — the machine-readable twin of
523/// [`registry_markdown`], and the seam that keeps the two demos in LOCKSTEP (LAW #2):
524/// the native egui demo reads the compiled [`REGISTRY`] directly, and the
525/// Python/Streamlit demo reads THIS json (generated into
526/// `py/facett_demo_python/errcodes.json`), so both render the SAME catalog from the
527/// SAME source of truth. A freshness test fails if the committed json drifts.
528pub fn registry_json() -> serde_json::Value {
529    let codes: Vec<serde_json::Value> = registry()
530        .iter()
531        .map(|e| {
532            serde_json::json!({
533                "component": e.component,
534                "n": e.n,
535                "code": e.code,
536                "message": e.message,
537                "crate": e.crate_dir,
538                "src_file": e.src_file,
539                "codeberg_url": e.codeberg_url(),
540                "status": if is_wired(e.code) { "wired" } else { "reserved" },
541                "reaction": e.reaction.token(),
542                "reaction_describes": e.reaction.describe(),
543                "consumer": e.consumer,
544            })
545        })
546        .collect();
547    let matrix: Vec<serde_json::Value> = reaction_matrix()
548        .into_iter()
549        .map(|(mounted, base, mount, reaction)| {
550            serde_json::json!({
551                "mounted_code": mounted,
552                "code": base,
553                "mount": mount,
554                "reaction": reaction.token(),
555            })
556        })
557        .collect();
558    serde_json::json!({
559        "_generated": "GENERATED from facett-core errcode::REGISTRY — do not hand-edit. \
560                       Regenerate: cargo run -p facett-core --bin errcode_registry_json > py/facett_demo_python/errcodes.json",
561        "scheme": "facet-<component>-<n>",
562        "codeberg_repo": CODEBERG_REPO,
563        "codeberg_branch": CODEBERG_BRANCH,
564        "component_count": components().len(),
565        "code_count": registry().len(),
566        "wired_count": registry().iter().filter(|e| is_wired(e.code)).count(),
567        "reaction_pairs": matrix.len(),
568        "components": components(),
569        "reactions": [
570            { "token": Reaction::Retry.token(), "describes": Reaction::Retry.describe() },
571            { "token": Reaction::EmptyState.token(), "describes": Reaction::EmptyState.describe() },
572            { "token": Reaction::Reconnect.token(), "describes": Reaction::Reconnect.describe() },
573            { "token": Reaction::Fallback.token(), "describes": Reaction::Fallback.describe() },
574            { "token": Reaction::Diagnose.token(), "describes": Reaction::Diagnose.describe() },
575        ],
576        "codes": codes,
577        "reaction_matrix": matrix,
578    })
579}
580
581// ── The reusable CONSUMER side (Phase 2) ──────────────────────────────────────
582// korp integrates facett errors into its own typed `DiagCode` ring (it had one
583// already). The other consumers — nornir, dwarves, holger — do NOT need a bespoke
584// module each: they declare their mounts and record through this shared recorder,
585// so "how a consumer reacts" is implemented ONCE and every app agrees by construction.
586
587/// A **consumer mount point** — `<app>/<surface>` (e.g. `nornir/viz`,
588/// `dwarves/funnel`, `holger/mannequin`). The prefix a consumer prepends to a facett
589/// code so the same component mounted twice yields two distinguishable mounted codes.
590#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
591pub struct ConsumerMount {
592    /// The consuming application (`nornir`, `dwarves`, `holger`, `korp`).
593    pub app: &'static str,
594    /// The surface within that app (`viz`, `funnel`, `mannequin`, …).
595    pub surface: &'static str,
596    /// The source file that actually mounts the facett component — the ANTI-FICTION
597    /// anchor: a consumer's guard test asserts this file exists and really references
598    /// the component, so a declared mount can never be fiction.
599    pub site: &'static str,
600}
601
602impl ConsumerMount {
603    /// The mount prefix (`"nornir/viz"`).
604    pub fn prefix(&self) -> String {
605        format!("{}/{}", self.app, self.surface)
606    }
607}
608
609/// One recorded facett UI error at a consumer mount — what the consumer surfaces into
610/// its own `state_json` / diagnostics.
611#[derive(Debug, Clone, PartialEq, Eq)]
612pub struct MountedRecord {
613    /// The mount-namespaced code (`nornir/viz:facet-graph3d-1`).
614    pub mounted_code: String,
615    /// The base facett code (`facet-graph3d-1`) — frozen, facett's.
616    pub code: &'static str,
617    /// The mount prefix (`nornir/viz`).
618    pub mount: String,
619    /// The canonical reaction the consumer took.
620    pub reaction: Reaction,
621    /// The visible human message (UX unchanged).
622    pub message: String,
623}
624
625impl MountedRecord {
626    pub fn to_json(&self) -> serde_json::Value {
627        serde_json::json!({
628            "mounted_code": self.mounted_code,
629            "code": self.code,
630            "mount": self.mount,
631            "reaction": self.reaction.token(),
632            "message": self.message,
633        })
634    }
635}
636
637/// A bounded **recorder** a consumer keeps so raised facett UI errors land in its own
638/// observable state. This is the shared implementation of the Phase-2 consumer
639/// contract: namespace with the mount, resolve the canonical reaction, record the
640/// MOUNTED code, hand the reaction back for the app to act on.
641///
642/// ```ignore
643/// let mut rec = FacetRecorder::default();
644/// let reaction = rec.record(NORNIR_VIZ, &err);   // → Reaction::EmptyState
645/// json["facet_errors"] = rec.state_json();       // the mounted code rides in state
646/// ```
647#[derive(Debug, Default, Clone)]
648pub struct FacetRecorder {
649    entries: Vec<MountedRecord>,
650    total: u64,
651}
652
653/// How many records a [`FacetRecorder`] keeps (newest-last; older ones evict).
654pub const RECORDER_CAP: usize = 64;
655
656impl FacetRecorder {
657    pub fn new() -> Self {
658        Self::default()
659    }
660
661    /// **Record a facett UI error at `mount` and return the REACTION to take.** An
662    /// unregistered code yields [`Reaction::Diagnose`] — never a silent pass.
663    pub fn record(&mut self, mount: ConsumerMount, err: &FacetError) -> Reaction {
664        let reaction = reaction_for(err.code).unwrap_or(Reaction::Diagnose);
665        let prefix = mount.prefix();
666        self.total += 1;
667        self.entries.push(MountedRecord {
668            mounted_code: format!("{prefix}:{}", err.code),
669            code: err.code,
670            mount: prefix,
671            reaction,
672            message: err.message.clone(),
673        });
674        if self.entries.len() > RECORDER_CAP {
675            self.entries.remove(0);
676        }
677        reaction
678    }
679
680    /// Every recorded entry (newest last).
681    pub fn entries(&self) -> &[MountedRecord] {
682        &self.entries
683    }
684
685    /// Total ever recorded (survives eviction).
686    pub fn total(&self) -> u64 {
687        self.total
688    }
689
690    /// Whether a given mounted code was recorded.
691    pub fn saw(&self, mounted_code: &str) -> bool {
692        self.entries.iter().any(|e| e.mounted_code == mounted_code)
693    }
694
695    pub fn clear(&mut self) {
696        self.entries.clear();
697    }
698
699    /// The observable block a consumer folds into its `state_json` — a robot reads the
700    /// CODE and the REACTION, never a matched string.
701    pub fn state_json(&self) -> serde_json::Value {
702        serde_json::json!({
703            "count": self.entries.len(),
704            "total": self.total,
705            "entries": self.entries.iter().map(|e| e.to_json()).collect::<Vec<_>>(),
706        })
707    }
708}
709
710/// The reaction-matrix rows belonging to ONE app (`"nornir"`, `"dwarves"`, …) —
711/// what that consumer's table-driven test suite must satisfy. Derived from the
712/// shared [`reaction_matrix`], so declaring a mount in the registry automatically
713/// creates the app's obligations.
714pub fn matrix_for_app(app: &str) -> Vec<(String, &'static str, &'static str, Reaction)> {
715    let want = format!("{app}/");
716    reaction_matrix()
717        .into_iter()
718        .filter(|(_, _, mount, _)| mount.starts_with(&want))
719        .collect()
720}
721
722/// The **on-screen colour** for an error CODE: the clear [`PINK`] in a NON-release
723/// (debug) build, a subdued muted pink in release (the code is still present in
724/// `state_json` + logs — only its on-screen prominence drops). This is decision (2)
725/// of the design.
726pub fn code_color() -> Color32 {
727    if cfg!(debug_assertions) {
728        PINK
729    } else {
730        // Subdued: same hue, low prominence, so a release build does not shout the
731        // developer-facing code but a reviewer can still spot it.
732        Color32::from_rgba_unmultiplied(PINK.r(), PINK.g(), PINK.b(), 90)
733    }
734}
735
736/// A **raised facett UI error** — the typed carrier of a `facet-<component>-<n>`
737/// code + its human message + the codeberg source URL. Build it with
738/// [`facet_err!`](crate::facet_err). It renders the message (red) plus the code
739/// (pink in debug), folds into `state_json` via [`FacetError::to_json`], and reports
740/// [`Severity::Error`](crate::Severity::Error).
741#[derive(Debug, Clone, PartialEq, Eq)]
742pub struct FacetError {
743    /// The stable code (`facet-<component>-<n>`).
744    pub code: &'static str,
745    /// The component slug.
746    pub component: &'static str,
747    /// The per-component number.
748    pub n: u32,
749    /// The human-readable message (UX unchanged; may carry runtime detail).
750    pub message: String,
751    /// The codeberg source URL for the component (resolved from the [`REGISTRY`]).
752    pub codeberg_url: String,
753}
754
755impl FacetError {
756    /// Construct from parts — normally called by [`facet_err!`](crate::facet_err),
757    /// which supplies the compile-time `component`/`n`/`code`. The codeberg URL is
758    /// resolved from the [`REGISTRY`] (repo root if the code is not yet registered).
759    pub fn new(component: &'static str, n: u32, code: &'static str, message: impl Into<String>) -> Self {
760        Self {
761            code,
762            component,
763            n,
764            message: message.into(),
765            codeberg_url: codeberg_url(code),
766        }
767    }
768
769    /// This error's structural [`Severity`](crate::Severity) — always
770    /// [`Severity::Error`](crate::Severity::Error) (a raised `FacetError` is RED and
771    /// fails the Robot-UI gate).
772    pub fn severity(&self) -> crate::Severity {
773        crate::Severity::Error
774    }
775
776    /// The observable JSON an error site folds into its pane's `state_json` — the
777    /// CODE (not just the message) so robot tests + consumers key off it:
778    /// `{ code, component, n, message, codeberg_url, severity: "error" }`.
779    pub fn to_json(&self) -> serde_json::Value {
780        serde_json::json!({
781            "code": self.code,
782            "component": self.component,
783            "n": self.n,
784            "message": self.message,
785            "codeberg_url": self.codeberg_url,
786            "severity": "error",
787            // Phase 2: the canonical REACTION a consumer must take for this code.
788            "reaction": self.reaction().map(|r| r.token()),
789        })
790    }
791
792    /// The canonical [`Reaction`] a consumer must take for this error (Phase 2).
793    pub fn reaction(&self) -> Option<Reaction> {
794        reaction_for(self.code)
795    }
796
797    /// The a11y [`Semantics`](crate::a11y::Semantics) for this error — a
798    /// [`Severity::Error`](crate::Severity::Error) atom carrying the code, so the
799    /// Robot-UI gate reads the code straight off the AccessKit tree.
800    pub fn semantics(&self) -> crate::a11y::Semantics {
801        crate::a11y::Semantics::error(format!("{} [{}]", self.message, self.code)).error_code(self.code)
802    }
803
804    /// **Namespace this error with a CONSUMER mount-prefix.** A facett component can
805    /// be mounted in MULTIPLE places in a consuming app (korp mounts `facet-map` in
806    /// both its *cases* and *analysis* modes), so the consumer prepends ITS OWN
807    /// mount-prefix when it surfaces/reacts to a facett error:
808    /// `korp/cases:facet-map-2` vs `korp/analysis:facet-map-2` — the SAME base facet
809    /// code `facet-map-2`, two distinct mount points a robot test can pin apart.
810    ///
811    /// The base facett code stays `facet-<component>-<n>`; the prefix is **additive**
812    /// and applied only by the consumer (this is Phase-1 API that Phase-2 wires in).
813    pub fn with_consumer_prefix(&self, prefix: impl Into<String>) -> MountedFacetError {
814        MountedFacetError { prefix: prefix.into(), inner: self.clone() }
815    }
816
817    /// **Render** the error into `ui`: the human message in the warm failure [`RED`],
818    /// then the CODE in [`code_color`] (clear pink in debug, subdued in release), and
819    /// an AccessKit node carrying [`Severity::Error`](crate::Severity::Error) + the
820    /// code. UX is unchanged (the message is always shown); the pink code is the
821    /// added diagnostic stripe.
822    pub fn render(&self, ui: &mut Ui) -> Response {
823        use egui::RichText;
824        let resp = ui
825            .horizontal(|ui| {
826                ui.label(RichText::new(&self.message).color(RED).strong());
827                // The pink CODE chip — the stable diagnostic id.
828                ui.label(
829                    RichText::new(format!(" {} ", self.code))
830                        .monospace()
831                        .strong()
832                        .color(Color32::WHITE)
833                        .background_color(code_color()),
834                )
835            })
836            .response;
837        // Ride the code into the AccessKit tree as an Error atom so a headless robot
838        // (and the severity fold) sees the code, not the pixels.
839        resp.widget_info(|| self.semantics().widget_info());
840        resp
841    }
842
843    /// **Render for a SHOWCASE / catalog** (the facett-demo error-code slide) — the
844    /// SAME visual (message + pink code chip) as [`render`](Self::render), but the
845    /// a11y atom is a neutral [`Severity::Info`](crate::Severity::Info) `Label`, NOT
846    /// an `Error`. This is what a demonstration surface (which shows error codes on
847    /// purpose, as a catalog) uses so it does NOT trip the Robot-UI HARD GATE or the
848    /// deck error-atom net. A LIVE pane raising a real failure uses [`render`](Self::render).
849    pub fn render_demo(&self, ui: &mut Ui) -> Response {
850        use egui::RichText;
851        let resp = ui
852            .horizontal(|ui| {
853                ui.label(RichText::new(&self.message).color(RED));
854                ui.label(
855                    RichText::new(format!(" {} ", self.code))
856                        .monospace()
857                        .strong()
858                        .color(Color32::WHITE)
859                        .background_color(code_color()),
860                )
861            })
862            .response;
863        // Neutral (Info) label atom carrying the code — no Error severity.
864        let code = self.code;
865        resp.widget_info(|| crate::a11y::Semantics::new(egui::WidgetType::Label, format!("code {code}")).widget_info());
866        resp
867    }
868}
869
870/// **Paint an error CODE into a `Painter`** (for the many facett panes that draw
871/// their empty/error hint with `ui.painter()` rather than widgets). Draws the code
872/// in [`code_color`] (clear pink in debug, subdued in release) centred at `pos` and
873/// returns the drawn text rect. UX-additive: the caller still paints its human hint;
874/// this is the pink diagnostic stripe beneath it. Pair it with an a11y
875/// [`Semantics`](crate::a11y::Semantics)`::error(..).error_code(code)` on the pane so
876/// the code also rides the AccessKit tree.
877pub fn paint_code(painter: &egui::Painter, pos: egui::Pos2, code: &str) -> egui::Rect {
878    painter.text(
879        pos,
880        egui::Align2::CENTER_CENTER,
881        code,
882        egui::FontId::monospace(11.0),
883        code_color(),
884    )
885}
886
887/// A facett [`FacetError`] **mounted by a consumer** — the base facett code
888/// namespaced with the consumer's own mount-prefix (design decision 3). The base
889/// code is untouched; `mounted_code()` is `"<prefix>:<code>"`.
890///
891/// ```ignore
892/// let e = facett_core::facet_err!(map, 2, "no positions to plot");
893/// let m = e.with_consumer_prefix("korp/cases");
894/// assert_eq!(m.mounted_code(), "korp/cases:facet-map-2");
895/// ```
896#[derive(Debug, Clone, PartialEq, Eq)]
897pub struct MountedFacetError {
898    /// The consumer's mount-prefix (e.g. `korp/cases`).
899    pub prefix: String,
900    /// The underlying facett error (base code unchanged).
901    pub inner: FacetError,
902}
903
904impl MountedFacetError {
905    /// The namespaced code — `"<prefix>:<facet-component-n>"`.
906    pub fn mounted_code(&self) -> String {
907        format!("{}:{}", self.prefix, self.inner.code)
908    }
909
910    /// The observable JSON — carries BOTH the base facett `code` and the consumer's
911    /// `mounted_code`, plus the (unchanged) message + codeberg URL. This is the shape
912    /// a Phase-2 consumer robot test pins per mount point.
913    pub fn to_json(&self) -> serde_json::Value {
914        let mut j = self.inner.to_json();
915        if let serde_json::Value::Object(m) = &mut j {
916            m.insert("mount".into(), serde_json::Value::String(self.prefix.clone()));
917            m.insert("mounted_code".into(), serde_json::Value::String(self.mounted_code()));
918        }
919        j
920    }
921}
922
923/// **Raise a facett UI error** — the ergonomic front door for the error-code scheme.
924///
925/// `facet_err!(component, n, "message {with} {fmt}", ...)` builds a [`FacetError`]
926/// whose `code` is `facet-<component>-<n>` (formed at compile time from the literal
927/// `component` ident + `n` literal) and whose message is the (optionally formatted)
928/// string. The codeberg URL is resolved from the [`REGISTRY`].
929///
930/// ```ignore
931/// let e = facett_core::facet_err!(map, 1, "tiles failed to load");
932/// assert_eq!(e.code, "facet-map-1");
933/// ```
934/// **Is `facet-<component>-<n>` a REGISTERED code?** — answerable at COMPILE TIME.
935///
936/// This is what closes the hole `facet_err!` was born with. The macro `concat!`s its code
937/// from literals, so it could always name a code the REGISTRY had never heard of; the
938/// mistake compiled cleanly and only surfaced later as a `lookup` returning `None` and a
939/// `reaction_for` answering `None` — a code with no defined reaction, which is worse than
940/// no code at all.
941///
942/// A `const fn` can walk the `const REGISTRY`, so the macro can assert membership in a
943/// `const` context and turn that class of mistake into a **compile error**. This is the
944/// same property nornir and holger get from their `codes!` macro (a code that is not
945/// declared cannot be named), reached without renaming the catalog or touching a single
946/// call site.
947pub const fn is_registered(component: &str, n: u32) -> bool {
948    let mut i = 0;
949    while i < REGISTRY.len() {
950        if REGISTRY[i].n == n && const_str_eq(REGISTRY[i].component, component) {
951            return true;
952        }
953        i += 1;
954    }
955    false
956}
957
958/// `&str` equality in a `const` context (`==` is not const on `str`).
959const fn const_str_eq(a: &str, b: &str) -> bool {
960    let (a, b) = (a.as_bytes(), b.as_bytes());
961    if a.len() != b.len() {
962        return false;
963    }
964    let mut i = 0;
965    while i < a.len() {
966        if a[i] != b[i] {
967            return false;
968        }
969        i += 1;
970    }
971    true
972}
973
974#[macro_export]
975macro_rules! facet_err {
976    ($comp:ident, $n:literal, $($msg:tt)*) => {{
977        // COMPILE-TIME membership check: naming a code the REGISTRY does not declare is
978        // now a build failure, not a runtime surprise. See `errcode::is_registered`.
979        const _: () = ::core::assert!(
980            $crate::errcode::is_registered(stringify!($comp), $n),
981            "facet_err! names a code that is NOT in errcode::REGISTRY — add the row first",
982        );
983        $crate::errcode::FacetError::new(
984            stringify!($comp),
985            $n,
986            ::core::concat!("facet-", stringify!($comp), "-", stringify!($n)),
987            ::std::format!($($msg)*),
988        )
989    }};
990}
991
992#[cfg(test)]
993mod tests {
994    use super::*;
995    use std::collections::{HashMap, HashSet};
996
997    /// THE GUARD: no `code` repeats globally, and no `n` repeats within a component.
998    #[test]
999    fn codes_are_globally_unique_and_per_component_n_unique() {
1000        let reg = registry();
1001        let mut seen_codes: HashSet<&str> = HashSet::new();
1002        let mut per_component: HashMap<&str, HashSet<u32>> = HashMap::new();
1003        for e in reg {
1004            assert!(
1005                seen_codes.insert(e.code),
1006                "DUPLICATE error code {:?} — every facet-<component>-<n> must be globally unique",
1007                e.code
1008            );
1009            let ns = per_component.entry(e.component).or_default();
1010            assert!(
1011                ns.insert(e.n),
1012                "DUPLICATE n={} within component {:?} — n must never repeat inside a component",
1013                e.n,
1014                e.component
1015            );
1016        }
1017        assert!(!reg.is_empty(), "the registry must not be empty");
1018    }
1019
1020    /// Every `code` string MUST equal `facet-<component>-<n>`.
1021    #[test]
1022    fn code_strings_are_well_formed() {
1023        for e in registry() {
1024            assert_eq!(e.code, format!("facet-{}-{}", e.component, e.n), "malformed code");
1025            assert!(!e.message.is_empty(), "{} has an empty message", e.code);
1026            assert!(!e.crate_dir.is_empty() && !e.src_file.is_empty(), "{} missing source", e.code);
1027        }
1028    }
1029
1030    /// The codeberg URL resolves to the component's source file.
1031    #[test]
1032    fn codeberg_urls_resolve() {
1033        let e = lookup("facet-map-1").expect("facet-map-1 registered");
1034        assert_eq!(
1035            e.codeberg_url(),
1036            "https://codeberg.org/nordisk/facett/src/branch/main/facett-map/src/lib.rs"
1037        );
1038        assert_eq!(codeberg_url("facet-map-1"), e.codeberg_url());
1039        // An unknown code falls back to the repo root, never panics.
1040        assert_eq!(codeberg_url("facet-nope-99"), CODEBERG_REPO);
1041    }
1042
1043    /// The macro forms the code at compile time and resolves the URL.
1044    #[test]
1045    fn macro_builds_a_well_formed_error() {
1046        let e = crate::facet_err!(map, 1, "tiles failed: {}", 42);
1047        assert_eq!(e.code, "facet-map-1");
1048        assert_eq!(e.component, "map");
1049        assert_eq!(e.n, 1);
1050        assert_eq!(e.message, "tiles failed: 42");
1051        assert_eq!(e.severity(), crate::Severity::Error);
1052        let j = e.to_json();
1053        assert_eq!(j["code"], "facet-map-1");
1054        assert_eq!(j["severity"], "error");
1055        assert!(j["codeberg_url"].as_str().unwrap().contains("facett-map"));
1056    }
1057
1058    #[test]
1059    fn consumer_prefix_namespaces_the_code() {
1060        let e = crate::facet_err!(map, 2, "no positions to plot");
1061        let m = e.with_consumer_prefix("korp/cases");
1062        assert_eq!(m.mounted_code(), "korp/cases:facet-map-2");
1063        // The base code is untouched.
1064        assert_eq!(m.inner.code, "facet-map-2");
1065        let j = m.to_json();
1066        assert_eq!(j["code"], "facet-map-2", "base code preserved");
1067        assert_eq!(j["mounted_code"], "korp/cases:facet-map-2");
1068        assert_eq!(j["mount"], "korp/cases");
1069        // Same base code, two mount points, distinct mounted codes.
1070        let a = e.with_consumer_prefix("korp/analysis");
1071        assert_ne!(m.mounted_code(), a.mounted_code());
1072        assert_eq!(m.inner.code, a.inner.code);
1073    }
1074
1075    /// Phase 2: every code carries a canonical reaction, and the reaction is a
1076    /// property of the CODE — a mounted code resolves to the same reaction as its base.
1077    #[test]
1078    fn every_code_has_a_canonical_reaction_independent_of_mount() {
1079        for e in registry() {
1080            assert_eq!(reaction_for(e.code), Some(e.reaction), "{} reaction", e.code);
1081            // The mount prefix must NOT change the reaction.
1082            let mounted = format!("korp/cases:{}", e.code);
1083            assert_eq!(reaction_for(&mounted), Some(e.reaction), "{mounted} reaction");
1084            assert_eq!(base_code(&mounted), e.code);
1085            assert_eq!(mount_of(&mounted), Some("korp/cases"));
1086        }
1087        assert_eq!(reaction_for("facet-nope-99"), None, "unknown code has no reaction");
1088        assert_eq!(base_code("facet-map-2"), "facet-map-2", "a bare code passes through");
1089        assert_eq!(mount_of("facet-map-2"), None);
1090    }
1091
1092    /// The empty-data codes must all react with an HONEST EMPTY STATE, and the
1093    /// GPU-absent codes with a FALLBACK — the two rules that matter most for "the
1094    /// consumer must never show a blank pane".
1095    #[test]
1096    fn reaction_assignments_are_semantically_right() {
1097        assert_eq!(reaction_for("facet-map-2"), Some(Reaction::EmptyState));
1098        assert_eq!(reaction_for("facet-graph3d-1"), Some(Reaction::EmptyState));
1099        assert_eq!(reaction_for("facet-syschart-1"), Some(Reaction::EmptyState));
1100        assert_eq!(reaction_for("facet-korp-2"), Some(Reaction::EmptyState));
1101        // GPU-absent lanes degrade, they do not alarm.
1102        assert_eq!(reaction_for("facet-map-3"), Some(Reaction::Fallback));
1103        assert_eq!(reaction_for("facet-map3d-2"), Some(Reaction::Fallback));
1104        assert_eq!(reaction_for("facet-graph3d-2"), Some(Reaction::Fallback));
1105        // Transient loads retry.
1106        assert_eq!(reaction_for("facet-map-1"), Some(Reaction::Retry));
1107        assert_eq!(reaction_for("facet-geomap-1"), Some(Reaction::Retry));
1108        // A dead backend offers reconnect.
1109        assert_eq!(reaction_for("facet-korp-1"), Some(Reaction::Reconnect));
1110        // Compute faults are diagnosed.
1111        assert_eq!(reaction_for("facet-cfd-1"), Some(Reaction::Diagnose));
1112        assert_eq!(reaction_for("facet-systemmap-2"), Some(Reaction::Diagnose));
1113    }
1114
1115    /// The reaction MATRIX — the table the consumer robot tests are generated from.
1116    /// Every row is a real (mount × code) pair with a canonical reaction, every
1117    /// mounted code is unique, and the matrix is non-trivial.
1118    #[test]
1119    fn reaction_matrix_is_well_formed_and_covers_the_mounted_codes() {
1120        let matrix = reaction_matrix();
1121        assert!(!matrix.is_empty(), "the reaction matrix must not be empty");
1122        let mut seen = std::collections::HashSet::new();
1123        for (mounted, base, mount, reaction) in &matrix {
1124            assert!(seen.insert(mounted.clone()), "duplicate matrix row {mounted}");
1125            assert_eq!(mounted, &format!("{mount}:{base}"));
1126            assert_eq!(reaction_for(mounted), Some(*reaction));
1127            assert!(mount.contains('/'), "a mount is `<app>/<surface>`, got {mount}");
1128            assert!(lookup(base).is_some(), "{base} is a registered code");
1129        }
1130        // Every code that declares consumer mounts appears in the matrix.
1131        for e in registry() {
1132            let n = mounts_for(e.code).len();
1133            let rows = matrix.iter().filter(|(_, b, _, _)| *b == e.code).count();
1134            assert_eq!(rows, n, "{} contributes one row per declared mount", e.code);
1135        }
1136        eprintln!("\n══ facett reaction MATRIX ══\n  {} (code × mount) pairs\n", matrix.len());
1137    }
1138
1139    /// A raised error carries its reaction into `state_json` (the consumer reads the
1140    /// CODE + the REACTION as data, never a matched string).
1141    #[test]
1142    fn raised_error_json_carries_the_reaction() {
1143        let e = crate::facet_err!(map, 2, "no positions to plot");
1144        let j = e.to_json();
1145        assert_eq!(j["code"], "facet-map-2");
1146        assert_eq!(j["reaction"], "empty_state");
1147        assert_eq!(e.reaction(), Some(Reaction::EmptyState));
1148        // The mounted form keeps both the base code and the reaction.
1149        let m = e.with_consumer_prefix("korp/cases");
1150        let mj = m.to_json();
1151        assert_eq!(mj["mounted_code"], "korp/cases:facet-map-2");
1152        assert_eq!(mj["reaction"], "empty_state");
1153    }
1154
1155    #[test]
1156    fn reaction_tokens_roundtrip() {
1157        for r in [
1158            Reaction::Retry,
1159            Reaction::EmptyState,
1160            Reaction::Reconnect,
1161            Reaction::Fallback,
1162            Reaction::Diagnose,
1163        ] {
1164            assert_eq!(Reaction::parse(r.token()), Some(r), "roundtrip {r:?}");
1165            assert!(!r.describe().is_empty());
1166        }
1167        assert_eq!(Reaction::parse("bogus"), None);
1168    }
1169
1170    /// The shared consumer recorder: namespaces, reacts, records the MOUNTED code.
1171    #[test]
1172    fn facet_recorder_namespaces_reacts_and_records() {
1173        const VIZ: ConsumerMount =
1174            ConsumerMount { app: "nornir", surface: "viz", site: "src/autonom/facett_probe.rs" };
1175        assert_eq!(VIZ.prefix(), "nornir/viz");
1176        let mut rec = FacetRecorder::new();
1177        let err = crate::facet_err!(graph3d, 1, "no nodes to render");
1178        let reaction = rec.record(VIZ, &err);
1179        assert_eq!(reaction, Reaction::EmptyState, "an empty cloud shows an empty state");
1180        assert!(rec.saw("nornir/viz:facet-graph3d-1"), "the MOUNTED code was recorded");
1181        let j = rec.state_json();
1182        assert_eq!(j["count"], 1);
1183        assert_eq!(j["entries"][0]["mounted_code"], "nornir/viz:facet-graph3d-1");
1184        assert_eq!(j["entries"][0]["code"], "facet-graph3d-1", "base code preserved");
1185        assert_eq!(j["entries"][0]["reaction"], "empty_state");
1186        // The human message survives — the UX is unchanged.
1187        assert_eq!(j["entries"][0]["message"], "no nodes to render");
1188    }
1189
1190    /// An unregistered code is DIAGNOSED, never silently ignored.
1191    #[test]
1192    fn facet_recorder_diagnoses_an_unknown_code() {
1193        const M: ConsumerMount = ConsumerMount { app: "dwarves", surface: "funnel", site: "x.rs" };
1194        let mut rec = FacetRecorder::new();
1195        let bogus = FacetError::new("nope", 99, "facet-nope-99", "invented");
1196        assert_eq!(rec.record(M, &bogus), Reaction::Diagnose);
1197        assert!(rec.saw("dwarves/funnel:facet-nope-99"));
1198    }
1199
1200    /// The recorder is bounded — a hot error loop cannot grow it without bound.
1201    #[test]
1202    fn facet_recorder_is_bounded() {
1203        const M: ConsumerMount = ConsumerMount { app: "holger", surface: "mannequin", site: "x.rs" };
1204        let mut rec = FacetRecorder::new();
1205        let err = crate::facet_err!(syschart, 1, "no peers to show");
1206        for _ in 0..(RECORDER_CAP + 20) {
1207            rec.record(M, &err);
1208        }
1209        assert_eq!(rec.entries().len(), RECORDER_CAP, "the ring is capped");
1210        assert_eq!(rec.total(), (RECORDER_CAP + 20) as u64, "the total is honest");
1211    }
1212
1213    /// Per-app matrix slicing — each consumer's obligations come from the shared table.
1214    #[test]
1215    fn matrix_for_app_slices_the_shared_table() {
1216        for app in ["korp", "nornir", "dwarves", "holger"] {
1217            let rows = matrix_for_app(app);
1218            assert!(!rows.is_empty(), "{app} mounts at least one facett component");
1219            for (mounted, base, mount, reaction) in &rows {
1220                assert!(mount.starts_with(&format!("{app}/")), "{mounted} belongs to {app}");
1221                assert_eq!(mounted, &format!("{mount}:{base}"));
1222                assert_eq!(reaction_for(mounted), Some(*reaction));
1223            }
1224        }
1225        // Every row of the whole matrix belongs to exactly one app slice.
1226        let total: usize = ["korp", "nornir", "dwarves", "holger"]
1227            .iter()
1228            .map(|a| matrix_for_app(a).len())
1229            .sum();
1230        assert_eq!(total, reaction_matrix().len(), "the app slices partition the matrix");
1231    }
1232
1233    #[test]
1234    fn components_are_discoverable() {
1235        let comps = components();
1236        for want in ["map", "map3d", "geomap", "graph3d", "graphview", "cfd", "korp"] {
1237            assert!(comps.contains(&want), "component {want} missing from registry");
1238        }
1239    }
1240}