Skip to main content

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}