1use core::fmt;
12use core::fmt::Write as _;
13
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[non_exhaustive]
19pub struct Finding {
20 pub check: String,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub seq: Option<u64>,
25 pub detail: String,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "kebab-case")]
32#[non_exhaustive]
33pub enum Outcome {
34 Pass,
36 Fail,
38 Warn,
40 Excluded,
42 Unsupported,
44 NotApplicable,
47}
48
49#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
52#[non_exhaustive]
53pub struct Totals {
54 pub pass: u32,
56 pub fail: u32,
58 pub warn: u32,
60 pub excluded: u32,
62 pub unsupported: u32,
64 pub not_applicable: u32,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[non_exhaustive]
71pub struct RequirementReport {
72 pub id: String,
74 pub level: String,
76 pub outcome: Outcome,
78 #[serde(default, skip_serializing_if = "Vec::is_empty")]
80 pub findings: Vec<Finding>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub exclusion: Option<String>,
84 #[serde(default, skip_serializing_if = "Vec::is_empty")]
86 pub missing_checks: Vec<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub capability: Option<String>,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[non_exhaustive]
95pub struct Report {
96 pub revision: String,
98 pub totals: Totals,
100 pub requirements: Vec<RequirementReport>,
102}
103
104impl Report {
105 #[must_use]
107 pub const fn has_errors(&self) -> bool {
108 self.totals.fail > 0
109 }
110
111 #[must_use]
113 pub const fn has_warnings(&self) -> bool {
114 self.totals.warn > 0
115 }
116
117 #[must_use]
119 pub const fn has_unsupported(&self) -> bool {
120 self.totals.unsupported > 0
121 }
122
123 #[must_use]
125 pub fn render_human(&self) -> String {
126 let mut out = String::new();
127 let _ = writeln!(out, "MCP trace validation — revision {}", self.revision);
128 for row in &self.requirements {
129 let marker = match row.outcome {
130 Outcome::Pass => "PASS",
131 Outcome::Fail => "FAIL",
132 Outcome::Warn => "WARN",
133 Outcome::Excluded => "EXCL",
134 Outcome::Unsupported => "UNSUP",
135 Outcome::NotApplicable => "N/A",
136 };
137 let _ = writeln!(out, " {marker:<5} {} ({})", row.id, row.level);
138 for finding in &row.findings {
139 match finding.seq {
140 Some(seq) => {
141 let _ = writeln!(out, " seq {seq}: {}", finding.detail);
142 }
143 None => {
144 let _ = writeln!(out, " {}", finding.detail);
145 }
146 }
147 }
148 if let Some(exclusion) = &row.exclusion {
149 let _ = writeln!(out, " excluded: {exclusion}");
150 }
151 for check in &row.missing_checks {
152 let _ = writeln!(out, " unsupported check: {check}");
153 }
154 if let Some(capability) = &row.capability {
155 let _ = writeln!(
156 out,
157 " not applicable: capability {capability} was not declared in this session"
158 );
159 }
160 }
161 let totals = self.totals;
162 let _ = writeln!(
163 out,
164 "totals: {} pass, {} fail, {} warn, {} excluded, {} unsupported, {} not applicable",
165 totals.pass,
166 totals.fail,
167 totals.warn,
168 totals.excluded,
169 totals.unsupported,
170 totals.not_applicable
171 );
172 let _ = writeln!(out, "verdict: {}", self.verdict());
173 out
174 }
175
176 #[must_use]
178 pub const fn verdict(&self) -> Verdict {
179 if self.totals.unsupported > 0 {
180 Verdict::Unsupported
181 } else if self.totals.fail > 0 {
182 Verdict::Fail
183 } else if self.totals.warn > 0 {
184 Verdict::PassWithWarnings
185 } else {
186 Verdict::Pass
187 }
188 }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "kebab-case")]
194#[non_exhaustive]
195pub enum Verdict {
196 Pass,
198 PassWithWarnings,
200 Fail,
202 Unsupported,
204}
205
206impl fmt::Display for Verdict {
207 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208 let text = match self {
209 Self::Pass => "pass",
210 Self::PassWithWarnings => "pass-with-warnings",
211 Self::Fail => "fail",
212 Self::Unsupported => "unsupported",
213 };
214 f.write_str(text)
215 }
216}
217
218#[cfg(test)]
219#[allow(clippy::unwrap_used)]
220mod tests {
221 use super::*;
222
223 fn sample() -> Report {
224 Report {
225 revision: "2025-11-25".to_owned(),
226 totals: Totals {
227 pass: 1,
228 fail: 1,
229 warn: 0,
230 excluded: 1,
231 unsupported: 0,
232 not_applicable: 1,
233 },
234 requirements: vec![
235 RequirementReport {
236 id: "BASE-001".to_owned(),
237 level: "MUST".to_owned(),
238 outcome: Outcome::Pass,
239 findings: vec![],
240 exclusion: None,
241 missing_checks: vec![],
242 capability: None,
243 },
244 RequirementReport {
245 id: "LIFE-001".to_owned(),
246 level: "MUST".to_owned(),
247 outcome: Outcome::Fail,
248 findings: vec![Finding {
249 check: "lifecycle.first-interaction-initialize".to_owned(),
250 seq: Some(3),
251 detail: "first message is \"tools/list\", expected \"initialize\""
252 .to_owned(),
253 }],
254 exclusion: None,
255 missing_checks: vec![],
256 capability: None,
257 },
258 RequirementReport {
259 id: "TRAN-001".to_owned(),
260 level: "MUST NOT".to_owned(),
261 outcome: Outcome::Excluded,
262 findings: vec![],
263 exclusion: Some("enforced at capture time".to_owned()),
264 missing_checks: vec![],
265 capability: None,
266 },
267 RequirementReport {
268 id: "TOOL-001".to_owned(),
269 level: "MUST".to_owned(),
270 outcome: Outcome::NotApplicable,
271 findings: vec![],
272 exclusion: None,
273 missing_checks: vec![],
274 capability: Some("server.tools".to_owned()),
275 },
276 ],
277 }
278 }
279
280 #[test]
281 fn verdict_priority_is_unsupported_fail_warn_pass() {
282 let mut report = sample();
283 assert_eq!(report.verdict(), Verdict::Fail);
284 report.totals.unsupported = 1;
285 assert_eq!(report.verdict(), Verdict::Unsupported);
286 report.totals.unsupported = 0;
287 report.totals.fail = 0;
288 report.totals.warn = 2;
289 assert_eq!(report.verdict(), Verdict::PassWithWarnings);
290 report.totals.warn = 0;
291 assert_eq!(report.verdict(), Verdict::Pass);
292 }
293
294 #[test]
295 fn human_rendering_shows_findings_and_totals() {
296 let text = sample().render_human();
297 assert!(text.contains("FAIL LIFE-001 (MUST)"), "{text}");
298 assert!(text.contains("seq 3:"), "{text}");
299 assert!(
300 text.contains("excluded: enforced at capture time"),
301 "{text}"
302 );
303 assert!(text.contains("N/A TOOL-001 (MUST)"), "{text}");
304 assert!(
305 text.contains(
306 "not applicable: capability server.tools was not declared in this session"
307 ),
308 "{text}"
309 );
310 assert!(
311 text.contains(
312 "totals: 1 pass, 1 fail, 0 warn, 1 excluded, 0 unsupported, 1 not applicable"
313 ),
314 "{text}"
315 );
316 assert!(text.contains("verdict: fail"), "{text}");
317 }
318
319 #[test]
320 fn json_omits_empty_collections() {
321 let report = sample();
322 let json = serde_json::to_string(&report).unwrap();
323 assert!(json.contains("\"revision\":\"2025-11-25\""), "{json}");
324 assert!(!json.contains("\"missing_checks\""), "{json}");
326 }
327
328 #[test]
329 fn totals_predicates_pin_their_thresholds() {
330 let mut report = sample();
331 report.totals = Totals::default();
332 assert!(!report.has_errors());
333 assert!(!report.has_warnings());
334 assert!(!report.has_unsupported());
335 report.totals.fail = 1;
336 assert!(report.has_errors());
337 report.totals.warn = 1;
338 assert!(report.has_warnings());
339 report.totals.unsupported = 1;
340 assert!(report.has_unsupported());
341 }
342}