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 = totals.excluded + totals.unsupported + totals.not_applicable;
39 let tests = totals.pass + totals.fail + totals.warn + skipped;
40
41 let mut out = String::new();
42 out.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
43 out.push('\n');
44 let _ = writeln!(
45 out,
46 r#"<testsuites tests="{tests}" failures="{failures}" skipped="{skipped}">"#
47 );
48 let _ = writeln!(
49 out,
50 r#" <testsuite name="mcp-trace-validator ({})" tests="{tests}" failures="{failures}" skipped="{skipped}">"#,
51 escape(&report.revision)
52 );
53
54 for row in &report.requirements {
55 render_row(&mut out, report, row);
56 }
57
58 out.push_str(" </testsuite>\n</testsuites>\n");
59 out
60}
61
62fn render_row(out: &mut String, report: &Report, row: &crate::report::RequirementReport) {
63 let name = escape(&format!("{} ({})", row.id, row.level));
64 let classname = escape(&format!("mcp.{}", report.revision));
65 match row.outcome {
66 Outcome::Pass => {
67 let _ = writeln!(
68 out,
69 r#" <testcase classname="{classname}" name="{name}"/>"#
70 );
71 }
72 Outcome::Fail => {
73 let _ = writeln!(
74 out,
75 r#" <testcase classname="{classname}" name="{name}">"#
76 );
77 for finding in &row.findings {
78 let _ = writeln!(
79 out,
80 r#" <failure message="{}">{}</failure>"#,
81 escape(&finding.detail),
82 escape(&location(finding.seq, &finding.check)),
83 );
84 }
85 out.push_str(" </testcase>\n");
86 }
87 Outcome::Warn => {
88 let _ = writeln!(
89 out,
90 r#" <testcase classname="{classname}" name="{name}">"#
91 );
92 out.push_str(" <system-out>");
93 for finding in &row.findings {
94 let _ = writeln!(
95 out,
96 "{}: {}",
97 escape(&location(finding.seq, &finding.check)),
98 escape(&finding.detail)
99 );
100 }
101 out.push_str("</system-out>\n </testcase>\n");
102 }
103 Outcome::Excluded | Outcome::Unsupported | Outcome::NotApplicable => {
104 let _ = writeln!(
105 out,
106 r#" <testcase classname="{classname}" name="{name}"><skipped message="{}"/></testcase>"#,
107 escape(&skip_reason(row))
108 );
109 }
110 }
111}
112
113fn skip_reason(row: &crate::report::RequirementReport) -> String {
115 match row.outcome {
116 Outcome::Excluded => row
117 .exclusion
118 .clone()
119 .unwrap_or_else(|| "excluded".to_owned()),
120 Outcome::NotApplicable => format!(
121 "not applicable: capability {} was not declared in this session",
122 row.capability.as_deref().unwrap_or("(unknown)")
123 ),
124 _ => format!(
125 "registry references checks this build does not implement: {}",
126 row.missing_checks.join(", ")
127 ),
128 }
129}
130
131fn location(seq: Option<u64>, check: &str) -> String {
132 seq.map_or_else(
133 || format!("[{check}]"),
134 |seq| format!("[{check}] at seq {seq}"),
135 )
136}
137
138fn escape(text: &str) -> String {
149 let mut out = String::with_capacity(text.len());
150 for ch in text.chars() {
151 match ch {
152 '&' => out.push_str("&"),
153 '<' => out.push_str("<"),
154 '>' => out.push_str(">"),
155 '"' => out.push_str("""),
156 '\t' | '\n' | '\r' => out.push(ch),
157 c if (c as u32) < 0x20 => out.push('\u{FFFD}'),
160 _ => out.push(ch),
161 }
162 }
163 out
164}
165
166#[cfg(test)]
167#[allow(clippy::unwrap_used)]
168mod tests {
169 use super::*;
170 use crate::reader::{Limits, parse_trace};
171 use mcp_conformance_core::requirement::Registry;
172
173 fn report_for(trace: &str) -> Report {
174 let registry = Registry::builtin_2025_11_25().unwrap();
175 let events = parse_trace(trace, &Limits::default()).unwrap();
176 crate::engine::validate(®istry, &events)
177 }
178
179 const VIOLATION: &str = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"tools/list"}}"#;
180
181 #[test]
182 fn renders_well_formed_suite_with_failure_and_skips() {
183 let total = Registry::builtin_2025_11_25().unwrap().requirements().len();
184 let xml = render(&report_for(VIOLATION));
185 assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
186 assert!(
188 xml.contains(&format!(r#"<testsuites tests="{total}""#)),
189 "{xml}"
190 );
191 assert!(xml.contains(r#"name="LIFE-001 (MUST)""#), "{xml}");
192 assert!(xml.contains("<failure message="), "{xml}");
193 assert!(xml.contains("<skipped message="), "{xml}");
194 assert!(xml.contains("<system-out>"), "{xml}");
196 assert_eq!(xml.matches("<testsuites").count(), 1);
198 assert_eq!(xml.matches("</testsuites>").count(), 1);
199 assert_eq!(xml.matches("<testsuite ").count(), 1);
200 assert_eq!(xml.matches("</testsuite>").count(), 1);
201 assert_eq!(xml.matches("<testcase").count(), total);
202 }
203
204 #[test]
205 fn escapes_xml_metacharacters_in_details() {
206 let xml = render(&report_for(VIOLATION));
209 assert!(xml.contains(""tools/list""), "{xml}");
210 assert!(
211 !xml.contains(r#"message="first message is a "tools"#),
212 "{xml}"
213 );
214 assert_eq!(escape(r#"<a & "b">"#), "<a & "b">");
215 }
216
217 #[test]
218 fn escape_substitutes_xml_illegal_control_characters() {
219 assert_eq!(escape("a\u{0001}b\u{001F}c"), "a\u{FFFD}b\u{FFFD}c");
229 assert_eq!(escape("a\tb\nc\rd"), "a\tb\nc\rd");
230 assert_eq!(escape("\u{001F}\u{0020}"), "\u{FFFD} ");
232 }
233
234 #[test]
235 fn passing_reports_have_zero_failures_and_self_closing_cases() {
236 let xml = render(&report_for(
237 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"}}}}
238{"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"}}}}
239{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#,
240 ));
241 assert!(xml.contains(r#"failures="0""#), "{xml}");
242 assert!(xml.contains(r#"name="BASE-001 (MUST)"/>"#), "{xml}");
243 }
244
245 fn bare_row(id: &str, outcome: Outcome) -> crate::report::RequirementReport {
246 crate::report::RequirementReport {
247 id: id.to_owned(),
248 level: "MUST".to_owned(),
249 outcome,
250 findings: vec![],
251 exclusion: None,
252 missing_checks: vec![],
253 capability: None,
254 }
255 }
256
257 #[test]
258 fn skip_accounting_and_location_text_are_exact() {
259 use crate::report::{Finding, Totals};
260 let mut failed = bare_row("AAAA-001", Outcome::Fail);
264 failed.findings = vec![Finding {
265 check: "area.some-check".to_owned(),
266 seq: Some(7),
267 detail: "it went wrong".to_owned(),
268 }];
269 let mut excluded_a = bare_row("AAAA-002", Outcome::Excluded);
270 excluded_a.exclusion = Some("not judgeable from traces".to_owned());
271 let mut excluded_b = bare_row("AAAA-003", Outcome::Excluded);
272 excluded_b.exclusion = Some("also excluded".to_owned());
273 let mut unsupported = bare_row("AAAA-004", Outcome::Unsupported);
274 unsupported.missing_checks = vec!["future.check".to_owned()];
275 let mut not_applicable = bare_row("AAAA-005", Outcome::NotApplicable);
276 not_applicable.capability = Some("server.tools".to_owned());
277 let report = Report {
278 revision: "2025-11-25".to_owned(),
279 totals: Totals {
280 pass: 0,
281 fail: 1,
282 warn: 0,
283 excluded: 2,
284 unsupported: 1,
285 not_applicable: 1,
286 },
287 requirements: vec![failed, excluded_a, excluded_b, unsupported, not_applicable],
288 };
289 let xml = render(&report);
290 assert!(
292 xml.contains(r#"<testsuites tests="5" failures="1" skipped="4">"#),
293 "{xml}"
294 );
295 assert!(
296 xml.contains(
297 r#"<skipped message="not applicable: capability server.tools was not declared in this session"/>"#
298 ),
299 "{xml}"
300 );
301 assert!(
303 xml.contains(r#"<skipped message="not judgeable from traces"/>"#),
304 "{xml}"
305 );
306 assert!(
307 xml.contains(r#"<skipped message="registry references checks this build does not implement: future.check"/>"#),
308 "{xml}"
309 );
310 assert!(
312 xml.contains(">[area.some-check] at seq 7</failure>"),
313 "{xml}"
314 );
315 assert_eq!(location(None, "x.y"), "[x.y]");
316 assert_eq!(location(Some(3), "x.y"), "[x.y] at seq 3");
317 }
318}