facett_core/devid.rs
1//! **The DEV-ID badge** — every pane says who it is, in non-release builds only.
2//!
3//! Rickard's ask, verbatim: *"show an uniqe ID on each pane/component in non release
4//! mode … make it easy to copy this one to claude and explain where i am"*.
5//!
6//! # Why this is the errcode scheme's other half
7//!
8//! [`crate::errcode`] already answers *"what went wrong"* with a stable
9//! `facet-<component>-<n>`, and its [`REGISTRY`](crate::errcode::REGISTRY) maps a
10//! component to the crate dir, the source file and a Codeberg URL. This module answers
11//! the sibling question — ***"where am I"*** — with the same vocabulary, so a badge
12//! pasted into a chat resolves to a crate and a file with no guessing on either side.
13//! It is not an error; a pane showing its dev-id is perfectly healthy.
14//!
15//! # The trap this is built to avoid
16//!
17//! `facett-core/tests/errcode_raise_sites.rs` found that `facet_err!` **fabricates** its
18//! code with `concat!` and never asks the registry whether it exists, so
19//! `facet_err!(map, 99, …)` compiles and yields an authoritative-looking `facet-map-99`
20//! that `lookup` cannot resolve. A dev-id badge has exactly that hole: an id that *looks*
21//! canonical but names nothing wastes the time of whoever pastes it AND whoever reads it.
22//!
23//! So the badge is honest about its own confidence, and the two cases are rendered so
24//! they cannot be mistaken for each other:
25//!
26//! * **registered** — `facet-geomap` in the registry's own colour. [`resolve`] hands back
27//! the crate dir + source file, so the id is actionable on arrival.
28//! * **unregistered** — `⟨unregistered⟩ Some Title`, dimmed and bracketed. It still
29//! identifies the pane (better than nothing when you are staring at a bug), while
30//! saying plainly that it will not resolve to a file. Every one of these is a pane
31//! waiting to declare [`Facet::component`](crate::Facet::component) — the badge
32//! doubles as the to-do list.
33//!
34//! Two failures that render identically make a bug immortal (the ⚒ Build Thing
35//! post-mortem, quoted in `errcode_raise_sites.rs`). The same reasoning applies to two
36//! *confidences* that render identically.
37//!
38//! # When it shows
39//!
40//! ON in debug builds. In a RELEASE build it is off by default but can be switched on
41//! with **`FACETT_DEVID=1`** — because the binaries people actually run on this fleet
42//! are release builds. Gating on `debug_assertions` ALONE made the whole feature
43//! invisible to the person who asked for it: every pane was wired, every test was
44//! green, and the installed `korp-ui` painted nothing, because `badge` had compiled to
45//! an empty function. A debug-only debugging aid is not a debugging aid. The function is
46//! always *present* so no call site needs a `cfg`.
47
48use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, Ui};
49
50/// The prefix every dev-id atom carries in the a11y tree, so a headless test can find
51/// the chip among a pane's own atoms without matching on the id text itself.
52pub const DEVID_ATOM: &str = "dev-id:";
53
54/// The suffix the chip's atom carries when its pane is the one the pointer is in.
55///
56/// This is what makes FOCUS assertable rather than merely visible: a robot drive can ask
57/// the AccessKit tree *which pane am I in* instead of inferring it from what it clicked
58/// last. Rickard's ask — *"you could make it flash when in focus, that would be nice for
59/// robot tests"* — with the flash made deterministic (see [`badge`]).
60pub const FOCUSED_MARK: &str = "[focused]";
61
62/// The marker a pane that has not declared a component renders instead of an id.
63/// Deliberately not id-shaped — it can never be mistaken for a `facet-*` code.
64pub const UNREGISTERED: &str = "⟨unregistered⟩";
65
66/// **Normalise a raw dev-id to a registry component.**
67///
68/// The elm bridge macro fills [`Facet::component`](crate::Facet::component) with
69/// `module_path!()`, which expands at the CALL site — so a pane in `facett-geomap`
70/// reports `facett_geomap` (or `facett_geomap::inner`) with no per-pane code at all.
71/// This maps that to the registry's vocabulary: take the crate segment, drop the
72/// `facett_` prefix, and spell `_` as `-`. `facett_geomap::x` → `geomap`,
73/// `facett_map3d` → `map3d`.
74///
75/// A hand-written `component()` returning a bare `"geomap"` passes through unchanged, so
76/// both spellings are legal and neither needs to know about the other.
77pub fn normalize(raw: &str) -> &str {
78 let head = raw.split("::").next().unwrap_or(raw);
79 head.strip_prefix("facett_").unwrap_or(head)
80}
81
82/// **Resolve a dev-id to its source** — `(crate_dir, src_file)` for a component that the
83/// [`errcode`](crate::errcode) registry knows, `None` otherwise.
84///
85/// This is what makes a pasted badge actionable rather than decorative: the component
86/// carried by every `facet-<component>-<n>` code is the same string the badge shows, so
87/// one lookup turns *"I am here"* into a file to open. `None` is the honest answer for a
88/// pane that has not registered — the badge renders [`UNREGISTERED`] in that case rather
89/// than inventing a path.
90pub fn resolve(component: &str) -> Option<(&'static str, &'static str)> {
91 let want = normalize(component);
92 crate::errcode::REGISTRY
93 .iter()
94 .find(|e| e.component == want)
95 .map(|e| (e.crate_dir, e.src_file))
96}
97
98/// Is this component known to the [`errcode`](crate::errcode) registry?
99pub fn is_registered(component: &str) -> bool {
100 resolve(component).is_some()
101}
102
103/// **What the badge should say** for a pane — the pure decision, split out from any
104/// painting so it is testable without a GPU (and so the release build's no-op cannot
105/// silently diverge from what debug shows).
106///
107/// Returns `(text, registered)`. An empty `component` falls back to the pane title, and
108/// so does a component the registry does not know: claiming an unregistered string is a
109/// valid dev-id is exactly the `facet-map-99` failure.
110pub fn badge_text(component: &str, title: &str) -> (String, bool) {
111 if !component.is_empty() && is_registered(component) {
112 let comp = normalize(component);
113 // `component/pane` — the component alone is not enough to locate a human. korp
114 // draws every one of its panes from ONE `SceneHost::slot` dispatch, so a chip
115 // reading just "korp" would be identical on all of them, which is no better
116 // than no chip. With the slot it reads `korp/ingest_load`, and for a facett
117 // deck it reads `map3d/OSM 3D` — a crate to open AND the pane inside it.
118 let t = title.trim();
119 if t.is_empty() { (comp.to_string(), true) } else { (format!("{comp}/{t}"), true) }
120 } else {
121 let what = if component.is_empty() { title } else { normalize(component) };
122 (format!("{UNREGISTERED} {what}"), false)
123 }
124}
125
126/// **The chip's colours** — `(foreground, background plate)`, split out so the OPACITY of
127/// the plate is assertable without a GPU.
128///
129/// The plate must be **fully opaque**, and that is not a style preference. The chip is
130/// painted over the top-right corner of a pane that has already drawn there, so a
131/// translucent plate does not put the id *on* the pane, it superimposes two strings in the
132/// same pixels. Measured on a real release window (facett-demo, `FACETT_DEVID=1`): the
133/// chip `⟨unregistered⟩ Velo L1` landed on the pane's own header
134/// `L0 + L1 — vello off (feature off / no GPU)` and both became hard to read through each
135/// other, with the old `from_black_alpha(120)` plate letting the text underneath bleed
136/// straight through. An id you have to decode is an id you do not paste.
137///
138/// Registered ids read as actionable (bright blue); unregistered ones stay deliberately
139/// dim in the FOREGROUND, so the two confidences are still tellable apart at a glance —
140/// the dimming lives in the text colour, never in the plate.
141pub fn chip_colors(registered: bool) -> (Color32, Color32) {
142 if registered {
143 (Color32::from_rgb(150, 200, 255), Color32::from_rgb(12, 14, 20))
144 } else {
145 (Color32::from_gray(150), Color32::from_rgb(20, 20, 22))
146 }
147}
148
149/// **The chip's colours when its pane HAS focus** — brighter foreground, a lifted plate.
150///
151/// Same shape as [`chip_colors`] so the registered/unregistered distinction survives being
152/// focused: a focused unregistered pane must still read as unregistered, or the marker
153/// stops meaning anything.
154pub fn chip_colors_focused(registered: bool) -> (Color32, Color32) {
155 if registered {
156 (Color32::from_rgb(235, 245, 255), Color32::from_rgb(24, 48, 90))
157 } else {
158 (Color32::from_rgb(225, 225, 230), Color32::from_rgb(52, 52, 58))
159 }
160}
161
162/// **Paint the dev-id chip** in the top-right of `rect`, and copy the id to the clipboard
163/// when clicked (so it lands in a chat with one click, which is the entire point).
164///
165/// A no-op in release builds. `rect` is the pane's own rect — the caller passes it rather
166/// than reading `ui.max_rect()` after the pane drew, because a pane that consumed the
167/// whole `Ui` leaves the cursor somewhere unhelpful.
168pub fn badge(ui: &mut Ui, rect: Rect, component: &str, title: &str) {
169 if !enabled() {
170 return;
171 }
172 let (text, registered) = badge_text(component, title);
173 let font = FontId::monospace(10.0);
174 // FOCUS: the pane the pointer is in. Deliberately NOT an animated pulse — the render
175 // oracle compares snapshot images, so anything time-based would make every snapshot
176 // differ from the last and the oracle would be worthless. Pointer containment is
177 // deterministic under a driver (the pointer is scripted) and reads as a highlight to a
178 // human watching a visible drive, which was the point of the request.
179 let focused = ui.rect_contains_pointer(rect);
180 let (fg, bg) = if focused { chip_colors_focused(registered) } else { chip_colors(registered) };
181
182 let galley = ui.painter().layout_no_wrap(text.clone(), font, fg);
183 let size = galley.size();
184 let anchor = egui::pos2(rect.right() - 4.0, rect.top() + 4.0);
185 let chip = Rect::from_min_size(egui::pos2(anchor.x - size.x - 6.0, anchor.y), size)
186 .expand2(egui::vec2(4.0, 2.0));
187
188 // Emitted as a proper a11y ATOM, not bare painted glyphs: a headless test (and the
189 // robot driver) reads the id off the AccessKit tree, so "the chip is really on
190 // screen" is assertable rather than assumed. Painted-only text is invisible to every
191 // check we have — which is how a badge ships green and never appears.
192 let resp = crate::a11y::node(
193 ui,
194 ui.id(),
195 ("devid", &text),
196 Sense::click(),
197 chip,
198 // The atom carries the focus mark too, so `label_contains("[focused]")` answers
199 // "which pane is the robot in" off the tree — no guessing from the last click.
200 crate::a11y::Semantics::button(if focused {
201 format!("{DEVID_ATOM} {text} {FOCUSED_MARK}")
202 } else {
203 format!("{DEVID_ATOM} {text}")
204 }),
205 );
206 let p = ui.painter();
207 p.rect_filled(chip, 3.0, bg);
208 if resp.hovered() {
209 p.rect_stroke(chip, 3.0, Stroke::new(1.0, fg), egui::StrokeKind::Inside);
210 }
211 p.text(chip.center(), Align2::CENTER_CENTER, &text, FontId::monospace(10.0), fg);
212
213 if resp.clicked() {
214 // Copy the bare id, never the ⟨unregistered⟩ decoration — pasting the marker
215 // into a chat would send a string that resolves to nothing.
216 let payload =
217 if registered { normalize(component).to_string() } else { format!("{title} (unregistered pane)") };
218 crate::clipboard::put(ui.ctx(), payload);
219 }
220 let hover = match resolve(component) {
221 Some((dir, file)) => format!("{text}\n{dir}/{file}\nclick to copy"),
222 None => format!("{text}\nthis pane has not declared Facet::component()\nclick to copy"),
223 };
224 resp.on_hover_text(hover);
225}
226
227/// **The gate decision, as a pure function** — `$FACETT_DEVID` (if set and understood)
228/// wins, otherwise the build profile decides.
229///
230/// Split out from [`enabled`] for one reason: `debug_assertions` is a *compile-time*
231/// fact, and under `cargo test` it is always `true`. A test calling `enabled()` can
232/// therefore never observe the release branch — which is precisely how the badge shipped
233/// invisible to every release binary on this fleet with five green tests behind it. With
234/// the profile as a PARAMETER the release branch is reachable from a debug test, and
235/// there is still exactly ONE copy of the rule (LAW 5): [`enabled`] is a one-liner over
236/// this, so a table test here cannot drift from what ships.
237///
238/// It does NOT replace a real release build — a rule that is right and a function that
239/// got compiled away are different failures. See `facett-app/tests/devid_release_profile.rs`
240/// for the guard that actually runs `--release`.
241pub fn resolve_enabled(var: Option<&str>, debug_assertions: bool) -> bool {
242 match var {
243 Some("1" | "true" | "on" | "yes") => true,
244 Some("0" | "false" | "off" | "no") => false,
245 // Unset, empty, or a value we do not understand: the profile decides. An
246 // unrecognised value must never read as "on" — a typo'd `FACETT_DEVID=yes!`
247 // silently enabling a debug overlay in production is the wrong default.
248 _ => debug_assertions,
249 }
250}
251
252/// **Is the dev-id chip switched on?** Debug builds: yes. Release builds: only with
253/// `FACETT_DEVID=1`, so the aid is reachable in the binaries that actually ship.
254/// `FACETT_DEVID=0` turns it off even in debug. Read once and cached — this is called
255/// per pane per frame.
256pub fn enabled() -> bool {
257 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
258 *ON.get_or_init(|| {
259 resolve_enabled(std::env::var("FACETT_DEVID").ok().as_deref(), cfg!(debug_assertions))
260 })
261}
262
263/// **The gate, as observable data** — folded into `FacetDeck::state_json` and
264/// `facett_app::scene::render`'s state under the key `devid`, so a headless oracle can
265/// report *why* there are no chips without anyone opening a window.
266///
267/// ```json
268/// { "enabled": true, "env": "1", "debug_assertions": false, "source": "env" }
269/// ```
270///
271/// * `enabled` — the resolved answer [`badge`] acts on.
272/// * `env` — the raw `$FACETT_DEVID`, or `null`. Distinguishes *"nobody set it"* from
273/// *"it was set to something I did not understand"*, which look identical in `enabled`.
274/// * `debug_assertions` — the profile this binary was compiled in. This is the field that
275/// would have named the third failure on sight.
276/// * `source` — `"env"` when the variable decided, `"profile"` when it did not.
277///
278/// # This does NOT mean a chip is on screen
279///
280/// It reports the GATE, nothing more. `enabled: true` is consistent with a badge wired
281/// into a draw path nobody calls (miss #1), with a container that never calls it at all
282/// (miss #2), and with a chip painted off-screen or behind another pane. Each of those
283/// shipped, and each would have reported `enabled: true` here. This field narrows a
284/// diagnosis; only a screenshot of the real window on the real build profile closes it.
285/// See `facett-app/tests/devid_release_profile.rs` for the assertion on applied output.
286pub fn gate_json() -> serde_json::Value {
287 let env = std::env::var("FACETT_DEVID").ok();
288 let understood = matches!(
289 env.as_deref(),
290 Some("1" | "true" | "on" | "yes" | "0" | "false" | "off" | "no")
291 );
292 serde_json::json!({
293 "enabled": enabled(),
294 "env": env,
295 "debug_assertions": cfg!(debug_assertions),
296 "source": if understood { "env" } else { "profile" },
297 })
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 /// **A badge must never claim an id the registry cannot resolve.** This is the
305 /// `facet-map-99` hole from `errcode_raise_sites.rs`, applied to identity rather
306 /// than to errors: an authoritative-looking id that names nothing costs the reader
307 /// more than showing no id at all.
308 #[test]
309 fn an_unknown_component_is_marked_unregistered_not_rendered_as_an_id() {
310 let (text, ok) = badge_text("definitely-not-a-registered-component", "Some Pane");
311 assert!(!ok, "an unknown component must NOT report as registered");
312 assert!(text.starts_with(UNREGISTERED), "must be visibly marked, got {text:?}");
313 assert!(resolve("definitely-not-a-registered-component").is_none());
314
315 // The empty case (a pane that never declared one) falls back to the title, and
316 // is marked just the same — no component is not the same as a valid component.
317 let (text, ok) = badge_text("", "Some Pane");
318 assert!(!ok);
319 assert!(text.contains("Some Pane"), "the fallback still identifies the pane: {text:?}");
320 assert!(text.starts_with(UNREGISTERED));
321 }
322
323 /// **The elm bridge fills `component()` with `module_path!()`** — so the mapping
324 /// from a crate path to a registry component has to hold, or every bridged pane
325 /// silently renders ⟨unregistered⟩ despite being registered all along.
326 #[test]
327 fn a_module_path_normalises_to_its_registry_component() {
328 assert_eq!(normalize("facett_geomap"), "geomap");
329 assert_eq!(normalize("facett_map3d::inner::deep"), "map3d");
330 assert_eq!(normalize("geomap"), "geomap", "a hand-written bare component passes through");
331 assert!(is_registered("facett_geomap"), "the bridged spelling must resolve");
332 let (text, ok) = badge_text("facett_map3d", "3-D Map");
333 assert!(ok);
334 assert_eq!(text, "map3d/3-D Map", "the chip shows the registry name + the pane");
335 // Two panes of the SAME crate must be tellable apart — the korp case, where
336 // every pane shares one component.
337 let (a, _) = badge_text("korp", "ingest_load");
338 let (b, _) = badge_text("korp", "map");
339 assert_ne!(a, b, "same component, different pane, identical chip: {a} vs {b}");
340 }
341
342 /// **The RELEASE branch of the gate, reached from a debug test.**
343 ///
344 /// This is the hole that let the feature ship invisible: `cargo test` compiles with
345 /// `debug_assertions` ON, so every test saw the debug branch and none of them could
346 /// see that a release binary showed nothing. Passing the profile as a parameter makes
347 /// both branches reachable. The `debug = false` rows are the ones that matter — they
348 /// are the build Rickard actually runs.
349 #[test]
350 fn the_gate_honours_the_env_var_in_a_release_profile_too() {
351 // RELEASE (debug_assertions off): off unless explicitly switched on.
352 assert!(!resolve_enabled(None, false), "release + unset must be OFF");
353 for on in ["1", "true", "on", "yes"] {
354 assert!(
355 resolve_enabled(Some(on), false),
356 "FACETT_DEVID={on} must switch the chip ON in a RELEASE build — that is \
357 the whole escape hatch, and without it the aid is invisible to the \
358 binaries people run"
359 );
360 }
361 for off in ["0", "false", "off", "no"] {
362 assert!(!resolve_enabled(Some(off), false), "FACETT_DEVID={off} must stay OFF");
363 }
364 // An unrecognised value must fall back to the profile, never to "on".
365 assert!(!resolve_enabled(Some("banana"), false), "an unknown value must not enable it");
366 assert!(!resolve_enabled(Some(""), false), "an empty value must not enable it");
367
368 // DEBUG: on by default, and switchable off — so a developer can silence it.
369 assert!(resolve_enabled(None, true), "debug + unset must be ON");
370 assert!(!resolve_enabled(Some("0"), true), "FACETT_DEVID=0 must silence it in debug");
371 assert!(resolve_enabled(Some("banana"), true), "an unknown value falls back to profile");
372
373 // And the SHIPPED entry point must agree with the rule for THIS build, or the
374 // table above is testing a function nothing calls (the tautology this replaced).
375 // `enabled()` caches, so this only reads it — the env cases live above.
376 if std::env::var_os("FACETT_DEVID").is_none() {
377 assert_eq!(
378 enabled(),
379 cfg!(debug_assertions),
380 "enabled() disagrees with resolve_enabled for this build profile"
381 );
382 }
383 }
384
385 /// **The chip's plate must OCCLUDE, not tint.**
386 ///
387 /// The chip is drawn over a corner the pane has already painted. With a translucent
388 /// plate that is not a badge on a pane, it is two strings sharing pixels — seen on a
389 /// real release window, where `⟨unregistered⟩ Velo L1` sat on top of the pane's own
390 /// header and neither was readable. "The chip is there" and "the chip is legible" are
391 /// different claims and this asserts the second.
392 #[test]
393 fn the_chip_plate_is_opaque_so_it_does_not_superimpose_two_strings() {
394 for registered in [true, false] {
395 let (fg, bg) = chip_colors(registered);
396 assert_eq!(
397 bg.a(),
398 255,
399 "the chip plate is translucent (alpha {}) for registered={registered} — the \
400 pane's own text bleeds through it and the id becomes unreadable",
401 bg.a()
402 );
403 // A plate that occludes is worthless if the text on it does not stand out.
404 let lum = |c: Color32| 0.299 * c.r() as f32 + 0.587 * c.g() as f32 + 0.114 * c.b() as f32;
405 assert!(
406 (lum(fg) - lum(bg)).abs() > 60.0,
407 "chip fg/bg are too close to read (registered={registered}): {fg:?} on {bg:?}"
408 );
409 }
410 // The two CONFIDENCES must still be tellable apart — that distinction was the
411 // point of the ⟨unregistered⟩ marker, and it must survive the opacity fix.
412 assert_ne!(chip_colors(true).0, chip_colors(false).0, "registered/unregistered look identical");
413 }
414
415 /// **The gate must be REPORTED, not just obeyed.** A headless oracle reads this off
416 /// `state_json`; if it disagreed with what `badge` acts on it would be worse than
417 /// absent — a confident wrong answer during a hunt.
418 #[test]
419 fn the_reported_gate_matches_the_one_the_badge_acts_on() {
420 let g = gate_json();
421 assert_eq!(
422 g["enabled"], enabled(),
423 "state_json reports a different gate than badge() obeys: {g}"
424 );
425 assert_eq!(
426 g["debug_assertions"], cfg!(debug_assertions),
427 "the reported profile is not this binary's profile: {g}"
428 );
429 // `source` must name who decided, so "unset" and "set to nonsense" are tellable
430 // apart — they resolve identically in `enabled` and mean very different things.
431 let by_env = std::env::var("FACETT_DEVID")
432 .map(|v| matches!(v.as_str(), "1" | "true" | "on" | "yes" | "0" | "false" | "off" | "no"))
433 .unwrap_or(false);
434 assert_eq!(g["source"], if by_env { "env" } else { "profile" }, "wrong source: {g}");
435 }
436
437 /// The happy path, driven off a component the registry really carries — so this test
438 /// cannot pass by the lookup being dead (the identity-value trap: a resolver that
439 /// always returns `None` would pass the test above alone).
440 #[test]
441 fn a_registered_component_resolves_to_its_crate_and_file() {
442 let known = crate::errcode::REGISTRY.first().expect("the registry is not empty");
443 let (text, ok) = badge_text(known.component, "ignored title");
444 assert!(ok, "{} is in the registry but did not report registered", known.component);
445 assert_eq!(text, format!("{}/ignored title", known.component), "registered ⇒ component/pane");
446 let (dir, file) = resolve(known.component).expect("resolves");
447 assert!(!dir.is_empty() && !file.is_empty(), "a resolved id must name a real place");
448 }
449}