use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum HealthCheckType {
Http,
Tcp,
Grpc,
}
impl fmt::Display for HealthCheckType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Http => f.write_str("http"),
Self::Tcp => f.write_str("tcp"),
Self::Grpc => f.write_str("grpc"),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct HealthCheckConfig {
#[serde(rename = "type")]
pub check_type: HealthCheckType,
#[serde(default = "default_expected_status")]
pub expected_status: u16,
#[serde(default = "default_healthy_threshold")]
pub healthy_threshold: u32,
#[serde(default = "default_interval_ms")]
pub interval_ms: u64,
#[serde(default)]
pub passive_healthy_threshold: Option<u32>,
#[serde(default)]
pub passive_unhealthy_threshold: Option<u32>,
#[serde(default = "default_path")]
pub path: String,
#[serde(default = "default_timeout_ms")]
pub timeout_ms: u64,
#[serde(default = "default_unhealthy_threshold")]
pub unhealthy_threshold: u32,
}
fn default_path() -> String {
"/".to_owned()
}
fn default_expected_status() -> u16 {
200
}
fn default_interval_ms() -> u64 {
5000
}
fn default_timeout_ms() -> u64 {
2000
}
fn default_healthy_threshold() -> u32 {
2
}
fn default_unhealthy_threshold() -> u32 {
3
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::needless_raw_strings,
clippy::needless_raw_string_hashes,
reason = "tests use unwrap/expect/indexing/raw strings for brevity"
)]
mod tests {
use super::*;
#[test]
fn parse_full_config() {
let yaml = r#"
type: http
path: "/healthz"
expected_status: 200
interval_ms: 5000
timeout_ms: 2000
healthy_threshold: 2
unhealthy_threshold: 3
"#;
let hc: HealthCheckConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(hc.check_type, HealthCheckType::Http, "type should be http");
assert_eq!(hc.path, "/healthz", "path mismatch");
assert_eq!(hc.expected_status, 200, "expected_status mismatch");
assert_eq!(hc.interval_ms, 5000, "interval_ms mismatch");
assert_eq!(hc.timeout_ms, 2000, "timeout_ms mismatch");
assert_eq!(hc.healthy_threshold, 2, "healthy_threshold mismatch");
assert_eq!(hc.unhealthy_threshold, 3, "unhealthy_threshold mismatch");
}
#[test]
fn defaults_applied() {
let yaml = "type: http\n";
let hc: HealthCheckConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(hc.path, "/", "default path should be /");
assert_eq!(hc.expected_status, 200, "default expected_status should be 200");
assert_eq!(hc.interval_ms, 5000, "default interval_ms should be 5000");
assert_eq!(hc.timeout_ms, 2000, "default timeout_ms should be 2000");
assert_eq!(hc.healthy_threshold, 2, "default healthy_threshold should be 2");
assert_eq!(hc.unhealthy_threshold, 3, "default unhealthy_threshold should be 3");
}
#[test]
fn tcp_type_parses() {
let yaml = "type: tcp\n";
let hc: HealthCheckConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(hc.check_type, HealthCheckType::Tcp, "type should be tcp");
}
#[test]
fn roundtrip_via_serde() {
let hc = HealthCheckConfig {
check_type: HealthCheckType::Http,
expected_status: 204,
healthy_threshold: 3,
interval_ms: 10000,
passive_healthy_threshold: Some(3),
passive_unhealthy_threshold: Some(5),
path: "/health".to_owned(),
timeout_ms: 3000,
unhealthy_threshold: 5,
};
let value = serde_yaml::to_value(&hc).unwrap();
let back: HealthCheckConfig = serde_yaml::from_value(value).unwrap();
assert_eq!(back.check_type, hc.check_type, "type should roundtrip");
assert_eq!(back.path, hc.path, "path should roundtrip");
assert_eq!(back.expected_status, hc.expected_status, "status should roundtrip");
assert_eq!(back.interval_ms, hc.interval_ms, "interval should roundtrip");
assert_eq!(back.timeout_ms, hc.timeout_ms, "timeout should roundtrip");
assert_eq!(
back.passive_unhealthy_threshold, hc.passive_unhealthy_threshold,
"passive_unhealthy should roundtrip"
);
assert_eq!(
back.passive_healthy_threshold, hc.passive_healthy_threshold,
"passive_healthy should roundtrip"
);
}
#[test]
fn unknown_type_rejected_by_serde() {
let yaml = "type: websocket\n";
let result: Result<HealthCheckConfig, _> = serde_yaml::from_str(yaml);
assert!(result.is_err(), "unknown type should be rejected by serde");
}
#[test]
fn custom_expected_status() {
let yaml = r#"
type: http
expected_status: 204
"#;
let hc: HealthCheckConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(hc.expected_status, 204, "custom expected_status should be 204");
}
#[test]
fn passive_thresholds_default_to_none() {
let yaml = "type: http\n";
let hc: HealthCheckConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
hc.passive_unhealthy_threshold, None,
"passive_unhealthy_threshold should default to None"
);
assert_eq!(
hc.passive_healthy_threshold, None,
"passive_healthy_threshold should default to None"
);
}
#[test]
fn passive_thresholds_parse() {
let yaml = r#"
type: http
passive_unhealthy_threshold: 5
passive_healthy_threshold: 3
"#;
let hc: HealthCheckConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
hc.passive_unhealthy_threshold,
Some(5),
"passive_unhealthy_threshold mismatch"
);
assert_eq!(
hc.passive_healthy_threshold,
Some(3),
"passive_healthy_threshold mismatch"
);
}
#[test]
fn passive_only_unhealthy_threshold() {
let yaml = r#"
type: http
passive_unhealthy_threshold: 3
"#;
let hc: HealthCheckConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
hc.passive_unhealthy_threshold,
Some(3),
"should parse passive_unhealthy_threshold alone"
);
assert_eq!(
hc.passive_healthy_threshold, None,
"passive_healthy_threshold should be None"
);
}
#[test]
fn display_http() {
assert_eq!(
HealthCheckType::Http.to_string(),
"http",
"Http display should be 'http'"
);
}
#[test]
fn display_tcp() {
assert_eq!(HealthCheckType::Tcp.to_string(), "tcp", "Tcp display should be 'tcp'");
}
#[test]
fn display_grpc() {
assert_eq!(
HealthCheckType::Grpc.to_string(),
"grpc",
"Grpc display should be 'grpc'"
);
}
}