1use core::fmt::Write as _;
20
21use crate::report::{Outcome, Report};
22
23#[must_use]
35pub fn render(report: &Report) -> String {
36 let totals = report.totals;
37 let failures = totals.fail;
38 let skipped =
39 totals.excluded + totals.unsupported + totals.not_applicable + totals.not_observed;
40 let tests = totals.pass + totals.fail + totals.warn + skipped;
41
42 let mut out = String::new();
43 out.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
44 out.push('\n');
45 let _ = writeln!(
46 out,
47 r#"<testsuites tests="{tests}" failures="{failures}" skipped="{skipped}">"#
48 );
49 let _ = writeln!(
50 out,
51 r#" <testsuite name="mcp-trace-validator ({})" tests="{tests}" failures="{failures}" skipped="{skipped}">"#,
52 escape(&report.revision)
53 );
54
55 for row in &report.requirements {
56 render_row(&mut out, report, row);
57 }
58
59 out.push_str(" </testsuite>\n</testsuites>\n");
60 out
61}
62
63fn render_row(out: &mut String, report: &Report, row: &crate::report::RequirementReport) {
64 let name = escape(&format!("{} ({})", row.id, row.level));
65 let classname = escape(&format!("mcp.{}", report.revision));
66 match row.outcome {
67 Outcome::Pass => {
68 let _ = writeln!(
69 out,
70 r#" <testcase classname="{classname}" name="{name}"/>"#
71 );
72 }
73 Outcome::Fail => {
74 let _ = writeln!(
75 out,
76 r#" <testcase classname="{classname}" name="{name}">"#
77 );
78 for finding in &row.findings {
79 let _ = writeln!(
80 out,
81 r#" <failure message="{}">{}</failure>"#,
82 escape(&finding.detail),
83 escape(&location(finding.seq, &finding.check)),
84 );
85 }
86 out.push_str(" </testcase>\n");
87 }
88 Outcome::Warn => {
89 let _ = writeln!(
90 out,
91 r#" <testcase classname="{classname}" name="{name}">"#
92 );
93 out.push_str(" <system-out>");
94 for finding in &row.findings {
95 let _ = writeln!(
96 out,
97 "{}: {}",
98 escape(&location(finding.seq, &finding.check)),
99 escape(&finding.detail)
100 );
101 }
102 out.push_str("</system-out>\n </testcase>\n");
103 }
104 Outcome::Excluded
105 | Outcome::Unsupported
106 | Outcome::NotApplicable
107 | Outcome::NotObserved => {
108 let _ = writeln!(
109 out,
110 r#" <testcase classname="{classname}" name="{name}"><skipped message="{}"/></testcase>"#,
111 escape(&skip_reason(row))
112 );
113 }
114 }
115}
116
117fn skip_reason(row: &crate::report::RequirementReport) -> String {
119 match row.outcome {
120 Outcome::Excluded => row
121 .exclusion
122 .clone()
123 .unwrap_or_else(|| "excluded".to_owned()),
124 Outcome::NotObserved => {
125 "the session carried none of the traffic this clause binds to".to_owned()
126 }
127 Outcome::NotApplicable => format!(
128 "not applicable: capability {} was not declared in this session",
129 row.capability.as_deref().unwrap_or("(unknown)")
130 ),
131 _ => format!(
132 "registry references checks this build does not implement: {}",
133 row.missing_checks.join(", ")
134 ),
135 }
136}
137
138fn location(seq: Option<u64>, check: &str) -> String {
139 seq.map_or_else(
140 || format!("[{check}]"),
141 |seq| format!("[{check}] at seq {seq}"),
142 )
143}
144
145fn escape(text: &str) -> String {
156 let mut out = String::with_capacity(text.len());
157 for ch in text.chars() {
158 match ch {
159 '&' => out.push_str("&"),
160 '<' => out.push_str("<"),
161 '>' => out.push_str(">"),
162 '"' => out.push_str("""),
163 '\t' | '\n' | '\r' => out.push(ch),
164 c if (c as u32) < 0x20 => out.push('\u{FFFD}'),
167 _ => out.push(ch),
168 }
169 }
170 out
171}
172
173#[cfg(test)]
174#[allow(clippy::unwrap_used)]
175mod tests {
176 use super::*;
177 use crate::reader::{Limits, parse_trace};
178 use mcp_conformance_core::requirement::Registry;
179
180 fn report_for(trace: &str) -> Report {
181 let registry = Registry::builtin_2025_11_25().unwrap();
182 let events = parse_trace(trace, &Limits::default()).unwrap();
183 crate::engine::validate(®istry, &events)
184 }
185
186 const VIOLATION: &str = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"tools/list"}}"#;
187
188 #[test]
189 fn renders_well_formed_suite_with_failure_and_skips() {
190 let total = Registry::builtin_2025_11_25().unwrap().requirements().len();
191 let xml = render(&report_for(VIOLATION));
192 assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
193 assert!(
195 xml.contains(&format!(r#"<testsuites tests="{total}""#)),
196 "{xml}"
197 );
198 assert!(xml.contains(r#"name="LIFE-001 (MUST)""#), "{xml}");
199 assert!(xml.contains("<failure message="), "{xml}");
200 assert!(xml.contains("<skipped message="), "{xml}");
201 assert!(xml.contains("<system-out>"), "{xml}");
203 assert_eq!(xml.matches("<testsuites").count(), 1);
205 assert_eq!(xml.matches("</testsuites>").count(), 1);
206 assert_eq!(xml.matches("<testsuite ").count(), 1);
207 assert_eq!(xml.matches("</testsuite>").count(), 1);
208 assert_eq!(xml.matches("<testcase").count(), total);
209 }
210
211 #[test]
212 fn escapes_xml_metacharacters_in_details() {
213 let xml = render(&report_for(VIOLATION));
216 assert!(xml.contains(""tools/list""), "{xml}");
217 assert!(
218 !xml.contains(r#"message="first message is a "tools"#),
219 "{xml}"
220 );
221 assert_eq!(escape(r#"<a & "b">"#), "<a & "b">");
222 }
223
224 #[test]
225 fn escape_substitutes_xml_illegal_control_characters() {
226 assert_eq!(escape("a\u{0001}b\u{001F}c"), "a\u{FFFD}b\u{FFFD}c");
236 assert_eq!(escape("a\tb\nc\rd"), "a\tb\nc\rd");
237 assert_eq!(escape("\u{001F}\u{0020}"), "\u{FFFD} ");
239 }
240
241 #[test]
242 fn passing_reports_have_zero_failures_and_self_closing_cases() {
243 let xml = render(&report_for(
244 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"}}}}
245{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"s","version":"0"}}}}
246{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#,
247 ));
248 assert!(xml.contains(r#"failures="0""#), "{xml}");
249 assert!(xml.contains(r#"name="BASE-001 (MUST)"/>"#), "{xml}");
250 }
251
252 fn bare_row(id: &str, outcome: Outcome) -> crate::report::RequirementReport {
253 crate::report::RequirementReport {
254 id: id.to_owned(),
255 level: "MUST".to_owned(),
256 outcome,
257 findings: vec![],
258 exclusion: None,
259 missing_checks: vec![],
260 capability: None,
261 }
262 }
263
264 fn every_skip_variant() -> Report {
270 use crate::report::{Finding, Totals};
271 let mut failed = bare_row("AAAA-001", Outcome::Fail);
272 failed.findings = vec![Finding {
273 check: "area.some-check".to_owned(),
274 seq: Some(7),
275 detail: "it went wrong".to_owned(),
276 }];
277 let mut excluded_a = bare_row("AAAA-002", Outcome::Excluded);
278 excluded_a.exclusion = Some("not judgeable from traces".to_owned());
279 let mut excluded_b = bare_row("AAAA-003", Outcome::Excluded);
280 excluded_b.exclusion = Some("also excluded".to_owned());
281 let mut unsupported = bare_row("AAAA-004", Outcome::Unsupported);
282 unsupported.missing_checks = vec!["future.check".to_owned()];
283 let mut not_applicable = bare_row("AAAA-005", Outcome::NotApplicable);
284 not_applicable.capability = Some("server.tools".to_owned());
285 Report {
286 revision_mismatch: None,
287 revision: "2025-11-25".to_owned(),
288 totals: Totals {
289 pass: 0,
290 fail: 1,
291 warn: 0,
292 excluded: 2,
293 unsupported: 1,
294 not_applicable: 1,
295 not_observed: 1,
296 },
297 requirements: vec![
298 failed,
299 excluded_a,
300 excluded_b,
301 unsupported,
302 not_applicable,
303 bare_row("AAAA-006", Outcome::NotObserved),
304 ],
305 }
306 }
307
308 #[test]
309 fn skip_accounting_and_location_text_are_exact() {
310 let xml = render(&every_skip_variant());
313 assert!(
316 xml.contains(r#"<testsuites tests="6" failures="1" skipped="5">"#),
317 "{xml}"
318 );
319 assert_eq!(xml.matches("<testcase").count(), 6, "{xml}");
320 assert!(
321 xml.contains(
322 r#"<skipped message="not applicable: capability server.tools was not declared in this session"/>"#
323 ),
324 "{xml}"
325 );
326 assert!(
328 xml.contains(r#"<skipped message="not judgeable from traces"/>"#),
329 "{xml}"
330 );
331 assert!(
332 xml.contains(
333 r#"<skipped message="the session carried none of the traffic this clause binds to"/>"#
334 ),
335 "{xml}"
336 );
337 assert!(
338 xml.contains(r#"<skipped message="registry references checks this build does not implement: future.check"/>"#),
339 "{xml}"
340 );
341 assert!(
343 xml.contains(">[area.some-check] at seq 7</failure>"),
344 "{xml}"
345 );
346 assert_eq!(location(None, "x.y"), "[x.y]");
347 assert_eq!(location(Some(3), "x.y"), "[x.y] at seq 3");
348 }
349}