facett-core 0.1.19

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **facett-core::severity** — the STRUCTURAL error signal for the Robot-UI HARD
//! GATE (RESOLVED decision (a)).
//!
//! Every rendered pane/atom can report a first-class [`Severity`] as part of the
//! component contract, surfaced via the [`Panel`](crate::Panel)/[`Facet`](crate::Facet)
//! seam and the [`a11y`](crate::a11y) semantics. The Robot-UI gate asserts on THIS
//! structural severity — a pane that reports [`Severity::Error`] is RED and **FAILS
//! the gate** — instead of scanning rendered *text* for the `ERROR_MARKERS`
//! substring list.
//!
//! The old `nornir_robotui::error_atoms()` / `ERROR_MARKERS` substring scan stays
//! as a **MIGRATION FALLBACK only**: until a pane overrides [`Facet::severity`],
//! it reports the [`Severity::Info`] floor and the string scan is what still
//! catches a leaked error label. As panes adopt `severity()`, the gate reads the
//! structural signal directly and never has to guess from text.
//!
//! ## The contract in one line
//! `pane.severity() == max(atom.severity() for atom in pane)`, and the gate is
//! `green ⟺ no driven pane reports Severity::Error`.

use serde::{Deserialize, Serialize};

/// The structural severity a pane/atom carries.
///
/// **Ordered** `Info < Warning < Error` so a pane's severity is the [`max`](Severity::max)
/// over its atoms and `>= Error` is the red gate. [`Default`] is [`Severity::Info`]
/// (the green floor) — so the field is purely ADDITIVE: a component that never sets
/// it stays green.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
    /// The green floor — normal, load-bearing data. The default.
    #[default]
    Info,
    /// A degraded-but-usable surface (a soft warning, a partial load). Does NOT
    /// fail the gate on its own, but is recorded so it is visible.
    Warning,
    /// A RED surface — the pane surfaced a failure (a load error, an unavailable
    /// backend, a panic). This is the signal the Robot-UI HARD GATE fails on.
    Error,
}

impl Severity {
    /// The stable lowercase tag stored in `state_json` / warehouse rows / the
    /// `FacetRow::severity` the discovery layer reads.
    pub fn as_str(self) -> &'static str {
        match self {
            Severity::Info => "info",
            Severity::Warning => "warning",
            Severity::Error => "error",
        }
    }

    /// Parse a severity tag (case-insensitive). Unknown → `None`. Lenient synonyms:
    /// `err`/`red` → [`Severity::Error`], `warn`/`amber` → [`Severity::Warning`].
    pub fn parse(s: &str) -> Option<Severity> {
        match s.trim().to_ascii_lowercase().as_str() {
            "info" | "ok" | "green" => Some(Severity::Info),
            "warning" | "warn" | "amber" => Some(Severity::Warning),
            "error" | "err" | "red" => Some(Severity::Error),
            _ => None,
        }
    }

    /// Is this the RED, gate-failing severity?
    pub fn is_error(self) -> bool {
        self == Severity::Error
    }

    /// The worse (higher-ordered) of two severities — the fold a pane uses to
    /// aggregate its atoms' severities.
    pub fn max(self, other: Severity) -> Severity {
        core::cmp::max(self, other)
    }
}

/// Fold an iterator of severities into the WORST (max) one — a pane's structural
/// severity is the worst over its rendered atoms. An empty iterator yields the
/// [`Severity::Info`] floor (a pane with no atoms is green).
pub fn worst<I: IntoIterator<Item = Severity>>(iter: I) -> Severity {
    iter.into_iter().fold(Severity::Info, Severity::max)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_is_the_green_floor() {
        assert_eq!(Severity::default(), Severity::Info);
        assert!(!Severity::default().is_error());
    }

    #[test]
    fn ordering_puts_error_on_top() {
        assert!(Severity::Info < Severity::Warning);
        assert!(Severity::Warning < Severity::Error);
        assert_eq!(Severity::Info.max(Severity::Error), Severity::Error);
        assert_eq!(Severity::Warning.max(Severity::Info), Severity::Warning);
    }

    #[test]
    fn worst_folds_to_the_red_and_empty_is_green() {
        assert_eq!(worst([]), Severity::Info, "no atoms → green");
        assert_eq!(
            worst([Severity::Info, Severity::Info]),
            Severity::Info,
            "all green → green"
        );
        assert_eq!(
            worst([Severity::Info, Severity::Warning, Severity::Info]),
            Severity::Warning
        );
        assert_eq!(
            worst([Severity::Info, Severity::Error, Severity::Warning]),
            Severity::Error,
            "one red pane atom makes the pane red"
        );
    }

    #[test]
    fn tag_roundtrips_and_is_lenient() {
        for s in [Severity::Info, Severity::Warning, Severity::Error] {
            assert_eq!(Severity::parse(s.as_str()), Some(s), "roundtrip {s:?}");
        }
        assert_eq!(Severity::parse("RED"), Some(Severity::Error));
        assert_eq!(Severity::parse(" warn "), Some(Severity::Warning));
        assert_eq!(Severity::parse("green"), Some(Severity::Info));
        assert_eq!(Severity::parse("bogus"), None);
    }

    #[test]
    fn serde_uses_the_lowercase_tag() {
        let j = serde_json::to_string(&Severity::Error).unwrap();
        assert_eq!(j, "\"error\"");
        let back: Severity = serde_json::from_str("\"warning\"").unwrap();
        assert_eq!(back, Severity::Warning);
    }
}