facett-core/src/empty.rs
//! **The NAMED empty state** — the one honest thing a pane draws when its feed has
//! nothing to show.
//!
//! ## Why this exists (2026-09-10)
//! An audit of the dwarves-ui panes found seven bodies that, with no data, drew a
//! *fixture*: a hand-written roster of plausible rows (`#1038`…`#1042`, `SampleVault`,
//! seven green pipeline chips) tagged with a `source: "fixture"` marker and shipped.
//! The marker was honest; the pixels were not. A reader sees rows, and rows mean data.
//!
//! The replacement is this component, and its contract is one sentence: **a pane with
//! no data names the feed it does not have.** Never a row that was never measured.
//!
//! ```text
//! ⌀ no rows
//! dwarf.v1/Dwarf.History — the forge run ledger
//! the server at http://127.0.0.1:7900 answered with 0 runs
//! → `dwarves run <pipeline>` records one
//! ```
//!
//! Three fields, and the FIRST TWO ARE REQUIRED because they are the two questions a
//! reader actually has: **which feed** (so they can go look at it themselves) and
//! **why it is empty** (unreachable ≠ reachable-and-empty ≠ not-configured — three
//! very different situations that an unadorned "no data" flattens into one).
//! [`hint`](EmptyState::hint) is optional and is the only decorative field.
//!
//! ## The oracle half
//! [`EmptyState::state_json`] folds the same three fields into the pane's own
//! `state_json`, so a test can assert the pane is in a named empty state — and, more
//! to the point, can assert it is NOT rendering the deleted sample ids. That is the
//! shape of the test each converted pane carries.
//!
//! No colour decision, no layout decision, no dependency: `egui` + `serde_json`, both
//! already in this kernel.
use serde::{Deserialize, Serialize};
/// The reason a feed produced nothing — three situations a bare "no data" flattens
/// into one, and which a reader needs told apart.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Cause {
/// The feed was READ and is genuinely empty (0 rows / 0 runs / 0 entries). The
/// green case: nothing is broken, there is simply nothing recorded yet.
NoRows,
/// The feed could not be reached (server down, socket refused, RPC error). NOT
/// the same as empty — an unreachable feed says nothing about what it holds.
Unreachable,
/// The feed is not wired up in this build/config at all (a missing env var, an
/// unbuilt feature, a vault that was never created).
NotConfigured,
}
impl Cause {
/// The short word drawn in the badge line.
pub fn label(self) -> &'static str {
match self {
Cause::NoRows => "no rows",
Cause::Unreachable => "unreachable",
Cause::NotConfigured => "not configured",
}
}
/// The machine word `state_json` carries.
pub fn tag(self) -> &'static str {
match self {
Cause::NoRows => "no-rows",
Cause::Unreachable => "unreachable",
Cause::NotConfigured => "not-configured",
}
}
}
/// A pane's named empty state: WHICH feed is empty, WHY, and (optionally) what fills it.
///
/// Construct it at the point the read comes back empty — the constructor takes the feed
/// name and the cause, because a pane that cannot name its own feed has no business
/// drawing an empty state for it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EmptyState {
/// The feed, named the way an operator would go look it up: an RPC path
/// (`dwarf.v1/Dwarf.History`), a warehouse table (`mimir/binary_facts`), a file
/// (`$DWARVES_FLEET_RUN`). Never a vague noun like "the backend".
pub feed: String,
/// Why it produced nothing.
pub cause: Cause,
/// The concrete detail behind the cause — the endpoint that refused, the path that
/// was absent, the row count that was zero. One line, plain words.
pub detail: String,
/// Optional: the action that would fill the feed (a command, an env var to set).
pub hint: String,
}
impl EmptyState {
/// A named empty state. `feed` and `detail` are both required — see the module note.
pub fn new(feed: impl Into<String>, cause: Cause, detail: impl Into<String>) -> Self {
Self { feed: feed.into(), cause, detail: detail.into(), hint: String::new() }
}
/// The feed was read and holds nothing (0 rows).
pub fn no_rows(feed: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(feed, Cause::NoRows, detail)
}
/// The feed could not be reached.
pub fn unreachable(feed: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(feed, Cause::Unreachable, detail)
}
/// The feed is not wired in this build/config.
pub fn not_configured(feed: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(feed, Cause::NotConfigured, detail)
}
/// Add the action that would fill the feed.
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = hint.into();
self
}
/// **Draw it.** A dim glyph, the cause word, the feed name in monospace (it is a
/// lookup key, so it is typeface-distinct from prose), the detail, and the hint.
/// Nothing here is clickable and nothing is coloured green — an empty state that
/// looks like a healthy result is the bug this component exists to prevent.
pub fn ui(&self, ui: &mut egui::Ui) {
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.weak("⌀");
ui.label(self.cause.label());
});
ui.horizontal_wrapped(|ui| {
ui.monospace(&self.feed);
if !self.detail.is_empty() {
ui.weak("—");
ui.weak(&self.detail);
}
});
if !self.hint.is_empty() {
ui.weak(format!("→ {}", self.hint));
}
ui.add_space(6.0);
}
/// The oracle half — fold into the pane's own `state_json` so a test can assert the
/// pane is in a NAMED empty state (and is therefore not painting sample rows).
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"empty": true,
"feed": self.feed,
"cause": self.cause.tag(),
"detail": self.detail,
"hint": self.hint,
})
}
/// A one-line render for a host that has no room for the block form (a status bar,
/// a tooltip, a `#[test]` message).
pub fn line(&self) -> String {
let mut s = format!("{} — {} ({})", self.feed, self.cause.label(), self.detail);
if !self.hint.is_empty() {
s.push_str(&format!(" → {}", self.hint));
}
s
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The three causes stay TOLD APART on the wire — the whole point of the enum. A
/// pane that cannot say "unreachable" separately from "read it, it's empty" is
/// back to the flattened "no data" this replaced. RED-when-broken: collapse two
/// tags to one string and this fails.
#[test]
fn the_three_causes_are_distinct_on_the_wire() {
let tags: Vec<&str> =
[Cause::NoRows, Cause::Unreachable, Cause::NotConfigured].iter().map(|c| c.tag()).collect();
assert_eq!(tags, vec!["no-rows", "unreachable", "not-configured"]);
let mut sorted = tags.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), 3, "three causes, three tags");
}
/// `state_json` NAMES the feed — that is the assertion every converted pane's test
/// leans on. It also carries `empty: true`, so "is this pane showing data?" is one
/// field lookup rather than a row count a fixture could satisfy.
#[test]
fn state_json_names_the_feed_and_flags_empty() {
let e = EmptyState::unreachable("dwarf.v1/Dwarf.History", "http://127.0.0.1:7900 refused")
.with_hint("start dwarves-server");
let j = e.state_json();
assert_eq!(j["empty"], true);
assert_eq!(j["feed"], "dwarf.v1/Dwarf.History");
assert_eq!(j["cause"], "unreachable");
assert_eq!(j["detail"], "http://127.0.0.1:7900 refused");
assert_eq!(j["hint"], "start dwarves-server");
assert!(e.line().contains("dwarf.v1/Dwarf.History"), "the line form names it too: {}", e.line());
}
/// Round-trips (the whole struct rides a pane's serialized state).
#[test]
fn round_trips() {
let e = EmptyState::no_rows("mimir/binary_facts", "0 rows in workspace nordisk");
let back: EmptyState = serde_json::from_str(&serde_json::to_string(&e).unwrap()).unwrap();
assert_eq!(e, back);
}
}