use std::fmt;
use super::asked::{Asked, u64_is_zero};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CheckId {
SliceParse,
SliceSync,
IntrospectCoverage,
AdminUnreachable,
RouterVersionSkew,
DescribeTotality,
SchemaDrift,
DescribeMissing,
StaleState,
UnstampedState,
StorageCoverage,
PayloadUndecodable,
PayloadInvalid,
QosObservedMismatch,
UnregisteredTraffic,
RateOverDeclared,
TimestampStampedElsewhere,
CardinalityOverDeclared,
FieldVanished,
FieldStuck,
FieldNew,
}
impl CheckId {
pub const ALL: [CheckId; 21] = [
CheckId::SliceParse,
CheckId::SliceSync,
CheckId::IntrospectCoverage,
CheckId::AdminUnreachable,
CheckId::RouterVersionSkew,
CheckId::DescribeTotality,
CheckId::SchemaDrift,
CheckId::DescribeMissing,
CheckId::StaleState,
CheckId::UnstampedState,
CheckId::StorageCoverage,
CheckId::PayloadUndecodable,
CheckId::PayloadInvalid,
CheckId::QosObservedMismatch,
CheckId::UnregisteredTraffic,
CheckId::RateOverDeclared,
CheckId::TimestampStampedElsewhere,
CheckId::CardinalityOverDeclared,
CheckId::FieldVanished,
CheckId::FieldStuck,
CheckId::FieldNew,
];
pub fn as_str(self) -> &'static str {
match self {
CheckId::SliceParse => "slice-parse",
CheckId::SliceSync => "slice-sync",
CheckId::IntrospectCoverage => "introspect-coverage",
CheckId::AdminUnreachable => "admin-unreachable",
CheckId::RouterVersionSkew => "router-version-skew",
CheckId::DescribeTotality => "describe-totality",
CheckId::SchemaDrift => "schema-drift",
CheckId::DescribeMissing => "describe-missing",
CheckId::StaleState => "stale-state",
CheckId::UnstampedState => "unstamped-state",
CheckId::StorageCoverage => "storage-coverage",
CheckId::PayloadUndecodable => "payload-undecodable",
CheckId::PayloadInvalid => "payload-invalid",
CheckId::QosObservedMismatch => "qos-observed-mismatch",
CheckId::UnregisteredTraffic => "unregistered-traffic",
CheckId::RateOverDeclared => "rate-over-declared",
CheckId::TimestampStampedElsewhere => "timestamp-stamped-elsewhere",
CheckId::CardinalityOverDeclared => "cardinality-over-declared",
CheckId::FieldVanished => "field-vanished",
CheckId::FieldStuck => "field-stuck",
CheckId::FieldNew => "field-new",
}
}
pub fn parse(token: &str) -> Option<CheckId> {
CheckId::ALL.into_iter().find(|c| c.as_str() == token)
}
}
impl fmt::Display for CheckId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DoctorSeverity {
Error,
Warning,
Info,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DoctorFinding {
pub severity: DoctorSeverity,
pub check: CheckId,
pub subject: String,
pub evidence: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub citation: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DoctorReport {
pub findings: Vec<DoctorFinding>,
#[serde(skip_serializing_if = "Asked::is_not_asked", default)]
pub synced: Asked<Vec<String>>,
pub introspect_answered: usize,
pub live_producers: usize,
pub describe_served: usize,
pub describe_missing: usize,
pub routers: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub router_version: Option<String>,
pub deep: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub observation: Option<ObservationSummary>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ObservationSummary {
pub window_s: f64,
pub scopes: Vec<String>,
pub samples: u64,
pub keys_seen: usize,
pub dropped: u64,
pub synthetic_marked: u64,
#[serde(skip_serializing_if = "u64_is_zero")]
pub field_paths_dropped: u64,
#[serde(skip_serializing_if = "u64_is_zero", default)]
pub facts_evicted: u64,
}
impl DoctorReport {
pub fn count(&self, severity: DoctorSeverity) -> usize {
self.findings
.iter()
.filter(|f| f.severity == severity)
.count()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::Asked;
#[test]
fn doctor_report_json_shape_is_pinned() {
let report = DoctorReport {
findings: vec![DoctorFinding {
severity: DoctorSeverity::Error,
check: CheckId::SliceSync,
subject: "h-3fa9c2d41b7e/sysinfo".into(),
evidence: "registry version differs: served 1.0, local 2.0".into(),
citation: Some("RFC 08 §6".into()),
}],
synced: Asked::Asked(vec!["h-3fa9c2d41b7e/other (registry 1.0)".into()]),
introspect_answered: 2,
live_producers: 3,
describe_served: 1,
describe_missing: 1,
routers: 1,
router_version: Some("1.9.0".into()),
deep: false,
observation: None,
};
let json = serde_json::to_value(&report).unwrap();
assert_eq!(
json,
serde_json::json!({
"findings": [{
"severity": "error",
"check": "slice-sync",
"subject": "h-3fa9c2d41b7e/sysinfo",
"evidence": "registry version differs: served 1.0, local 2.0",
"citation": "RFC 08 §6",
}],
"synced": ["h-3fa9c2d41b7e/other (registry 1.0)"],
"introspect_answered": 2,
"live_producers": 3,
"describe_served": 1,
"describe_missing": 1,
"routers": 1,
"router_version": "1.9.0",
"deep": false,
}),
"without --for the document is byte-identical to pre-#161"
);
let unchecked = DoctorReport {
synced: Asked::NotAsked,
..report.clone()
};
let json = serde_json::to_value(&unchecked).unwrap();
assert!(
!json.as_object().unwrap().contains_key("synced"),
"diff never ran: the key is absent, exactly as pre-R1 no-registry \
runs serialized"
);
let ran_empty = DoctorReport {
synced: Asked::Asked(vec![]),
..report.clone()
};
let json = serde_json::to_value(&ran_empty).unwrap();
assert_eq!(
json["synced"],
serde_json::json!([]),
"ran and confirmed nothing is `[]`, not absence"
);
let report = DoctorReport {
observation: Some(ObservationSummary {
window_s: 10.0,
scopes: vec!["v1/*/state/**".into()],
samples: 42,
keys_seen: 7,
dropped: 0,
synthetic_marked: 3,
field_paths_dropped: 0,
facts_evicted: 0,
}),
..report
};
let json = serde_json::to_value(&report).unwrap();
assert_eq!(
json["observation"],
serde_json::json!({
"window_s": 10.0,
"scopes": ["v1/*/state/**"],
"samples": 42,
"keys_seen": 7,
"dropped": 0,
"synthetic_marked": 3,
})
);
let report = DoctorReport {
observation: Some(ObservationSummary {
field_paths_dropped: 2,
..report.observation.unwrap()
}),
..report
};
let json = serde_json::to_value(&report).unwrap();
assert_eq!(json["observation"]["field_paths_dropped"], 2);
assert!(
!json["observation"]
.as_object()
.unwrap()
.contains_key("facts_evicted")
);
let report = DoctorReport {
observation: Some(ObservationSummary {
facts_evicted: 5,
..report.observation.unwrap()
}),
..report
};
let json = serde_json::to_value(&report).unwrap();
assert_eq!(json["observation"]["facts_evicted"], 5);
}
}
#[cfg(test)]
mod check_id_tests {
use super::*;
#[test]
fn check_ids_are_stable() {
assert_eq!(
CheckId::ALL.map(CheckId::as_str),
[
"slice-parse",
"slice-sync",
"introspect-coverage",
"admin-unreachable",
"router-version-skew",
"describe-totality",
"schema-drift",
"describe-missing",
"stale-state",
"unstamped-state",
"storage-coverage",
"payload-undecodable",
"payload-invalid",
"qos-observed-mismatch",
"unregistered-traffic",
"rate-over-declared",
"timestamp-stamped-elsewhere",
"cardinality-over-declared",
"field-vanished",
"field-stuck",
"field-new",
]
);
}
#[test]
fn every_check_id_round_trips_through_serde_and_parse() {
for id in CheckId::ALL {
let json = serde_json::to_string(&id).unwrap();
assert_eq!(json, format!("\"{}\"", id.as_str()));
assert_eq!(serde_json::from_str::<CheckId>(&json).unwrap(), id);
assert_eq!(CheckId::parse(id.as_str()), Some(id));
}
assert_eq!(CheckId::parse("slice-sinc"), None);
}
}