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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revision_mismatch: Option<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(", ")
);
self.write_revision_mismatch(&mut out);
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 _ = writeln!(
out,
" {}: {} — verdict {}",
summary.revision, summary.totals, summary.verdict
);
}
let _ = writeln!(out, "overall verdict: {}", self.verdict());
self.write_revision_mismatch(&mut out);
out
}
fn write_revision_mismatch(&self, out: &mut String) {
let Some(declared) = &self.revision_mismatch else {
return;
};
let (subject, tail) = if declared.len() == 1 {
("revision", "which was not judged")
} else {
("revisions", "none of which was judged")
};
let _ = writeln!(
out,
" NOTE this session declares protocol {subject} {}, {tail}.",
declared.join(", ")
);
let _ = writeln!(
out,
" Every outcome here judges it against rules it was not playing by."
);
}
}
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",
Some(Outcome::NotObserved) => "not-observed",
}
}
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(),
revision_mismatch: crate::declared::mismatch_any(revisions, events),
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)]
mod tests;