Skip to main content

mcp_trace_validator/
report.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! Validation reports: per-requirement outcomes with actionable findings.
5//!
6//! Reports are artifacts: they get committed as golden files, diffed in CI, and cited
7//! in published results. Two consequences shape this module: serialization order is
8//! fixed (registry order; struct fields in declaration order), and nothing
9//! environment-dependent (paths, timestamps, hostnames) is ever included.
10
11use core::fmt;
12
13use serde::{Deserialize, Serialize};
14
15mod human;
16
17/// One concrete violation, addressed to a requirement and (where possible) an event.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[non_exhaustive]
20pub struct Finding {
21    /// The validator check that produced this finding.
22    pub check: String,
23    /// The event `seq` the finding points at, when one event is identifiable.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub seq: Option<u64>,
26    /// What was observed and what was expected, in one actionable sentence.
27    pub detail: String,
28}
29
30/// The outcome of evaluating one requirement against one trace.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "kebab-case")]
33#[non_exhaustive]
34pub enum Outcome {
35    /// All covering checks ran and produced no findings.
36    Pass,
37    /// A MUST / MUST NOT requirement has findings.
38    Fail,
39    /// A SHOULD / SHOULD NOT requirement has findings.
40    Warn,
41    /// The registry documents that this requirement is not judged from traces.
42    Excluded,
43    /// The registry references a check this validator build does not implement.
44    Unsupported,
45    /// The requirement is gated on a capability this session never declared
46    /// (ADR-0006); its checks were not run.
47    NotApplicable,
48    /// Every covering check ran and found nothing to judge: this session
49    /// carried none of the traffic the clause binds to.
50    ///
51    /// Distinct from [`Self::Pass`], and the distinction is the whole point. A
52    /// clause about `subscriptions/listen` cannot be *complied with* by a
53    /// session that never opened a stream — there was no opportunity to break
54    /// it — so reporting `pass` states evidence the trace does not carry.
55    /// Distinct from [`Self::NotApplicable`] too: that one is the registry
56    /// saying the clause is gated on a capability nobody declared, this one is
57    /// the trace saying it had nothing to show.
58    NotObserved,
59}
60
61/// Aggregate counts, in report order. `excluded` and `unsupported` are first-class:
62/// inflating pass rates by hiding them is how conformance tools lose trust.
63#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
64#[non_exhaustive]
65pub struct Totals {
66    /// Requirements with outcome [`Outcome::Pass`].
67    pub pass: u32,
68    /// Requirements with outcome [`Outcome::Fail`].
69    pub fail: u32,
70    /// Requirements with outcome [`Outcome::Warn`].
71    pub warn: u32,
72    /// Requirements with outcome [`Outcome::Excluded`].
73    pub excluded: u32,
74    /// Requirements with outcome [`Outcome::Unsupported`].
75    pub unsupported: u32,
76    /// Requirements with outcome [`Outcome::NotApplicable`].
77    pub not_applicable: u32,
78    /// Requirements with outcome [`Outcome::NotObserved`].
79    pub not_observed: u32,
80}
81
82impl Totals {
83    /// Every outcome's report label and count, in report order.
84    ///
85    /// Destructured exhaustively on purpose, and that is the whole point of the
86    /// method existing: a field added to [`Totals`] fails to compile here until
87    /// it is given a label, and every summary line in the crate is formatted
88    /// from this one list. Hand-written `write!` arms could not offer that —
89    /// the single-revision line named all seven outcomes while the
90    /// multi-revision line named six, so the same run reported 140 clauses as
91    /// human text and 140 as JSON but only accounted for 125 of them in the
92    /// former. Counts that do not add up are how a conformance tool overstates
93    /// what it judged.
94    #[must_use]
95    pub const fn labelled(&self) -> [(&'static str, u32); 7] {
96        let Self {
97            pass,
98            fail,
99            warn,
100            excluded,
101            unsupported,
102            not_applicable,
103            not_observed,
104        } = *self;
105        [
106            ("pass", pass),
107            ("fail", fail),
108            ("warn", warn),
109            ("excluded", excluded),
110            ("unsupported", unsupported),
111            ("not applicable", not_applicable),
112            ("not observed", not_observed),
113        ]
114    }
115}
116
117/// The counts as one phrase — `23 pass, 0 fail, …` — naming every outcome.
118///
119/// The summary lines differ in what surrounds them (`totals: ` on a
120/// single-revision report, the revision and its verdict on a multi-revision
121/// one) and agree on what is inside, so what is inside is written once.
122impl fmt::Display for Totals {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        for (index, (label, count)) in self.labelled().into_iter().enumerate() {
125            if index > 0 {
126                f.write_str(", ")?;
127            }
128            write!(f, "{count} {label}")?;
129        }
130        Ok(())
131    }
132}
133
134/// One requirement's row in the report.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[non_exhaustive]
137pub struct RequirementReport {
138    /// The requirement ID (`AREA-NNN`).
139    pub id: String,
140    /// The requirement's RFC 2119 level, as registry text (`"MUST"`, …).
141    pub level: String,
142    /// The evaluation outcome.
143    pub outcome: Outcome,
144    /// Findings, in event order. Empty unless `outcome` is `fail` or `warn`.
145    #[serde(default, skip_serializing_if = "Vec::is_empty")]
146    pub findings: Vec<Finding>,
147    /// The documented exclusion reason, when `outcome` is `excluded`.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub exclusion: Option<String>,
150    /// Check IDs the build lacks, when `outcome` is `unsupported`.
151    #[serde(default, skip_serializing_if = "Vec::is_empty")]
152    pub missing_checks: Vec<String>,
153    /// The undeclared capability gate, when `outcome` is `not-applicable`.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub capability: Option<String>,
156}
157
158/// A complete validation report for one trace against one registry.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[non_exhaustive]
161pub struct Report {
162    /// The registry's protocol revision (`YYYY-MM-DD`).
163    pub revision: String,
164    /// The revisions the *session* declared, when it declared some and
165    /// [`Self::revision`] is not among them — so the reader is told that these
166    /// findings judge the trace against rules it was not playing by.
167    ///
168    /// Absent whenever there is nothing to say, which is the common case; see
169    /// [`crate::declared`] for what counts as a declaration and why the rule is
170    /// deliberately quiet.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub revision_mismatch: Option<Vec<String>>,
173    /// Aggregate counts.
174    pub totals: Totals,
175    /// Per-requirement outcomes, in registry order.
176    pub requirements: Vec<RequirementReport>,
177}
178
179impl Report {
180    /// `true` when any requirement failed (errors, not warnings).
181    #[must_use]
182    pub const fn has_errors(&self) -> bool {
183        self.totals.fail > 0
184    }
185
186    /// `true` when any SHOULD-level requirement produced findings.
187    #[must_use]
188    pub const fn has_warnings(&self) -> bool {
189        self.totals.warn > 0
190    }
191
192    /// `true` when the registry referenced checks this build does not implement.
193    #[must_use]
194    pub const fn has_unsupported(&self) -> bool {
195        self.totals.unsupported > 0
196    }
197
198    /// One-word verdict for the trailing summary line.
199    #[must_use]
200    pub const fn verdict(&self) -> Verdict {
201        if self.totals.unsupported > 0 {
202            Verdict::Unsupported
203        } else if self.totals.fail > 0 {
204            Verdict::Fail
205        } else if self.totals.warn > 0 {
206            Verdict::PassWithWarnings
207        } else {
208            Verdict::Pass
209        }
210    }
211}
212
213/// Overall verdict of a validation run.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "kebab-case")]
216#[non_exhaustive]
217pub enum Verdict {
218    /// No findings at all.
219    Pass,
220    /// Only SHOULD-level findings.
221    PassWithWarnings,
222    /// At least one MUST-level violation.
223    Fail,
224    /// The registry and this build disagree about available checks.
225    Unsupported,
226}
227
228impl fmt::Display for Verdict {
229    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230        let text = match self {
231            Self::Pass => "pass",
232            Self::PassWithWarnings => "pass-with-warnings",
233            Self::Fail => "fail",
234            Self::Unsupported => "unsupported",
235        };
236        f.write_str(text)
237    }
238}
239
240#[cfg(test)]
241#[allow(clippy::unwrap_used)]
242mod tests {
243    use super::*;
244
245    fn row(id: &str, level: &str, outcome: Outcome) -> RequirementReport {
246        RequirementReport {
247            id: id.to_owned(),
248            level: level.to_owned(),
249            outcome,
250            findings: vec![],
251            exclusion: None,
252            missing_checks: vec![],
253            capability: None,
254        }
255    }
256
257    /// One row of every outcome the renderer can produce, so the totals line
258    /// and the per-row text are pinned against the full set rather than a
259    /// convenient subset.
260    fn sample() -> Report {
261        let mut failed = row("LIFE-001", "MUST", Outcome::Fail);
262        failed.findings = vec![Finding {
263            check: "lifecycle.first-interaction-initialize".to_owned(),
264            seq: Some(3),
265            detail: "first message is \"tools/list\", expected \"initialize\"".to_owned(),
266        }];
267        let mut excluded = row("TRAN-001", "MUST NOT", Outcome::Excluded);
268        excluded.exclusion = Some("enforced at capture time".to_owned());
269        let mut not_applicable = row("TOOL-001", "MUST", Outcome::NotApplicable);
270        not_applicable.capability = Some("server.tools".to_owned());
271        Report {
272            revision_mismatch: None,
273            revision: "2025-11-25".to_owned(),
274            totals: Totals {
275                pass: 1,
276                fail: 1,
277                warn: 0,
278                excluded: 1,
279                unsupported: 0,
280                not_applicable: 1,
281                not_observed: 1,
282            },
283            requirements: vec![
284                row("BASE-001", "MUST", Outcome::Pass),
285                failed,
286                excluded,
287                not_applicable,
288                row("PAGE-002", "MUST", Outcome::NotObserved),
289            ],
290        }
291    }
292
293    #[test]
294    fn verdict_priority_is_unsupported_fail_warn_pass() {
295        let mut report = sample();
296        assert_eq!(report.verdict(), Verdict::Fail);
297        report.totals.unsupported = 1;
298        assert_eq!(report.verdict(), Verdict::Unsupported);
299        report.totals.unsupported = 0;
300        report.totals.fail = 0;
301        report.totals.warn = 2;
302        assert_eq!(report.verdict(), Verdict::PassWithWarnings);
303        report.totals.warn = 0;
304        assert_eq!(report.verdict(), Verdict::Pass);
305    }
306
307    #[test]
308    fn human_rendering_shows_findings_and_totals() {
309        let text = sample().render_human();
310        assert!(text.contains("FAIL  LIFE-001 (MUST)"), "{text}");
311        assert!(text.contains("seq 3:"), "{text}");
312        assert!(
313            text.contains("excluded: enforced at capture time"),
314            "{text}"
315        );
316        assert!(text.contains("N/A   TOOL-001 (MUST)"), "{text}");
317        assert!(
318            text.contains(
319                "not applicable: capability server.tools was not declared in this session"
320            ),
321            "{text}"
322        );
323        // A not-observed row says so in words, like every other non-judged
324        // outcome: "NOBS" alone tells an operator nothing about *why*. Pinned
325        // as the two lines *together*, and counted: asserting only that the
326        // sentence appears somewhere passes just as well when it is attached
327        // to every row except the one it describes.
328        assert!(
329            text.contains(
330                "  NOBS  PAGE-002 (MUST)\n        not observed: the session carried none of \
331                 the traffic this clause binds to\n"
332            ),
333            "{text}"
334        );
335        assert_eq!(
336            text.matches("not observed:").count(),
337            1,
338            "exactly the not-observed row carries the reason: {text}"
339        );
340        // The whole line, anchored at both ends: a `contains` of a prefix would
341        // pass while a new outcome went unnamed and the counts stopped summing
342        // to the registry's size.
343        assert!(
344            text.contains(
345                "\ntotals: 1 pass, 1 fail, 0 warn, 1 excluded, 0 unsupported, \
346                 1 not applicable, 1 not observed\n"
347            ),
348            "{text}"
349        );
350        assert!(text.contains("verdict: fail"), "{text}");
351    }
352
353    #[test]
354    fn json_omits_empty_collections() {
355        let report = sample();
356        let json = serde_json::to_string(&report).unwrap();
357        assert!(json.contains("\"revision\":\"2025-11-25\""), "{json}");
358        // Passing rows carry no findings/exclusion/missing_checks keys.
359        assert!(!json.contains("\"missing_checks\""), "{json}");
360    }
361
362    /// The counts a rendered summary line actually carries, read back out of
363    /// the text a reader sees rather than off the struct the renderer was
364    /// handed — the two disagreeing is the whole failure this guards.
365    fn counts_in(line: &str) -> Vec<u32> {
366        // The first number in each comma-separated part is its count; what
367        // surrounds it (`totals: ` here, a revision there) carries none.
368        line.split(", ")
369            .filter_map(|part| part.split_whitespace().find_map(|word| word.parse().ok()))
370            .collect()
371    }
372
373    #[test]
374    fn a_summary_line_accounts_for_every_requirement() {
375        let report = sample();
376        let text = report.render_human();
377        let line = text
378            .lines()
379            .find(|line| line.starts_with("totals: "))
380            .unwrap();
381        let counts = counts_in(line);
382        assert_eq!(
383            counts.len(),
384            Totals::default().labelled().len(),
385            "every outcome is named: {line}"
386        );
387        // The invariant the line's own comment claims, asserted rather than
388        // left to a reader's arithmetic: what the renderer prints must add up
389        // to the rows it printed. The multi-revision line made exactly this
390        // claim in prose and silently broke it.
391        assert_eq!(
392            counts.iter().sum::<u32>() as usize,
393            report.requirements.len(),
394            "{line}"
395        );
396    }
397
398    #[test]
399    fn every_outcome_has_a_label_and_they_are_distinct() {
400        let labels: Vec<&str> = Totals::default()
401            .labelled()
402            .iter()
403            .map(|&(label, _)| label)
404            .collect();
405        let mut sorted = labels.clone();
406        sorted.sort_unstable();
407        sorted.dedup();
408        assert_eq!(sorted.len(), labels.len(), "duplicate label in {labels:?}");
409        // Each count sits with its own label: a swapped pair would keep the sum
410        // and the label set intact, so the mapping is pinned too.
411        let totals = Totals {
412            pass: 1,
413            fail: 2,
414            warn: 3,
415            excluded: 4,
416            unsupported: 5,
417            not_applicable: 6,
418            not_observed: 7,
419        };
420        assert_eq!(
421            totals.to_string(),
422            "1 pass, 2 fail, 3 warn, 4 excluded, 5 unsupported, 6 not applicable, 7 not observed"
423        );
424    }
425
426    #[test]
427    fn totals_predicates_pin_their_thresholds() {
428        let mut report = sample();
429        report.totals = Totals::default();
430        assert!(!report.has_errors());
431        assert!(!report.has_warnings());
432        assert!(!report.has_unsupported());
433        report.totals.fail = 1;
434        assert!(report.has_errors());
435        report.totals.warn = 1;
436        assert!(report.has_warnings());
437        report.totals.unsupported = 1;
438        assert!(report.has_unsupported());
439    }
440}