clnrm_core/formatting/
junit.rs

1//! JUnit XML Formatter
2//!
3//! Generates JUnit-compatible XML output for CI/CD integration.
4//! Follows the JUnit XML schema specification.
5
6use crate::error::Result;
7use crate::formatting::formatter::{Formatter, FormatterType};
8use crate::formatting::test_result::{TestStatus, TestSuite};
9
10/// JUnit XML formatter for test results
11#[derive(Debug, Default)]
12pub struct JunitFormatter;
13
14impl JunitFormatter {
15    /// Create a new JUnit formatter
16    pub fn new() -> Self {
17        Self
18    }
19
20    /// Escape XML special characters
21    fn escape_xml(s: &str) -> String {
22        s.replace('&', "&")
23            .replace('<', "&lt;")
24            .replace('>', "&gt;")
25            .replace('"', "&quot;")
26            .replace('\'', "&apos;")
27    }
28
29    /// Generate XML header
30    fn generate_header() -> String {
31        r#"<?xml version="1.0" encoding="UTF-8"?>"#.to_string()
32    }
33
34    /// Generate testsuite opening tag
35    fn generate_testsuite_open(suite: &TestSuite) -> String {
36        let mut output = format!(
37            r#"<testsuite name="{}" tests="{}" failures="{}" skipped="{}" errors="0""#,
38            Self::escape_xml(&suite.name),
39            suite.total_count(),
40            suite.failed_count(),
41            suite.skipped_count()
42        );
43
44        if let Some(duration) = suite.duration {
45            output.push_str(&format!(" time=\"{:.3}\"", duration.as_secs_f64()));
46        }
47
48        output.push('>');
49        output
50    }
51
52    /// Generate testcase element
53    fn generate_testcase(result: &crate::formatting::test_result::TestResult) -> String {
54        let mut output = format!(
55            r#"  <testcase name="{}" classname="{}""#,
56            Self::escape_xml(&result.name),
57            Self::escape_xml(&result.name)
58        );
59
60        if let Some(duration) = result.duration {
61            output.push_str(&format!(" time=\"{:.3}\"", duration.as_secs_f64()));
62        }
63
64        match result.status {
65            TestStatus::Passed => {
66                output.push_str(" />");
67            }
68            TestStatus::Failed => {
69                output.push_str(">\n");
70                if let Some(error) = &result.error {
71                    output.push_str(&format!(
72                        r#"    <failure message="{}" />"#,
73                        Self::escape_xml(error)
74                    ));
75                } else {
76                    output.push_str(r#"    <failure message="Test failed" />"#);
77                }
78                output.push_str("\n  </testcase>");
79            }
80            TestStatus::Skipped => {
81                output.push_str(">\n");
82                output.push_str("    <skipped />");
83                output.push_str("\n  </testcase>");
84            }
85            TestStatus::Unknown => {
86                output.push_str(" />");
87            }
88        }
89
90        output
91    }
92
93    /// Generate system-out element if needed
94    fn generate_system_out(suite: &TestSuite) -> Option<String> {
95        let stdout_outputs: Vec<String> = suite
96            .results
97            .iter()
98            .filter_map(|r| r.stdout.as_ref())
99            .map(|s| Self::escape_xml(s))
100            .collect();
101
102        if stdout_outputs.is_empty() {
103            None
104        } else {
105            Some(format!(
106                "  <system-out>\n{}\n  </system-out>",
107                stdout_outputs.join("\n")
108            ))
109        }
110    }
111
112    /// Generate system-err element if needed
113    fn generate_system_err(suite: &TestSuite) -> Option<String> {
114        let stderr_outputs: Vec<String> = suite
115            .results
116            .iter()
117            .filter_map(|r| r.stderr.as_ref())
118            .map(|s| Self::escape_xml(s))
119            .collect();
120
121        if stderr_outputs.is_empty() {
122            None
123        } else {
124            Some(format!(
125                "  <system-err>\n{}\n  </system-err>",
126                stderr_outputs.join("\n")
127            ))
128        }
129    }
130}
131
132impl Formatter for JunitFormatter {
133    fn format(&self, suite: &TestSuite) -> Result<String> {
134        let mut output = Vec::new();
135
136        // XML header
137        output.push(Self::generate_header());
138
139        // Testsuite opening tag
140        output.push(Self::generate_testsuite_open(suite));
141
142        // Test cases
143        for result in &suite.results {
144            output.push(Self::generate_testcase(result));
145        }
146
147        // System output if present
148        if let Some(system_out) = Self::generate_system_out(suite) {
149            output.push(system_out);
150        }
151
152        // System error if present
153        if let Some(system_err) = Self::generate_system_err(suite) {
154            output.push(system_err);
155        }
156
157        // Testsuite closing tag
158        output.push("</testsuite>".to_string());
159
160        Ok(output.join("\n"))
161    }
162
163    fn name(&self) -> &'static str {
164        "junit"
165    }
166
167    fn formatter_type(&self) -> FormatterType {
168        FormatterType::Junit
169    }
170}