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}