facett-core/src/severity.rs
//! **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);
}
}