facett_core/empty.rs
1//! **The NAMED empty state** — the one honest thing a pane draws when its feed has
2//! nothing to show.
3//!
4//! ## Why this exists (2026-09-10)
5//! An audit of the dwarves-ui panes found seven bodies that, with no data, drew a
6//! *fixture*: a hand-written roster of plausible rows (`#1038`…`#1042`, `SampleVault`,
7//! seven green pipeline chips) tagged with a `source: "fixture"` marker and shipped.
8//! The marker was honest; the pixels were not. A reader sees rows, and rows mean data.
9//!
10//! The replacement is this component, and its contract is one sentence: **a pane with
11//! no data names the feed it does not have.** Never a row that was never measured.
12//!
13//! ```text
14//! ⌀ no rows
15//! dwarf.v1/Dwarf.History — the forge run ledger
16//! the server at http://127.0.0.1:7900 answered with 0 runs
17//! → `dwarves run <pipeline>` records one
18//! ```
19//!
20//! Three fields, and the FIRST TWO ARE REQUIRED because they are the two questions a
21//! reader actually has: **which feed** (so they can go look at it themselves) and
22//! **why it is empty** (unreachable ≠ reachable-and-empty ≠ not-configured — three
23//! very different situations that an unadorned "no data" flattens into one).
24//! [`hint`](EmptyState::hint) is optional and is the only decorative field.
25//!
26//! ## The oracle half
27//! [`EmptyState::state_json`] folds the same three fields into the pane's own
28//! `state_json`, so a test can assert the pane is in a named empty state — and, more
29//! to the point, can assert it is NOT rendering the deleted sample ids. That is the
30//! shape of the test each converted pane carries.
31//!
32//! No colour decision, no layout decision, no dependency: `egui` + `serde_json`, both
33//! already in this kernel.
34
35use serde::{Deserialize, Serialize};
36
37/// The reason a feed produced nothing — three situations a bare "no data" flattens
38/// into one, and which a reader needs told apart.
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "kebab-case")]
41pub enum Cause {
42 /// The feed was READ and is genuinely empty (0 rows / 0 runs / 0 entries). The
43 /// green case: nothing is broken, there is simply nothing recorded yet.
44 NoRows,
45 /// The feed could not be reached (server down, socket refused, RPC error). NOT
46 /// the same as empty — an unreachable feed says nothing about what it holds.
47 Unreachable,
48 /// The feed is not wired up in this build/config at all (a missing env var, an
49 /// unbuilt feature, a vault that was never created).
50 NotConfigured,
51}
52
53impl Cause {
54 /// The short word drawn in the badge line.
55 pub fn label(self) -> &'static str {
56 match self {
57 Cause::NoRows => "no rows",
58 Cause::Unreachable => "unreachable",
59 Cause::NotConfigured => "not configured",
60 }
61 }
62
63 /// The machine word `state_json` carries.
64 pub fn tag(self) -> &'static str {
65 match self {
66 Cause::NoRows => "no-rows",
67 Cause::Unreachable => "unreachable",
68 Cause::NotConfigured => "not-configured",
69 }
70 }
71}
72
73/// A pane's named empty state: WHICH feed is empty, WHY, and (optionally) what fills it.
74///
75/// Construct it at the point the read comes back empty — the constructor takes the feed
76/// name and the cause, because a pane that cannot name its own feed has no business
77/// drawing an empty state for it.
78#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
79pub struct EmptyState {
80 /// The feed, named the way an operator would go look it up: an RPC path
81 /// (`dwarf.v1/Dwarf.History`), a warehouse table (`mimir/binary_facts`), a file
82 /// (`$DWARVES_FLEET_RUN`). Never a vague noun like "the backend".
83 pub feed: String,
84 /// Why it produced nothing.
85 pub cause: Cause,
86 /// The concrete detail behind the cause — the endpoint that refused, the path that
87 /// was absent, the row count that was zero. One line, plain words.
88 pub detail: String,
89 /// Optional: the action that would fill the feed (a command, an env var to set).
90 pub hint: String,
91}
92
93impl EmptyState {
94 /// A named empty state. `feed` and `detail` are both required — see the module note.
95 pub fn new(feed: impl Into<String>, cause: Cause, detail: impl Into<String>) -> Self {
96 Self { feed: feed.into(), cause, detail: detail.into(), hint: String::new() }
97 }
98
99 /// The feed was read and holds nothing (0 rows).
100 pub fn no_rows(feed: impl Into<String>, detail: impl Into<String>) -> Self {
101 Self::new(feed, Cause::NoRows, detail)
102 }
103
104 /// The feed could not be reached.
105 pub fn unreachable(feed: impl Into<String>, detail: impl Into<String>) -> Self {
106 Self::new(feed, Cause::Unreachable, detail)
107 }
108
109 /// The feed is not wired in this build/config.
110 pub fn not_configured(feed: impl Into<String>, detail: impl Into<String>) -> Self {
111 Self::new(feed, Cause::NotConfigured, detail)
112 }
113
114 /// Add the action that would fill the feed.
115 pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
116 self.hint = hint.into();
117 self
118 }
119
120 /// **Draw it.** A dim glyph, the cause word, the feed name in monospace (it is a
121 /// lookup key, so it is typeface-distinct from prose), the detail, and the hint.
122 /// Nothing here is clickable and nothing is coloured green — an empty state that
123 /// looks like a healthy result is the bug this component exists to prevent.
124 pub fn ui(&self, ui: &mut egui::Ui) {
125 ui.add_space(6.0);
126 ui.horizontal(|ui| {
127 ui.weak("⌀");
128 ui.label(self.cause.label());
129 });
130 ui.horizontal_wrapped(|ui| {
131 ui.monospace(&self.feed);
132 if !self.detail.is_empty() {
133 ui.weak("—");
134 ui.weak(&self.detail);
135 }
136 });
137 if !self.hint.is_empty() {
138 ui.weak(format!("→ {}", self.hint));
139 }
140 ui.add_space(6.0);
141 }
142
143 /// The oracle half — fold into the pane's own `state_json` so a test can assert the
144 /// pane is in a NAMED empty state (and is therefore not painting sample rows).
145 pub fn state_json(&self) -> serde_json::Value {
146 serde_json::json!({
147 "empty": true,
148 "feed": self.feed,
149 "cause": self.cause.tag(),
150 "detail": self.detail,
151 "hint": self.hint,
152 })
153 }
154
155 /// A one-line render for a host that has no room for the block form (a status bar,
156 /// a tooltip, a `#[test]` message).
157 pub fn line(&self) -> String {
158 let mut s = format!("{} — {} ({})", self.feed, self.cause.label(), self.detail);
159 if !self.hint.is_empty() {
160 s.push_str(&format!(" → {}", self.hint));
161 }
162 s
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 /// The three causes stay TOLD APART on the wire — the whole point of the enum. A
171 /// pane that cannot say "unreachable" separately from "read it, it's empty" is
172 /// back to the flattened "no data" this replaced. RED-when-broken: collapse two
173 /// tags to one string and this fails.
174 #[test]
175 fn the_three_causes_are_distinct_on_the_wire() {
176 let tags: Vec<&str> =
177 [Cause::NoRows, Cause::Unreachable, Cause::NotConfigured].iter().map(|c| c.tag()).collect();
178 assert_eq!(tags, vec!["no-rows", "unreachable", "not-configured"]);
179 let mut sorted = tags.clone();
180 sorted.sort_unstable();
181 sorted.dedup();
182 assert_eq!(sorted.len(), 3, "three causes, three tags");
183 }
184
185 /// `state_json` NAMES the feed — that is the assertion every converted pane's test
186 /// leans on. It also carries `empty: true`, so "is this pane showing data?" is one
187 /// field lookup rather than a row count a fixture could satisfy.
188 #[test]
189 fn state_json_names_the_feed_and_flags_empty() {
190 let e = EmptyState::unreachable("dwarf.v1/Dwarf.History", "http://127.0.0.1:7900 refused")
191 .with_hint("start dwarves-server");
192 let j = e.state_json();
193 assert_eq!(j["empty"], true);
194 assert_eq!(j["feed"], "dwarf.v1/Dwarf.History");
195 assert_eq!(j["cause"], "unreachable");
196 assert_eq!(j["detail"], "http://127.0.0.1:7900 refused");
197 assert_eq!(j["hint"], "start dwarves-server");
198 assert!(e.line().contains("dwarf.v1/Dwarf.History"), "the line form names it too: {}", e.line());
199 }
200
201 /// Round-trips (the whole struct rides a pane's serialized state).
202 #[test]
203 fn round_trips() {
204 let e = EmptyState::no_rows("mimir/binary_facts", "0 rows in workspace nordisk");
205 let back: EmptyState = serde_json::from_str(&serde_json::to_string(&e).unwrap()).unwrap();
206 assert_eq!(e, back);
207 }
208}