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