1use core::fmt;
18use core::fmt::Write as _;
19
20use mcp_conformance_core::requirement::RegistrySet;
21use mcp_conformance_core::revision::ProtocolRevision;
22use mcp_conformance_core::trace::TraceEvent;
23use serde::{Deserialize, Serialize};
24
25use crate::engine;
26use crate::report::{Outcome, Report, Totals, Verdict};
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum MultiError {
32 NoRevisions,
34 UnknownRevision(ProtocolRevision),
36}
37
38impl fmt::Display for MultiError {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 match self {
41 Self::NoRevisions => f.write_str("no revisions requested for multi-revision judgment"),
42 Self::UnknownRevision(revision) => {
43 write!(f, "registry set does not describe revision {revision}")
44 }
45 }
46 }
47}
48
49impl core::error::Error for MultiError {}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[non_exhaustive]
54pub struct RevisionSummary {
55 pub revision: String,
57 pub totals: Totals,
59 pub verdict: Verdict,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[non_exhaustive]
66pub struct MultiRow {
67 pub id: String,
69 pub level: String,
71 pub outcomes: Vec<Option<Outcome>>,
75}
76
77impl MultiRow {
78 #[must_use]
81 pub fn differs(&self) -> bool {
82 self.outcomes.windows(2).any(|pair| pair[0] != pair[1])
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[non_exhaustive]
94pub struct MultiReport {
95 pub revisions: Vec<String>,
97 pub summaries: Vec<RevisionSummary>,
99 pub requirements: Vec<MultiRow>,
102}
103
104impl MultiReport {
105 #[must_use]
109 pub fn verdict(&self) -> Verdict {
110 let any = |verdict: Verdict| self.summaries.iter().any(|s| s.verdict == verdict);
111 if any(Verdict::Unsupported) {
112 Verdict::Unsupported
113 } else if any(Verdict::Fail) {
114 Verdict::Fail
115 } else if any(Verdict::PassWithWarnings) {
116 Verdict::PassWithWarnings
117 } else {
118 Verdict::Pass
119 }
120 }
121
122 #[must_use]
124 pub fn render_human(&self) -> String {
125 let mut out = String::new();
126 let _ = writeln!(
127 out,
128 "MCP multi-revision validation — revisions {}",
129 self.revisions.join(", ")
130 );
131 for row in &self.requirements {
132 let _ = write!(out, " {:<10} ({})", row.id, row.level);
133 for (revision, outcome) in self.revisions.iter().zip(&row.outcomes) {
134 let _ = write!(out, " {revision}={}", cell_token(*outcome));
135 }
136 if row.differs() {
137 let _ = write!(out, " *differs");
138 }
139 let _ = writeln!(out);
140 }
141 let _ = writeln!(out, "per revision:");
142 for summary in &self.summaries {
143 let totals = summary.totals;
144 let _ = writeln!(
145 out,
146 " {}: {} pass, {} fail, {} warn, {} excluded, {} unsupported, {} not applicable — verdict {}",
147 summary.revision,
148 totals.pass,
149 totals.fail,
150 totals.warn,
151 totals.excluded,
152 totals.unsupported,
153 totals.not_applicable,
154 summary.verdict
155 );
156 }
157 let _ = writeln!(out, "overall verdict: {}", self.verdict());
158 out
159 }
160}
161
162const fn cell_token(outcome: Option<Outcome>) -> &'static str {
165 match outcome {
166 None => "absent",
167 Some(Outcome::Pass) => "pass",
168 Some(Outcome::Fail) => "fail",
169 Some(Outcome::Warn) => "warn",
170 Some(Outcome::Excluded) => "excluded",
171 Some(Outcome::Unsupported) => "unsupported",
172 Some(Outcome::NotApplicable) => "not-applicable",
173 }
174}
175
176pub fn validate_revisions(
212 set: &RegistrySet,
213 revisions: &[ProtocolRevision],
214 events: &[TraceEvent],
215) -> Result<MultiReport, MultiError> {
216 if revisions.is_empty() {
217 return Err(MultiError::NoRevisions);
218 }
219 let mut summaries = Vec::with_capacity(revisions.len());
220 let mut reports = Vec::with_capacity(revisions.len());
221 for &revision in revisions {
222 let registry = set
223 .registry(revision)
224 .ok_or(MultiError::UnknownRevision(revision))?;
225 let report = engine::validate(®istry, events);
226 summaries.push(RevisionSummary {
227 revision: revision.to_string(),
228 totals: report.totals,
229 verdict: report.verdict(),
230 });
231 reports.push(report);
232 }
233
234 let mut rows = Vec::new();
240 for requirement in set.requirements() {
241 let id = requirement.id.as_str();
242 let outcomes: Vec<Option<Outcome>> = reports
243 .iter()
244 .map(|report| outcome_in(report, id))
245 .collect();
246 if outcomes.iter().all(Option::is_none) {
247 continue;
248 }
249 rows.push(MultiRow {
250 id: id.to_owned(),
251 level: requirement.level.keyword().to_owned(),
252 outcomes,
253 });
254 }
255
256 Ok(MultiReport {
257 revisions: revisions.iter().map(ProtocolRevision::to_string).collect(),
258 summaries,
259 requirements: rows,
260 })
261}
262
263fn outcome_in(report: &Report, id: &str) -> Option<Outcome> {
266 report
267 .requirements
268 .iter()
269 .find(|row| row.id == id)
270 .map(|row| row.outcome)
271}
272
273#[cfg(test)]
274#[allow(clippy::unwrap_used)]
275mod tests {
276 use super::*;
277 use crate::reader::{Limits, parse_trace};
278
279 const SET: &str = r#"{
282 "revisions": ["2025-11-25", "2026-07-28"],
283 "requirements": [
284 {"id": "BASE-001", "level": "MUST", "actor": "both",
285 "source": {"section": "b#x", "quote": "MUST jsonrpc 2.0"},
286 "checks": ["base.jsonrpc-version"]},
287 {"id": "LIFE-009", "level": "MUST", "actor": "server",
288 "applies": {"removed": "2026-07-28"},
289 "source": {"section": "l#y", "quote": "MUST jsonrpc 2.0"},
290 "checks": ["base.jsonrpc-version"]},
291 {"id": "DISC-001", "level": "MUST", "actor": "server",
292 "applies": {"introduced": "2026-07-28"},
293 "source": {"section": "d#z", "quote": "MUST jsonrpc 2.0"},
294 "checks": ["base.jsonrpc-version"]}
295 ]
296 }"#;
297
298 fn set() -> RegistrySet {
299 RegistrySet::from_json(SET).unwrap()
300 }
301
302 fn revs() -> [ProtocolRevision; 2] {
303 ["2025-11-25".parse().unwrap(), "2026-07-28".parse().unwrap()]
304 }
305
306 #[test]
307 fn no_revisions_is_an_error() {
308 assert_eq!(
309 validate_revisions(&set(), &[], &[]),
310 Err(MultiError::NoRevisions)
311 );
312 }
313
314 #[test]
315 fn unknown_revision_names_itself() {
316 let unknown: ProtocolRevision = "2024-01-01".parse().unwrap();
317 assert_eq!(
318 validate_revisions(&set(), &[unknown], &[]),
319 Err(MultiError::UnknownRevision(unknown))
320 );
321 assert!(unknown.to_string().contains("2024-01-01"));
322 }
323
324 #[test]
325 fn rows_align_outcomes_with_revisions_and_mark_absence() {
326 let report = validate_revisions(&set(), &revs(), &[]).unwrap();
327 assert_eq!(report.revisions, ["2025-11-25", "2026-07-28"]);
328 assert_eq!(report.summaries.len(), 2);
329
330 let find = |id: &str| {
331 report
332 .requirements
333 .iter()
334 .find(|row| row.id == id)
335 .cloned()
336 .unwrap()
337 };
338
339 let base = find("BASE-001");
341 assert!(base.outcomes[0].is_some() && base.outcomes[1].is_some());
342 assert!(!base.differs());
343
344 let life = find("LIFE-009");
346 assert!(life.outcomes[0].is_some());
347 assert_eq!(life.outcomes[1], None);
348 assert!(life.differs());
349
350 let disc = find("DISC-001");
352 assert_eq!(disc.outcomes[0], None);
353 assert!(disc.outcomes[1].is_some());
354 assert!(disc.differs());
355 }
356
357 #[test]
358 fn union_order_follows_the_set_and_drops_clauses_in_no_judged_revision() {
359 let older: [ProtocolRevision; 1] = ["2025-11-25".parse().unwrap()];
362 let report = validate_revisions(&set(), &older, &[]).unwrap();
363 let ids: Vec<&str> = report.requirements.iter().map(|r| r.id.as_str()).collect();
364 assert_eq!(ids, ["BASE-001", "LIFE-009"]);
365 assert!(report.requirements.iter().all(|row| !row.differs()));
367 }
368
369 #[test]
370 fn differs_detects_a_non_adjacent_divergence() {
371 let uniform = MultiRow {
374 id: "X-001".to_owned(),
375 level: "MUST".to_owned(),
376 outcomes: vec![
377 Some(Outcome::Pass),
378 Some(Outcome::Pass),
379 Some(Outcome::Pass),
380 ],
381 };
382 assert!(!uniform.differs());
383 let diverges = MultiRow {
384 outcomes: vec![
385 Some(Outcome::Pass),
386 Some(Outcome::Pass),
387 Some(Outcome::Fail),
388 ],
389 ..uniform
390 };
391 assert!(diverges.differs());
392 }
393
394 #[test]
395 fn overall_verdict_is_the_worst_across_revisions() {
396 let mut report = validate_revisions(&set(), &revs(), &[]).unwrap();
397 assert_eq!(report.verdict(), Verdict::Pass);
399 report.summaries[1].verdict = Verdict::PassWithWarnings;
401 assert_eq!(report.verdict(), Verdict::PassWithWarnings);
402 report.summaries[1].verdict = Verdict::Fail;
403 assert_eq!(report.verdict(), Verdict::Fail);
404 report.summaries[0].verdict = Verdict::Unsupported;
405 assert_eq!(report.verdict(), Verdict::Unsupported);
406 }
407
408 #[test]
409 fn human_render_shows_each_revision_cell_and_marks_divergence() {
410 let report = validate_revisions(&set(), &revs(), &[]).unwrap();
411 let text = report.render_human();
412 assert!(text.contains("revisions 2025-11-25, 2026-07-28"), "{text}");
413 assert!(text.contains("LIFE-009"), "{text}");
415 assert!(text.contains("2026-07-28=absent"), "{text}");
416 assert!(text.contains("*differs"), "{text}");
417 assert!(text.contains("overall verdict: pass"), "{text}");
418 }
419
420 #[test]
421 fn judges_a_real_trace_and_is_deterministic() {
422 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"}}}}"#;
424 let events = parse_trace(trace, &Limits::default()).unwrap();
425 let a = validate_revisions(&set(), &revs(), &events).unwrap();
426 let b = validate_revisions(&set(), &revs(), &events).unwrap();
427 assert_eq!(
428 serde_json::to_string(&a).unwrap(),
429 serde_json::to_string(&b).unwrap()
430 );
431 }
432}