use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use super::status::HealthStatus;
pub const MAX_DEPENDENCY_ID_LENGTH: usize = 128;
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(try_from = "String")]
pub struct DependencyId(String);
impl DependencyId {
pub fn new(value: impl Into<String>) -> Option<Self> {
let value = value.into();
if value.is_empty() || value.len() > MAX_DEPENDENCY_ID_LENGTH {
return None;
}
Some(Self(value))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_inner(self) -> String {
self.0
}
}
impl TryFrom<String> for DependencyId {
type Error = &'static str;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value).ok_or("dependency identifier is empty or exceeds the maximum length")
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
pub enum DependencyRequirement {
Required,
Optional,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct DependencyReport {
id: DependencyId,
requirement: DependencyRequirement,
status: HealthStatus,
observed_at: SystemTime,
}
impl DependencyReport {
#[must_use]
pub fn new(id: DependencyId, requirement: DependencyRequirement, status: HealthStatus) -> Self {
Self {
id,
requirement,
status,
observed_at: SystemTime::now(),
}
}
#[must_use]
pub const fn at(
id: DependencyId,
requirement: DependencyRequirement,
status: HealthStatus,
observed_at: SystemTime,
) -> Self {
Self {
id,
requirement,
status,
observed_at,
}
}
#[must_use]
pub fn healthy(id: DependencyId, requirement: DependencyRequirement) -> Self {
Self::new(id, requirement, HealthStatus::Healthy)
}
#[must_use]
pub fn degraded(id: DependencyId, requirement: DependencyRequirement) -> Self {
Self::new(id, requirement, HealthStatus::Degraded)
}
#[must_use]
pub fn unhealthy(id: DependencyId, requirement: DependencyRequirement) -> Self {
Self::new(id, requirement, HealthStatus::Unhealthy)
}
#[must_use]
pub fn unknown(id: DependencyId, requirement: DependencyRequirement) -> Self {
Self::new(id, requirement, HealthStatus::Unknown)
}
#[must_use]
pub fn id(&self) -> &DependencyId {
&self.id
}
#[must_use]
pub const fn requirement(&self) -> DependencyRequirement {
self.requirement
}
#[must_use]
pub const fn status(&self) -> HealthStatus {
self.status
}
#[must_use]
pub const fn observed_at(&self) -> SystemTime {
self.observed_at
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, UNIX_EPOCH};
fn dependency(id: &str) -> DependencyId {
DependencyId::new(id).unwrap()
}
#[test]
fn dependency_id_preserves_value() {
let id = DependencyId::new("database").unwrap();
assert_eq!(id.as_str(), "database");
assert_eq!(id.clone().into_inner(), "database");
}
#[test]
fn dependency_id_rejects_empty_value() {
assert!(DependencyId::new("").is_none());
}
#[test]
fn dependency_id_rejects_values_over_maximum_length() {
let value = "a".repeat(MAX_DEPENDENCY_ID_LENGTH + 1);
assert!(DependencyId::new(value).is_none());
}
#[test]
fn dependency_id_deserialization_preserves_constructor_validation() {
let valid = serde_json::from_str::<DependencyId>("\"database\"")
.expect("valid dependency id should deserialize");
assert_eq!(valid.as_str(), "database");
assert!(serde_json::from_str::<DependencyId>("\"\"").is_err());
let oversized = format!("\"{}\"", "x".repeat(MAX_DEPENDENCY_ID_LENGTH + 1));
assert!(serde_json::from_str::<DependencyId>(&oversized).is_err());
}
#[test]
fn dependency_requirement_variants_are_distinct() {
assert_ne!(
DependencyRequirement::Required,
DependencyRequirement::Optional
);
}
#[test]
fn report_preserves_all_observation_fields() {
let observed_at = UNIX_EPOCH + Duration::from_secs(42);
let report = DependencyReport::at(
dependency("database"),
DependencyRequirement::Required,
HealthStatus::Healthy,
observed_at,
);
assert_eq!(report.id().as_str(), "database");
assert_eq!(report.requirement(), DependencyRequirement::Required);
assert_eq!(report.status(), HealthStatus::Healthy);
assert_eq!(report.observed_at(), observed_at);
}
#[test]
fn convenience_constructors_use_expected_statuses() {
let required = DependencyRequirement::Required;
let id = dependency("service");
assert_eq!(
DependencyReport::healthy(id.clone(), required).status(),
HealthStatus::Healthy
);
assert_eq!(
DependencyReport::degraded(id.clone(), required).status(),
HealthStatus::Degraded
);
assert_eq!(
DependencyReport::unhealthy(id.clone(), required).status(),
HealthStatus::Unhealthy
);
assert_eq!(
DependencyReport::unknown(id, required).status(),
HealthStatus::Unknown
);
}
#[test]
fn required_and_optional_metadata_are_preserved() {
let required =
DependencyReport::healthy(dependency("required-db"), DependencyRequirement::Required);
let optional = DependencyReport::unhealthy(
dependency("optional-cache"),
DependencyRequirement::Optional,
);
assert_eq!(required.requirement(), DependencyRequirement::Required);
assert_eq!(optional.requirement(), DependencyRequirement::Optional);
assert_eq!(optional.status(), HealthStatus::Unhealthy);
}
#[test]
fn explicit_timestamp_is_not_recomputed() {
let observed_at = UNIX_EPOCH + Duration::from_secs(100);
let report = DependencyReport::at(
dependency("search"),
DependencyRequirement::Optional,
HealthStatus::Degraded,
observed_at,
);
assert_eq!(report.observed_at(), observed_at);
}
#[test]
fn new_records_a_current_observation_time() {
let before = SystemTime::now();
let report =
DependencyReport::healthy(dependency("database"), DependencyRequirement::Required);
let after = SystemTime::now();
assert!(report.observed_at() >= before);
assert!(report.observed_at() <= after);
}
#[test]
fn unknown_is_preserved_without_becoming_unhealthy() {
let report = DependencyReport::unknown(
dependency("external-service"),
DependencyRequirement::Required,
);
assert_eq!(report.status(), HealthStatus::Unknown);
assert_ne!(report.status(), HealthStatus::Unhealthy);
}
}