use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::posture::Posture;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CheckStatus {
Ok,
Warn,
Red,
NotApplicable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum DiagnosisStatus {
Healthy,
Degraded,
Red,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct DiagnosisCheck {
pub name: String,
pub status: CheckStatus,
pub detail: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remedy: Option<String>,
}
impl DiagnosisCheck {
fn new(name: &str, status: CheckStatus, detail: impl Into<String>) -> Self {
Self {
name: name.to_string(),
status,
detail: detail.into(),
remedy: None,
}
}
pub fn ok(name: &str, detail: impl Into<String>) -> Self {
Self::new(name, CheckStatus::Ok, detail)
}
pub fn not_applicable(name: &str, detail: impl Into<String>) -> Self {
Self::new(name, CheckStatus::NotApplicable, detail)
}
pub fn warn(name: &str, detail: impl Into<String>) -> Self {
Self::new(name, CheckStatus::Warn, detail)
}
pub fn red(name: &str, detail: impl Into<String>) -> Self {
Self::new(name, CheckStatus::Red, detail)
}
pub fn with_remedy(mut self, remedy: impl Into<String>) -> Self {
self.remedy = Some(remedy.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct TrustDiagnosis {
pub posture: Posture,
pub overall: DiagnosisStatus,
pub checks: Vec<DiagnosisCheck>,
}
impl TrustDiagnosis {
pub fn from_checks(posture: Posture, checks: Vec<DiagnosisCheck>) -> Self {
let overall = if checks.iter().any(|c| c.status == CheckStatus::Red) {
DiagnosisStatus::Red
} else if checks.iter().any(|c| c.status == CheckStatus::Warn) {
DiagnosisStatus::Degraded
} else {
DiagnosisStatus::Healthy
};
Self {
posture,
overall,
checks,
}
}
pub fn is_red(&self) -> bool {
self.overall == DiagnosisStatus::Red
}
pub fn exit_code(&self) -> i32 {
if self.is_red() {
1
} else {
0
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rollup_is_worst_check_wins() {
let healthy = TrustDiagnosis::from_checks(
Posture::OPEN,
vec![
DiagnosisCheck::ok("a", "fine"),
DiagnosisCheck::not_applicable("b", "n/a"),
],
);
assert_eq!(healthy.overall, DiagnosisStatus::Healthy);
assert_eq!(healthy.exit_code(), 0);
assert!(!healthy.is_red());
let degraded = TrustDiagnosis::from_checks(
Posture::OPEN,
vec![
DiagnosisCheck::ok("a", "fine"),
DiagnosisCheck::warn("b", "soon"),
],
);
assert_eq!(degraded.overall, DiagnosisStatus::Degraded);
assert_eq!(
degraded.exit_code(),
0,
"warnings are loud but not a failure"
);
let red = TrustDiagnosis::from_checks(
Posture::OPEN,
vec![
DiagnosisCheck::warn("a", "soon"),
DiagnosisCheck::red("b", "broken"),
],
);
assert_eq!(red.overall, DiagnosisStatus::Red);
assert_eq!(red.exit_code(), 1, "RED must fail loud (non-zero)");
assert!(red.is_red());
}
#[test]
fn check_remedy_is_optional_and_omitted_when_absent() {
let c = DiagnosisCheck::ok("posture", "Authenticated");
let json = serde_json::to_value(&c).unwrap();
assert!(json.get("remedy").is_none(), "no remedy field when None");
let c =
DiagnosisCheck::red("renewal", "expired").with_remedy("koi certmesh join <endpoint>");
let json = serde_json::to_value(&c).unwrap();
assert_eq!(json["remedy"], "koi certmesh join <endpoint>");
assert_eq!(json["status"], "red");
}
#[test]
fn status_serializes_snake_case() {
assert_eq!(
serde_json::to_string(&CheckStatus::NotApplicable).unwrap(),
r#""not_applicable""#
);
assert_eq!(
serde_json::to_string(&DiagnosisStatus::Degraded).unwrap(),
r#""degraded""#
);
}
#[test]
fn diagnosis_round_trips() {
let d = TrustDiagnosis::from_checks(
Posture::new(true, false),
vec![DiagnosisCheck::ok("posture", "Authenticated")],
);
let json = serde_json::to_string(&d).unwrap();
let back: TrustDiagnosis = serde_json::from_str(&json).unwrap();
assert_eq!(d, back);
}
}