Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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);
    }
}