use serde_json::{Map, Value, json};
#[derive(Debug, Clone)]
pub enum CheckStatus {
Pass,
Skip(String),
Warning(String),
Fail(String),
Broken(String),
}
impl CheckStatus {
pub fn wire_result(&self) -> &'static str {
match self {
CheckStatus::Pass => "passed",
CheckStatus::Skip(_) => "skipped",
CheckStatus::Warning(_) => "warning",
CheckStatus::Fail(_) => "failed",
CheckStatus::Broken(_) => "broken",
}
}
pub fn is_fatal(&self) -> bool {
matches!(self, CheckStatus::Fail(_))
}
pub fn is_skip(&self) -> bool {
matches!(self, CheckStatus::Skip(_))
}
pub fn reason(&self) -> Option<&str> {
match self {
CheckStatus::Pass => None,
CheckStatus::Skip(r)
| CheckStatus::Warning(r)
| CheckStatus::Fail(r)
| CheckStatus::Broken(r) => Some(r),
}
}
}
#[derive(Debug, Clone)]
pub struct Check {
pub name: &'static str,
pub status: CheckStatus,
pub summary: String,
pub details: Map<String, Value>,
pub payload_extras: Map<String, Value>,
}
impl Check {
pub fn pass(name: &'static str, summary: impl Into<String>) -> Self {
Self {
name,
status: CheckStatus::Pass,
summary: summary.into(),
details: Map::new(),
payload_extras: Map::new(),
}
}
pub fn skip(name: &'static str, summary: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
name,
status: CheckStatus::Skip(reason.into()),
summary: summary.into(),
details: Map::new(),
payload_extras: Map::new(),
}
}
pub fn warning(
name: &'static str,
summary: impl Into<String>,
reason: impl Into<String>,
) -> Self {
Self {
name,
status: CheckStatus::Warning(reason.into()),
summary: summary.into(),
details: Map::new(),
payload_extras: Map::new(),
}
}
pub fn fail(name: &'static str, summary: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
name,
status: CheckStatus::Fail(reason.into()),
summary: summary.into(),
details: Map::new(),
payload_extras: Map::new(),
}
}
pub fn broken(
name: &'static str,
summary: impl Into<String>,
reason: impl Into<String>,
) -> Self {
Self {
name,
status: CheckStatus::Broken(reason.into()),
summary: summary.into(),
details: Map::new(),
payload_extras: Map::new(),
}
}
pub fn with_detail(mut self, key: &str, value: impl Into<Value>) -> Self {
self.details.insert(key.to_string(), value.into());
self
}
pub fn with_details(mut self, details: Map<String, Value>) -> Self {
self.details = details;
self
}
pub fn with_payload_extra(mut self, key: &str, value: impl Into<Value>) -> Self {
self.payload_extras.insert(key.to_string(), value.into());
self
}
pub fn to_wire(&self) -> Value {
let mut obj = Map::new();
obj.insert("check".into(), self.name.into());
obj.insert("result".into(), self.status.wire_result().into());
for (k, v) in &self.details {
obj.insert(k.clone(), v.clone());
}
obj.insert("summary".into(), self.summary.clone().into());
if let Some(reason) = self.status.reason() {
obj.insert("reason".into(), reason.into());
}
Value::Object(obj)
}
pub fn to_streaming_json(&self) -> Value {
let (status, reason) = match &self.status {
CheckStatus::Pass => ("pass", None),
CheckStatus::Skip(r) => ("skip", Some(r.as_str())),
CheckStatus::Warning(r) => ("warning", Some(r.as_str())),
CheckStatus::Fail(r) => ("fail", Some(r.as_str())),
CheckStatus::Broken(r) => ("broken", Some(r.as_str())),
};
let mut obj = json!({
"name": self.name,
"status": status,
"summary": self.summary,
"details": Value::Object(self.details.clone()),
});
if let Some(r) = reason {
obj["reason"] = Value::String(r.to_string());
}
obj
}
pub fn from_streaming_json(
value: &Value,
name_resolver: impl FnOnce(&str) -> Option<&'static str>,
) -> Option<Self> {
let name_str = value.get("name")?.as_str()?;
let name = name_resolver(name_str)?;
let status_str = value.get("status")?.as_str()?;
let reason = value
.get("reason")
.and_then(Value::as_str)
.map(str::to_string);
let status = match (status_str, reason) {
("pass", _) => CheckStatus::Pass,
("skip", Some(r)) => CheckStatus::Skip(r),
("warning", Some(r)) => CheckStatus::Warning(r),
("fail", Some(r)) => CheckStatus::Fail(r),
("broken", Some(r)) => CheckStatus::Broken(r),
_ => return None,
};
let summary = value.get("summary")?.as_str()?.to_string();
let details = value
.get("details")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
Some(Self {
name,
status,
summary,
details,
payload_extras: Map::new(),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OverallResult {
Healthy,
Degraded,
Failing,
}
impl OverallResult {
pub fn from_checks(checks: &[Check]) -> Self {
if checks.iter().any(|c| c.status.is_fatal()) {
OverallResult::Failing
} else if checks
.iter()
.any(|c| matches!(c.status, CheckStatus::Warning(_) | CheckStatus::Broken(_)))
{
OverallResult::Degraded
} else {
OverallResult::Healthy
}
}
pub fn label(self) -> &'static str {
match self {
OverallResult::Healthy => "HEALTHY",
OverallResult::Degraded => "DEGRADED",
OverallResult::Failing => "FAILING",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wire_results() {
assert_eq!(CheckStatus::Pass.wire_result(), "passed");
assert_eq!(CheckStatus::Skip("r".into()).wire_result(), "skipped");
assert_eq!(CheckStatus::Warning("r".into()).wire_result(), "warning");
assert_eq!(CheckStatus::Fail("r".into()).wire_result(), "failed");
assert_eq!(CheckStatus::Broken("r".into()).wire_result(), "broken");
}
#[test]
fn warning_is_not_fatal() {
assert!(!CheckStatus::Warning("w".into()).is_fatal());
}
#[test]
fn fail_is_fatal() {
assert!(CheckStatus::Fail("f".into()).is_fatal());
}
#[test]
fn skip_is_not_fatal() {
let s = CheckStatus::Skip("reason".into());
assert!(!s.is_fatal());
assert!(s.is_skip());
}
#[test]
fn broken_is_not_fatal() {
assert!(!CheckStatus::Broken("reason".into()).is_fatal());
}
#[test]
fn skip_does_not_change_overall_result() {
let with_skip = vec![Check::pass("a", ""), Check::skip("b", "", "r")];
assert_eq!(
OverallResult::from_checks(&with_skip),
OverallResult::Healthy
);
}
#[test]
fn overall_from_checks() {
let healthy = vec![Check::pass("a", "")];
assert_eq!(OverallResult::from_checks(&healthy), OverallResult::Healthy);
let degraded = vec![Check::pass("a", ""), Check::warning("b", "", "x")];
assert_eq!(
OverallResult::from_checks(°raded),
OverallResult::Degraded
);
let broken = vec![Check::pass("a", ""), Check::broken("b", "", "x")];
assert_eq!(OverallResult::from_checks(&broken), OverallResult::Degraded);
let failing = vec![Check::warning("a", "", "x"), Check::fail("b", "", "y")];
assert_eq!(OverallResult::from_checks(&failing), OverallResult::Failing);
}
#[test]
fn check_to_wire_pass() {
let c = Check::pass("db_connect", "ok").with_detail("latency_ms", 3);
let v = c.to_wire();
assert_eq!(v["check"], "db_connect");
assert_eq!(v["result"], "passed");
assert_eq!(v["latency_ms"], 3);
assert_eq!(v["summary"], "ok");
assert!(v.get("reason").is_none());
}
#[test]
fn check_to_wire_statuses() {
let warn = Check::warning("disk_free", "20% used", "below threshold");
let v = warn.to_wire();
assert_eq!(v["result"], "warning");
assert_eq!(v["summary"], "20% used");
assert_eq!(v["reason"], "below threshold");
let fail = Check::fail("disk_free", "1% free", "out of space");
assert_eq!(fail.to_wire()["result"], "failed");
assert_eq!(fail.to_wire()["reason"], "out of space");
let broken = Check::broken("x", "query broken", "no such column");
assert_eq!(broken.to_wire()["result"], "broken");
let skip = Check::skip("x", "n/a", "central-only");
assert_eq!(skip.to_wire()["result"], "skipped");
assert_eq!(skip.to_wire()["reason"], "central-only");
}
#[test]
fn broken_round_trips_through_streaming_json() {
let c = Check::broken("x", "query broken", "no such column");
let v = c.to_streaming_json();
assert_eq!(v["status"], "broken");
assert_eq!(v["reason"], "no such column");
let back = Check::from_streaming_json(&v, |_| Some("x")).unwrap();
assert!(matches!(back.status, CheckStatus::Broken(r) if r == "no such column"));
}
}