Skip to main content

mcp_trace_validator/
junit.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! `JUnit` XML rendering of validation reports, for CI systems that ingest test
5//! result files.
6//!
7//! Mapping (documented because `JUnit` has no native concept of a warning):
8//!
9//! | Outcome | `JUnit` representation |
10//! |---------|----------------------|
11//! | `pass` | passing `<testcase>` |
12//! | `fail` | `<failure>` per requirement, findings in the body |
13//! | `warn` | passing `<testcase>` with findings in `<system-out>` — SHOULD-level findings do not fail CI unless promoted by `--strict`, and that promotion is an exit-code concern, not a report concern |
14//! | `excluded` / `unsupported` / `not-applicable` | `<skipped>` with the reason as its message |
15//!
16//! The output is deterministic (registry order, no timestamps, no hostnames) for the
17//! same reason every other report format is: reports are artifacts.
18
19use core::fmt::Write as _;
20
21use crate::report::{Outcome, Report};
22
23/// Renders the report as a single-suite `JUnit` XML document.
24///
25/// ```
26/// use mcp_conformance_core::requirement::Registry;
27/// use mcp_trace_validator::{engine, junit};
28///
29/// let registry = Registry::builtin_2025_11_25()?;
30/// let xml = junit::render(&engine::validate(&registry, &[]));
31/// assert!(xml.starts_with(r#"<?xml version="1.0" encoding="UTF-8"?>"#));
32/// # Ok::<(), Box<dyn core::error::Error>>(())
33/// ```
34#[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
113/// The `<skipped>` message for the three non-judged outcomes.
114fn 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
138/// Minimal XML escaping for text and attribute content (we always double-quote
139/// attributes, so escaping `"` but not `'` is sufficient).
140fn 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("&amp;"),
145            '<' => out.push_str("&lt;"),
146            '>' => out.push_str("&gt;"),
147            '"' => out.push_str("&quot;"),
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(&registry, &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        // One testcase per registry requirement; counts reconcile with the totals.
175        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        // The LIFE-004 warning must NOT be a failure; its findings live in system-out.
183        assert!(xml.contains("<system-out>"), "{xml}");
184        // Balanced tags, exactly once each.
185        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        // Finding details quote method names: "tools/list" arrives inside XML
195        // attributes, and quotes/angles must be escaped, never raw.
196        let xml = render(&report_for(VIOLATION));
197        assert!(xml.contains("&quot;tools/list&quot;"), "{xml}");
198        assert!(
199            !xml.contains(r#"message="first message is a "tools"#),
200            "{xml}"
201        );
202        assert_eq!(escape(r#"<a & "b">"#), "&lt;a &amp; &quot;b&quot;&gt;");
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        // Hand-built report with excluded, unsupported, AND not-applicable rows:
232        // pins the skipped sum (excluded + unsupported + not_applicable), the
233        // per-variant skip messages, and the failure-body location text.
234        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        // skipped = excluded + unsupported + not_applicable, no other arithmetic.
262        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        // The two skip variants carry their own distinct messages.
273        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        // Failure bodies carry the check-and-seq location, verbatim.
282        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}