use core::fmt;
use serde::{Deserialize, Serialize};
mod human;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Finding {
pub check: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<u64>,
pub detail: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Outcome {
Pass,
Fail,
Warn,
Excluded,
Unsupported,
NotApplicable,
NotObserved,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Totals {
pub pass: u32,
pub fail: u32,
pub warn: u32,
pub excluded: u32,
pub unsupported: u32,
pub not_applicable: u32,
pub not_observed: u32,
}
impl Totals {
#[must_use]
pub const fn labelled(&self) -> [(&'static str, u32); 7] {
let Self {
pass,
fail,
warn,
excluded,
unsupported,
not_applicable,
not_observed,
} = *self;
[
("pass", pass),
("fail", fail),
("warn", warn),
("excluded", excluded),
("unsupported", unsupported),
("not applicable", not_applicable),
("not observed", not_observed),
]
}
}
impl fmt::Display for Totals {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (index, (label, count)) in self.labelled().into_iter().enumerate() {
if index > 0 {
f.write_str(", ")?;
}
write!(f, "{count} {label}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RequirementReport {
pub id: String,
pub level: String,
pub outcome: Outcome,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub findings: Vec<Finding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exclusion: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub missing_checks: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Report {
pub revision: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revision_mismatch: Option<Vec<String>>,
pub totals: Totals,
pub requirements: Vec<RequirementReport>,
}
impl Report {
#[must_use]
pub const fn has_errors(&self) -> bool {
self.totals.fail > 0
}
#[must_use]
pub const fn has_warnings(&self) -> bool {
self.totals.warn > 0
}
#[must_use]
pub const fn has_unsupported(&self) -> bool {
self.totals.unsupported > 0
}
#[must_use]
pub const fn verdict(&self) -> Verdict {
if self.totals.unsupported > 0 {
Verdict::Unsupported
} else if self.totals.fail > 0 {
Verdict::Fail
} else if self.totals.warn > 0 {
Verdict::PassWithWarnings
} else {
Verdict::Pass
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Verdict {
Pass,
PassWithWarnings,
Fail,
Unsupported,
}
impl fmt::Display for Verdict {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text = match self {
Self::Pass => "pass",
Self::PassWithWarnings => "pass-with-warnings",
Self::Fail => "fail",
Self::Unsupported => "unsupported",
};
f.write_str(text)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn row(id: &str, level: &str, outcome: Outcome) -> RequirementReport {
RequirementReport {
id: id.to_owned(),
level: level.to_owned(),
outcome,
findings: vec![],
exclusion: None,
missing_checks: vec![],
capability: None,
}
}
fn sample() -> Report {
let mut failed = row("LIFE-001", "MUST", Outcome::Fail);
failed.findings = vec![Finding {
check: "lifecycle.first-interaction-initialize".to_owned(),
seq: Some(3),
detail: "first message is \"tools/list\", expected \"initialize\"".to_owned(),
}];
let mut excluded = row("TRAN-001", "MUST NOT", Outcome::Excluded);
excluded.exclusion = Some("enforced at capture time".to_owned());
let mut not_applicable = row("TOOL-001", "MUST", Outcome::NotApplicable);
not_applicable.capability = Some("server.tools".to_owned());
Report {
revision_mismatch: None,
revision: "2025-11-25".to_owned(),
totals: Totals {
pass: 1,
fail: 1,
warn: 0,
excluded: 1,
unsupported: 0,
not_applicable: 1,
not_observed: 1,
},
requirements: vec![
row("BASE-001", "MUST", Outcome::Pass),
failed,
excluded,
not_applicable,
row("PAGE-002", "MUST", Outcome::NotObserved),
],
}
}
#[test]
fn verdict_priority_is_unsupported_fail_warn_pass() {
let mut report = sample();
assert_eq!(report.verdict(), Verdict::Fail);
report.totals.unsupported = 1;
assert_eq!(report.verdict(), Verdict::Unsupported);
report.totals.unsupported = 0;
report.totals.fail = 0;
report.totals.warn = 2;
assert_eq!(report.verdict(), Verdict::PassWithWarnings);
report.totals.warn = 0;
assert_eq!(report.verdict(), Verdict::Pass);
}
#[test]
fn human_rendering_shows_findings_and_totals() {
let text = sample().render_human();
assert!(text.contains("FAIL LIFE-001 (MUST)"), "{text}");
assert!(text.contains("seq 3:"), "{text}");
assert!(
text.contains("excluded: enforced at capture time"),
"{text}"
);
assert!(text.contains("N/A TOOL-001 (MUST)"), "{text}");
assert!(
text.contains(
"not applicable: capability server.tools was not declared in this session"
),
"{text}"
);
assert!(
text.contains(
" NOBS PAGE-002 (MUST)\n not observed: the session carried none of \
the traffic this clause binds to\n"
),
"{text}"
);
assert_eq!(
text.matches("not observed:").count(),
1,
"exactly the not-observed row carries the reason: {text}"
);
assert!(
text.contains(
"\ntotals: 1 pass, 1 fail, 0 warn, 1 excluded, 0 unsupported, \
1 not applicable, 1 not observed\n"
),
"{text}"
);
assert!(text.contains("verdict: fail"), "{text}");
}
#[test]
fn json_omits_empty_collections() {
let report = sample();
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"revision\":\"2025-11-25\""), "{json}");
assert!(!json.contains("\"missing_checks\""), "{json}");
}
fn counts_in(line: &str) -> Vec<u32> {
line.split(", ")
.filter_map(|part| part.split_whitespace().find_map(|word| word.parse().ok()))
.collect()
}
#[test]
fn a_summary_line_accounts_for_every_requirement() {
let report = sample();
let text = report.render_human();
let line = text
.lines()
.find(|line| line.starts_with("totals: "))
.unwrap();
let counts = counts_in(line);
assert_eq!(
counts.len(),
Totals::default().labelled().len(),
"every outcome is named: {line}"
);
assert_eq!(
counts.iter().sum::<u32>() as usize,
report.requirements.len(),
"{line}"
);
}
#[test]
fn every_outcome_has_a_label_and_they_are_distinct() {
let labels: Vec<&str> = Totals::default()
.labelled()
.iter()
.map(|&(label, _)| label)
.collect();
let mut sorted = labels.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), labels.len(), "duplicate label in {labels:?}");
let totals = Totals {
pass: 1,
fail: 2,
warn: 3,
excluded: 4,
unsupported: 5,
not_applicable: 6,
not_observed: 7,
};
assert_eq!(
totals.to_string(),
"1 pass, 2 fail, 3 warn, 4 excluded, 5 unsupported, 6 not applicable, 7 not observed"
);
}
#[test]
fn totals_predicates_pin_their_thresholds() {
let mut report = sample();
report.totals = Totals::default();
assert!(!report.has_errors());
assert!(!report.has_warnings());
assert!(!report.has_unsupported());
report.totals.fail = 1;
assert!(report.has_errors());
report.totals.warn = 1;
assert!(report.has_warnings());
report.totals.unsupported = 1;
assert!(report.has_unsupported());
}
}