use core::fmt;
use core::fmt::Write as _;
use mcp_conformance_core::requirement::RegistrySet;
use mcp_conformance_core::revision::ProtocolRevision;
use mcp_conformance_core::trace::TraceEvent;
use serde::{Deserialize, Serialize};
use crate::engine;
use crate::report::{Outcome, Report, Totals, Verdict};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum MultiError {
NoRevisions,
UnknownRevision(ProtocolRevision),
}
impl fmt::Display for MultiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoRevisions => f.write_str("no revisions requested for multi-revision judgment"),
Self::UnknownRevision(revision) => {
write!(f, "registry set does not describe revision {revision}")
}
}
}
}
impl core::error::Error for MultiError {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RevisionSummary {
pub revision: String,
pub totals: Totals,
pub verdict: Verdict,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct MultiRow {
pub id: String,
pub level: String,
pub outcomes: Vec<Option<Outcome>>,
}
impl MultiRow {
#[must_use]
pub fn differs(&self) -> bool {
self.outcomes.windows(2).any(|pair| pair[0] != pair[1])
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct MultiReport {
pub revisions: Vec<String>,
pub summaries: Vec<RevisionSummary>,
pub requirements: Vec<MultiRow>,
}
impl MultiReport {
#[must_use]
pub fn verdict(&self) -> Verdict {
let any = |verdict: Verdict| self.summaries.iter().any(|s| s.verdict == verdict);
if any(Verdict::Unsupported) {
Verdict::Unsupported
} else if any(Verdict::Fail) {
Verdict::Fail
} else if any(Verdict::PassWithWarnings) {
Verdict::PassWithWarnings
} else {
Verdict::Pass
}
}
#[must_use]
pub fn render_human(&self) -> String {
let mut out = String::new();
let _ = writeln!(
out,
"MCP multi-revision validation — revisions {}",
self.revisions.join(", ")
);
for row in &self.requirements {
let _ = write!(out, " {:<10} ({})", row.id, row.level);
for (revision, outcome) in self.revisions.iter().zip(&row.outcomes) {
let _ = write!(out, " {revision}={}", cell_token(*outcome));
}
if row.differs() {
let _ = write!(out, " *differs");
}
let _ = writeln!(out);
}
let _ = writeln!(out, "per revision:");
for summary in &self.summaries {
let totals = summary.totals;
let _ = writeln!(
out,
" {}: {} pass, {} fail, {} warn, {} excluded, {} unsupported, {} not applicable — verdict {}",
summary.revision,
totals.pass,
totals.fail,
totals.warn,
totals.excluded,
totals.unsupported,
totals.not_applicable,
summary.verdict
);
}
let _ = writeln!(out, "overall verdict: {}", self.verdict());
out
}
}
const fn cell_token(outcome: Option<Outcome>) -> &'static str {
match outcome {
None => "absent",
Some(Outcome::Pass) => "pass",
Some(Outcome::Fail) => "fail",
Some(Outcome::Warn) => "warn",
Some(Outcome::Excluded) => "excluded",
Some(Outcome::Unsupported) => "unsupported",
Some(Outcome::NotApplicable) => "not-applicable",
}
}
pub fn validate_revisions(
set: &RegistrySet,
revisions: &[ProtocolRevision],
events: &[TraceEvent],
) -> Result<MultiReport, MultiError> {
if revisions.is_empty() {
return Err(MultiError::NoRevisions);
}
let mut summaries = Vec::with_capacity(revisions.len());
let mut reports = Vec::with_capacity(revisions.len());
for &revision in revisions {
let registry = set
.registry(revision)
.ok_or(MultiError::UnknownRevision(revision))?;
let report = engine::validate(®istry, events);
summaries.push(RevisionSummary {
revision: revision.to_string(),
totals: report.totals,
verdict: report.verdict(),
});
reports.push(report);
}
let mut rows = Vec::new();
for requirement in set.requirements() {
let id = requirement.id.as_str();
let outcomes: Vec<Option<Outcome>> = reports
.iter()
.map(|report| outcome_in(report, id))
.collect();
if outcomes.iter().all(Option::is_none) {
continue;
}
rows.push(MultiRow {
id: id.to_owned(),
level: requirement.level.keyword().to_owned(),
outcomes,
});
}
Ok(MultiReport {
revisions: revisions.iter().map(ProtocolRevision::to_string).collect(),
summaries,
requirements: rows,
})
}
fn outcome_in(report: &Report, id: &str) -> Option<Outcome> {
report
.requirements
.iter()
.find(|row| row.id == id)
.map(|row| row.outcome)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::reader::{Limits, parse_trace};
const SET: &str = r#"{
"revisions": ["2025-11-25", "2026-07-28"],
"requirements": [
{"id": "BASE-001", "level": "MUST", "actor": "both",
"source": {"section": "b#x", "quote": "MUST jsonrpc 2.0"},
"checks": ["base.jsonrpc-version"]},
{"id": "LIFE-009", "level": "MUST", "actor": "server",
"applies": {"removed": "2026-07-28"},
"source": {"section": "l#y", "quote": "MUST jsonrpc 2.0"},
"checks": ["base.jsonrpc-version"]},
{"id": "DISC-001", "level": "MUST", "actor": "server",
"applies": {"introduced": "2026-07-28"},
"source": {"section": "d#z", "quote": "MUST jsonrpc 2.0"},
"checks": ["base.jsonrpc-version"]}
]
}"#;
fn set() -> RegistrySet {
RegistrySet::from_json(SET).unwrap()
}
fn revs() -> [ProtocolRevision; 2] {
["2025-11-25".parse().unwrap(), "2026-07-28".parse().unwrap()]
}
#[test]
fn no_revisions_is_an_error() {
assert_eq!(
validate_revisions(&set(), &[], &[]),
Err(MultiError::NoRevisions)
);
}
#[test]
fn unknown_revision_names_itself() {
let unknown: ProtocolRevision = "2024-01-01".parse().unwrap();
assert_eq!(
validate_revisions(&set(), &[unknown], &[]),
Err(MultiError::UnknownRevision(unknown))
);
assert!(unknown.to_string().contains("2024-01-01"));
}
#[test]
fn rows_align_outcomes_with_revisions_and_mark_absence() {
let report = validate_revisions(&set(), &revs(), &[]).unwrap();
assert_eq!(report.revisions, ["2025-11-25", "2026-07-28"]);
assert_eq!(report.summaries.len(), 2);
let find = |id: &str| {
report
.requirements
.iter()
.find(|row| row.id == id)
.cloned()
.unwrap()
};
let base = find("BASE-001");
assert!(base.outcomes[0].is_some() && base.outcomes[1].is_some());
assert!(!base.differs());
let life = find("LIFE-009");
assert!(life.outcomes[0].is_some());
assert_eq!(life.outcomes[1], None);
assert!(life.differs());
let disc = find("DISC-001");
assert_eq!(disc.outcomes[0], None);
assert!(disc.outcomes[1].is_some());
assert!(disc.differs());
}
#[test]
fn union_order_follows_the_set_and_drops_clauses_in_no_judged_revision() {
let older: [ProtocolRevision; 1] = ["2025-11-25".parse().unwrap()];
let report = validate_revisions(&set(), &older, &[]).unwrap();
let ids: Vec<&str> = report.requirements.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, ["BASE-001", "LIFE-009"]);
assert!(report.requirements.iter().all(|row| !row.differs()));
}
#[test]
fn differs_detects_a_non_adjacent_divergence() {
let uniform = MultiRow {
id: "X-001".to_owned(),
level: "MUST".to_owned(),
outcomes: vec![
Some(Outcome::Pass),
Some(Outcome::Pass),
Some(Outcome::Pass),
],
};
assert!(!uniform.differs());
let diverges = MultiRow {
outcomes: vec![
Some(Outcome::Pass),
Some(Outcome::Pass),
Some(Outcome::Fail),
],
..uniform
};
assert!(diverges.differs());
}
#[test]
fn overall_verdict_is_the_worst_across_revisions() {
let mut report = validate_revisions(&set(), &revs(), &[]).unwrap();
assert_eq!(report.verdict(), Verdict::Pass);
report.summaries[1].verdict = Verdict::PassWithWarnings;
assert_eq!(report.verdict(), Verdict::PassWithWarnings);
report.summaries[1].verdict = Verdict::Fail;
assert_eq!(report.verdict(), Verdict::Fail);
report.summaries[0].verdict = Verdict::Unsupported;
assert_eq!(report.verdict(), Verdict::Unsupported);
}
#[test]
fn human_render_shows_each_revision_cell_and_marks_divergence() {
let report = validate_revisions(&set(), &revs(), &[]).unwrap();
let text = report.render_human();
assert!(text.contains("revisions 2025-11-25, 2026-07-28"), "{text}");
assert!(text.contains("LIFE-009"), "{text}");
assert!(text.contains("2026-07-28=absent"), "{text}");
assert!(text.contains("*differs"), "{text}");
assert!(text.contains("overall verdict: pass"), "{text}");
}
#[test]
fn judges_a_real_trace_and_is_deterministic() {
let trace = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}}"#;
let events = parse_trace(trace, &Limits::default()).unwrap();
let a = validate_revisions(&set(), &revs(), &events).unwrap();
let b = validate_revisions(&set(), &revs(), &events).unwrap();
assert_eq!(
serde_json::to_string(&a).unwrap(),
serde_json::to_string(&b).unwrap()
);
}
}