use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum HealthState {
Unknown,
OnTrack,
NeedsAttention,
AtRisk,
}
impl HealthState {
pub const fn as_str(self) -> &'static str {
match self {
Self::Unknown => "unknown",
Self::OnTrack => "on_track",
Self::NeedsAttention => "needs_attention",
Self::AtRisk => "at_risk",
}
}
pub const fn label(self) -> &'static str {
match self {
Self::Unknown => "Unknown",
Self::OnTrack => "On track",
Self::NeedsAttention => "Needs attention",
Self::AtRisk => "At risk",
}
}
pub const ALL: [Self; 4] = [
Self::Unknown,
Self::OnTrack,
Self::NeedsAttention,
Self::AtRisk,
];
pub fn parse(raw: &str) -> std::result::Result<Self, StateParseError> {
let normalised: String = raw
.trim()
.chars()
.filter(|c| !matches!(c, '-' | '_' | ' '))
.map(|c| c.to_ascii_lowercase())
.collect();
match normalised.as_str() {
"unknown" => Ok(Self::Unknown),
"ontrack" => Ok(Self::OnTrack),
"needsattention" => Ok(Self::NeedsAttention),
"atrisk" => Ok(Self::AtRisk),
_ => Err(StateParseError {
raw: raw.to_string(),
}),
}
}
pub const fn severity(self) -> u8 {
match self {
Self::Unknown => 0,
Self::OnTrack => 1,
Self::NeedsAttention => 2,
Self::AtRisk => 3,
}
}
}
impl fmt::Display for HealthState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StateParseError {
pub raw: String,
}
impl StateParseError {
pub fn render(&self) -> String {
format!(
"`{}` is not a health state; the states are: {}",
self.raw,
HealthState::ALL
.iter()
.map(|state| state.as_str())
.collect::<Vec<_>>()
.join(", ")
)
}
}
impl fmt::Display for StateParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.render())
}
}
impl std::error::Error for StateParseError {}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
#[test]
fn health_state_spellings_round_trip_and_are_stable() {
for state in HealthState::ALL {
assert_eq!(HealthState::parse(state.as_str()).unwrap(), state);
assert_eq!(state.to_string(), state.as_str());
assert_ne!(state.label(), "");
}
assert_eq!(HealthState::Unknown.as_str(), "unknown");
assert_eq!(HealthState::OnTrack.as_str(), "on_track");
assert_eq!(HealthState::NeedsAttention.as_str(), "needs_attention");
assert_eq!(HealthState::AtRisk.as_str(), "at_risk");
}
#[test]
fn health_state_parse_accepts_how_a_human_spells_them() {
for raw in [
"at_risk",
"at-risk",
"at risk",
"At Risk",
"AT_RISK",
" at_risk ",
] {
assert_eq!(
HealthState::parse(raw).expect("a spelling of at_risk"),
HealthState::AtRisk,
"{raw:?}"
);
}
for raw in ["OnTrack", "on track", "on-track", "ontrack", "ONTRACK"] {
assert_eq!(
HealthState::parse(raw).expect("a spelling of on_track"),
HealthState::OnTrack,
"{raw:?}"
);
}
}
#[test]
fn health_state_parse_refuses_anything_else_by_name() {
for raw in ["fine", "healthy", "green", "", "done", "at_riskish"] {
let error = HealthState::parse(raw).expect_err("not a state: {raw}");
assert_eq!(error.raw, raw);
let rendered = error.render();
assert!(
rendered.contains("on_track") && rendered.contains("at_risk"),
"{rendered}"
);
assert!(
rendered.contains(&format!("`{raw}`")) || raw.is_empty(),
"{rendered}"
);
}
}
#[test]
fn health_state_severity_orders_the_three_real_states() {
assert!(HealthState::AtRisk.severity() > HealthState::NeedsAttention.severity());
assert!(HealthState::NeedsAttention.severity() > HealthState::OnTrack.severity());
assert!(
HealthState::Unknown.severity() < HealthState::OnTrack.severity(),
"`unknown` is the absence of an answer, not a worse health — the policy says what it resolves to"
);
}
}