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 {
141 let mut out = String::with_capacity(text.len());
142 for ch in text.chars() {
143 match ch {
144 '&' => out.push_str("&"),
145 '<' => out.push_str("<"),
146 '>' => out.push_str(">"),
147 '"' => out.push_str("""),
148 _ => out.push(ch),
149 }
150 }
151 out
152}
153
154#[cfg(test)]
155#[allow(clippy::unwrap_used)]
156mod tests {
157 use super::*;
158 use crate::reader::{Limits, parse_trace};
159 use mcp_conformance_core::requirement::Registry;
160
161 fn report_for(trace: &str) -> Report {
162 let registry = Registry::builtin_2025_11_25().unwrap();
163 let events = parse_trace(trace, &Limits::default()).unwrap();
164 crate::engine::validate(®istry, &events)
165 }
166
167 const VIOLATION: &str = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"tools/list"}}"#;
168
169 #[test]
170 fn renders_well_formed_suite_with_failure_and_skips() {
171 let total = Registry::builtin_2025_11_25().unwrap().requirements().len();
172 let xml = render(&report_for(VIOLATION));
173 assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
174 assert!(
176 xml.contains(&format!(r#"<testsuites tests="{total}""#)),
177 "{xml}"
178 );
179 assert!(xml.contains(r#"name="LIFE-001 (MUST)""#), "{xml}");
180 assert!(xml.contains("<failure message="), "{xml}");
181 assert!(xml.contains("<skipped message="), "{xml}");
182 assert!(xml.contains("<system-out>"), "{xml}");
184 assert_eq!(xml.matches("<testsuites").count(), 1);
186 assert_eq!(xml.matches("</testsuites>").count(), 1);
187 assert_eq!(xml.matches("<testsuite ").count(), 1);
188 assert_eq!(xml.matches("</testsuite>").count(), 1);
189 assert_eq!(xml.matches("<testcase").count(), total);
190 }
191
192 #[test]
193 fn escapes_xml_metacharacters_in_details() {
194 let xml = render(&report_for(VIOLATION));
197 assert!(xml.contains(""tools/list""), "{xml}");
198 assert!(
199 !xml.contains(r#"message="first message is a "tools"#),
200 "{xml}"
201 );
202 assert_eq!(escape(r#"<a & "b">"#), "<a & "b">");
203 }
204
205 #[test]
206 fn passing_reports_have_zero_failures_and_self_closing_cases() {
207 let xml = render(&report_for(
208 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"}}}}
209{"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"}}}}
210{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#,
211 ));
212 assert!(xml.contains(r#"failures="0""#), "{xml}");
213 assert!(xml.contains(r#"name="BASE-001 (MUST)"/>"#), "{xml}");
214 }
215
216 fn bare_row(id: &str, outcome: Outcome) -> crate::report::RequirementReport {
217 crate::report::RequirementReport {
218 id: id.to_owned(),
219 level: "MUST".to_owned(),
220 outcome,
221 findings: vec![],
222 exclusion: None,
223 missing_checks: vec![],
224 capability: None,
225 }
226 }
227
228 #[test]
229 fn skip_accounting_and_location_text_are_exact() {
230 use crate::report::{Finding, Totals};
231 let mut failed = bare_row("AAAA-001", Outcome::Fail);
235 failed.findings = vec![Finding {
236 check: "area.some-check".to_owned(),
237 seq: Some(7),
238 detail: "it went wrong".to_owned(),
239 }];
240 let mut excluded_a = bare_row("AAAA-002", Outcome::Excluded);
241 excluded_a.exclusion = Some("not judgeable from traces".to_owned());
242 let mut excluded_b = bare_row("AAAA-003", Outcome::Excluded);
243 excluded_b.exclusion = Some("also excluded".to_owned());
244 let mut unsupported = bare_row("AAAA-004", Outcome::Unsupported);
245 unsupported.missing_checks = vec!["future.check".to_owned()];
246 let mut not_applicable = bare_row("AAAA-005", Outcome::NotApplicable);
247 not_applicable.capability = Some("server.tools".to_owned());
248 let report = Report {
249 revision: "2025-11-25".to_owned(),
250 totals: Totals {
251 pass: 0,
252 fail: 1,
253 warn: 0,
254 excluded: 2,
255 unsupported: 1,
256 not_applicable: 1,
257 },
258 requirements: vec![failed, excluded_a, excluded_b, unsupported, not_applicable],
259 };
260 let xml = render(&report);
261 assert!(
263 xml.contains(r#"<testsuites tests="5" failures="1" skipped="4">"#),
264 "{xml}"
265 );
266 assert!(
267 xml.contains(
268 r#"<skipped message="not applicable: capability server.tools was not declared in this session"/>"#
269 ),
270 "{xml}"
271 );
272 assert!(
274 xml.contains(r#"<skipped message="not judgeable from traces"/>"#),
275 "{xml}"
276 );
277 assert!(
278 xml.contains(r#"<skipped message="registry references checks this build does not implement: future.check"/>"#),
279 "{xml}"
280 );
281 assert!(
283 xml.contains(">[area.some-check] at seq 7</failure>"),
284 "{xml}"
285 );
286 assert_eq!(location(None, "x.y"), "[x.y]");
287 assert_eq!(location(Some(3), "x.y"), "[x.y] at seq 3");
288 }
289}