use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HealthStatus {
Healthy,
Degraded(String),
Unhealthy(String),
}
impl HealthStatus {
pub fn is_healthy(&self) -> bool {
matches!(self, Self::Healthy)
}
pub fn is_unhealthy(&self) -> bool {
matches!(self, Self::Unhealthy(_))
}
}
#[derive(Debug, Clone)]
pub struct CompositeStatus {
pub components: Vec<(String, HealthStatus)>,
}
impl CompositeStatus {
pub fn is_healthy(&self) -> bool {
self.components.iter().all(|(_, s)| s.is_healthy())
}
pub fn has_unhealthy(&self) -> bool {
self.components.iter().any(|(_, s)| s.is_unhealthy())
}
pub fn for_external(&self) -> HealthStatus {
self.worst()
}
pub fn worst(&self) -> HealthStatus {
if self.has_unhealthy() {
let msgs: Vec<&str> = self
.components
.iter()
.filter_map(|(name, s)| match s {
HealthStatus::Unhealthy(_) => Some(name.as_str()),
_ => None,
})
.collect();
HealthStatus::Unhealthy(format!("unhealthy: {}", msgs.join(", ")))
} else if self
.components
.iter()
.any(|(_, s)| matches!(s, HealthStatus::Degraded(_)))
{
HealthStatus::Degraded("one or more components degraded".to_string())
} else {
HealthStatus::Healthy
}
}
}
pub trait HealthCheck: Send + Sync {
fn check(&self) -> Pin<Box<dyn Future<Output = HealthStatus> + Send + '_>>;
}
#[derive(Clone, Default)]
pub struct CompositeHealthCheck {
checks: Vec<(String, Arc<dyn HealthCheck>)>,
}
impl CompositeHealthCheck {
pub fn new() -> Self {
Self::default()
}
pub fn add(mut self, name: impl Into<String>, check: impl HealthCheck + 'static) -> Self {
let name = name.into();
self.checks.retain(|(n, _)| n != &name);
self.checks.push((name, Arc::new(check)));
self
}
pub async fn check_all(&self) -> CompositeStatus {
let mut components = Vec::with_capacity(self.checks.len());
for (name, check) in &self.checks {
let status = check.check().await;
components.push((name.clone(), status));
}
CompositeStatus { components }
}
}
#[cfg(test)]
mod tests {
use super::*;
struct AlwaysHealthy;
impl HealthCheck for AlwaysHealthy {
fn check(&self) -> Pin<Box<dyn Future<Output = HealthStatus> + Send + '_>> {
Box::pin(async { HealthStatus::Healthy })
}
}
struct AlwaysUnhealthy;
impl HealthCheck for AlwaysUnhealthy {
fn check(&self) -> Pin<Box<dyn Future<Output = HealthStatus> + Send + '_>> {
Box::pin(async { HealthStatus::Unhealthy("down".into()) })
}
}
#[test]
fn health_status_predicates() {
assert!(HealthStatus::Healthy.is_healthy());
assert!(!HealthStatus::Healthy.is_unhealthy());
assert!(!HealthStatus::Degraded("slow".into()).is_healthy());
assert!(HealthStatus::Unhealthy("down".into()).is_unhealthy());
}
#[tokio::test]
async fn composite_all_healthy() {
let health = CompositeHealthCheck::new()
.add("a", AlwaysHealthy)
.add("b", AlwaysHealthy);
let status = health.check_all().await;
assert!(status.is_healthy());
assert!(status.worst().is_healthy());
}
#[tokio::test]
async fn composite_one_unhealthy() {
let health = CompositeHealthCheck::new()
.add("ok", AlwaysHealthy)
.add("down", AlwaysUnhealthy);
let status = health.check_all().await;
assert!(!status.is_healthy());
assert!(status.has_unhealthy());
assert!(status.worst().is_unhealthy());
}
#[tokio::test]
async fn composite_empty_is_healthy() {
let health = CompositeHealthCheck::new();
let status = health.check_all().await;
assert!(status.is_healthy());
}
#[tokio::test]
async fn composite_worst_message_lists_unhealthy_component_names() {
let health = CompositeHealthCheck::new()
.add("primary-db", AlwaysUnhealthy)
.add("cache", AlwaysHealthy)
.add("queue", AlwaysUnhealthy);
let status = health.check_all().await;
let worst = status.worst();
let HealthStatus::Unhealthy(msg) = worst else {
panic!("worst() must return Unhealthy when components are down");
};
assert!(
msg.contains("primary-db"),
"worst() message must list 'primary-db' (got: {msg})"
);
assert!(
msg.contains("queue"),
"worst() message must list 'queue' (got: {msg})"
);
assert!(
!msg.contains("cache"),
"worst() must not name healthy components (got: {msg})"
);
}
#[tokio::test]
async fn composite_add_replaces_existing_name_and_keeps_others() {
let health = CompositeHealthCheck::new()
.add("db", AlwaysHealthy)
.add("cache", AlwaysHealthy)
.add("db", AlwaysUnhealthy);
let status = health.check_all().await;
assert_eq!(
status.components.len(),
2,
"dedup must replace same-named entry, not wipe others (got: {:?})",
status.components
);
let names: Vec<_> = status.components.iter().map(|(n, _)| n.as_str()).collect();
assert!(names.contains(&"db"), "'db' must be present");
assert!(names.contains(&"cache"), "'cache' must remain");
let db_status = status
.components
.iter()
.find(|(n, _)| n == "db")
.map(|(_, s)| s.clone())
.unwrap();
assert!(
db_status.is_unhealthy(),
"'db' must reflect the most recent registration (Unhealthy)"
);
}
}