use core::fmt;
use core::fmt::Write as _;
use serde::{Deserialize, Serialize};
#[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,
}
#[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,
}
#[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,
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 fn render_human(&self) -> String {
let mut out = String::new();
let _ = writeln!(out, "MCP trace validation — revision {}", self.revision);
for row in &self.requirements {
let marker = match row.outcome {
Outcome::Pass => "PASS",
Outcome::Fail => "FAIL",
Outcome::Warn => "WARN",
Outcome::Excluded => "EXCL",
Outcome::Unsupported => "UNSUP",
Outcome::NotApplicable => "N/A",
};
let _ = writeln!(out, " {marker:<5} {} ({})", row.id, row.level);
for finding in &row.findings {
match finding.seq {
Some(seq) => {
let _ = writeln!(out, " seq {seq}: {}", finding.detail);
}
None => {
let _ = writeln!(out, " {}", finding.detail);
}
}
}
if let Some(exclusion) = &row.exclusion {
let _ = writeln!(out, " excluded: {exclusion}");
}
for check in &row.missing_checks {
let _ = writeln!(out, " unsupported check: {check}");
}
if let Some(capability) = &row.capability {
let _ = writeln!(
out,
" not applicable: capability {capability} was not declared in this session"
);
}
}
let totals = self.totals;
let _ = writeln!(
out,
"totals: {} pass, {} fail, {} warn, {} excluded, {} unsupported, {} not applicable",
totals.pass,
totals.fail,
totals.warn,
totals.excluded,
totals.unsupported,
totals.not_applicable
);
let _ = writeln!(out, "verdict: {}", self.verdict());
out
}
#[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 sample() -> Report {
Report {
revision: "2025-11-25".to_owned(),
totals: Totals {
pass: 1,
fail: 1,
warn: 0,
excluded: 1,
unsupported: 0,
not_applicable: 1,
},
requirements: vec![
RequirementReport {
id: "BASE-001".to_owned(),
level: "MUST".to_owned(),
outcome: Outcome::Pass,
findings: vec![],
exclusion: None,
missing_checks: vec![],
capability: None,
},
RequirementReport {
id: "LIFE-001".to_owned(),
level: "MUST".to_owned(),
outcome: Outcome::Fail,
findings: vec![Finding {
check: "lifecycle.first-interaction-initialize".to_owned(),
seq: Some(3),
detail: "first message is \"tools/list\", expected \"initialize\""
.to_owned(),
}],
exclusion: None,
missing_checks: vec![],
capability: None,
},
RequirementReport {
id: "TRAN-001".to_owned(),
level: "MUST NOT".to_owned(),
outcome: Outcome::Excluded,
findings: vec![],
exclusion: Some("enforced at capture time".to_owned()),
missing_checks: vec![],
capability: None,
},
RequirementReport {
id: "TOOL-001".to_owned(),
level: "MUST".to_owned(),
outcome: Outcome::NotApplicable,
findings: vec![],
exclusion: None,
missing_checks: vec![],
capability: Some("server.tools".to_owned()),
},
],
}
}
#[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(
"totals: 1 pass, 1 fail, 0 warn, 1 excluded, 0 unsupported, 1 not applicable"
),
"{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}");
}
#[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());
}
}