clnrm_core/reporting/
junit.rs

1//! JUnit XML report format
2//!
3//! Generates JUnit-compatible XML reports for CI/CD integration.
4
5use crate::error::{CleanroomError, Result};
6use crate::validation::ValidationReport;
7use std::path::Path;
8
9/// JUnit XML report generator
10pub struct JunitReporter;
11
12impl JunitReporter {
13    /// Write JUnit XML report to file
14    ///
15    /// # Arguments
16    /// * `path` - File path for XML output
17    /// * `report` - Validation report to convert
18    ///
19    /// # Returns
20    /// * `Result<()>` - Success or error
21    ///
22    /// # Errors
23    /// Returns error if file write fails
24    pub fn write(path: &Path, report: &ValidationReport) -> Result<()> {
25        let xml = Self::generate_xml(report);
26        Self::write_file(path, &xml)
27    }
28
29    /// Generate complete JUnit XML document
30    fn generate_xml(report: &ValidationReport) -> String {
31        let mut xml = String::new();
32
33        Self::append_xml_header(&mut xml);
34        Self::append_testsuite_open(&mut xml, report);
35        Self::append_passed_tests(&mut xml, report);
36        Self::append_failed_tests(&mut xml, report);
37        Self::append_testsuite_close(&mut xml);
38
39        xml
40    }
41
42    /// Append XML header
43    fn append_xml_header(xml: &mut String) {
44        xml.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
45        xml.push('\n');
46    }
47
48    /// Append testsuite opening tag
49    fn append_testsuite_open(xml: &mut String, report: &ValidationReport) {
50        let total = report.passes().len() + report.failures().len();
51        xml.push_str(&format!(
52            r#"<testsuite name="clnrm" tests="{}" failures="{}" errors="0">"#,
53            total,
54            report.failures().len()
55        ));
56        xml.push('\n');
57    }
58
59    /// Append passed test cases
60    fn append_passed_tests(xml: &mut String, report: &ValidationReport) {
61        for pass_name in report.passes() {
62            xml.push_str(&format!(
63                r#"  <testcase name="{}" />"#,
64                Self::escape_xml(pass_name)
65            ));
66            xml.push('\n');
67        }
68    }
69
70    /// Append failed test cases
71    fn append_failed_tests(xml: &mut String, report: &ValidationReport) {
72        for (fail_name, error) in report.failures() {
73            xml.push_str(&format!(
74                r#"  <testcase name="{}">"#,
75                Self::escape_xml(fail_name)
76            ));
77            xml.push('\n');
78            xml.push_str(&format!(
79                r#"    <failure message="{}" />"#,
80                Self::escape_xml(error)
81            ));
82            xml.push('\n');
83            xml.push_str(r#"  </testcase>"#);
84            xml.push('\n');
85        }
86    }
87
88    /// Append testsuite closing tag
89    fn append_testsuite_close(xml: &mut String) {
90        xml.push_str("</testsuite>\n");
91    }
92
93    /// Escape XML special characters
94    fn escape_xml(s: &str) -> String {
95        s.replace('&', "&amp;")
96            .replace('<', "&lt;")
97            .replace('>', "&gt;")
98            .replace('"', "&quot;")
99            .replace('\'', "&apos;")
100    }
101
102    /// Write XML string to file
103    fn write_file(path: &Path, content: &str) -> Result<()> {
104        std::fs::write(path, content)
105            .map_err(|e| CleanroomError::report_error(format!("Failed to write JUnit XML: {}", e)))
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::validation::ValidationReport;
113    use tempfile::TempDir;
114
115    #[test]
116    fn test_junit_reporter_all_pass() -> Result<()> {
117        // Arrange
118        let temp_dir = TempDir::new()
119            .map_err(|e| CleanroomError::io_error(format!("Failed to create temp dir: {}", e)))?;
120        let xml_path = temp_dir.path().join("junit.xml");
121
122        let mut report = ValidationReport::new();
123        report.add_pass("test1");
124        report.add_pass("test2");
125
126        // Act
127        JunitReporter::write(&xml_path, &report)?;
128
129        // Assert
130        let content = std::fs::read_to_string(&xml_path)
131            .map_err(|e| CleanroomError::io_error(format!("Failed to read file: {}", e)))?;
132
133        assert!(content.contains(r#"<?xml version="1.0" encoding="UTF-8"?>"#));
134        assert!(content.contains(r#"<testsuite name="clnrm" tests="2" failures="0""#));
135        assert!(content.contains(r#"<testcase name="test1" />"#));
136        assert!(content.contains(r#"<testcase name="test2" />"#));
137        assert!(content.contains(r#"</testsuite>"#));
138
139        Ok(())
140    }
141
142    #[test]
143    fn test_junit_reporter_with_failures() -> Result<()> {
144        // Arrange
145        let temp_dir = TempDir::new()
146            .map_err(|e| CleanroomError::io_error(format!("Failed to create temp dir: {}", e)))?;
147        let xml_path = temp_dir.path().join("junit.xml");
148
149        let mut report = ValidationReport::new();
150        report.add_pass("test1");
151        report.add_fail("test2", "Expected 2 but got 1".to_string());
152        report.add_fail("test3", "Missing span".to_string());
153
154        // Act
155        JunitReporter::write(&xml_path, &report)?;
156
157        // Assert
158        let content = std::fs::read_to_string(&xml_path)
159            .map_err(|e| CleanroomError::io_error(format!("Failed to read file: {}", e)))?;
160
161        assert!(content.contains(r#"tests="3" failures="2""#));
162        assert!(content.contains(r#"<testcase name="test1" />"#));
163        assert!(content.contains(r#"<testcase name="test2">"#));
164        assert!(content.contains(r#"<failure message="Expected 2 but got 1" />"#));
165        assert!(content.contains(r#"<testcase name="test3">"#));
166        assert!(content.contains(r#"<failure message="Missing span" />"#));
167
168        Ok(())
169    }
170
171    #[test]
172    fn test_junit_reporter_empty_report() -> Result<()> {
173        // Arrange
174        let temp_dir = TempDir::new()
175            .map_err(|e| CleanroomError::io_error(format!("Failed to create temp dir: {}", e)))?;
176        let xml_path = temp_dir.path().join("junit.xml");
177
178        let report = ValidationReport::new();
179
180        // Act
181        JunitReporter::write(&xml_path, &report)?;
182
183        // Assert
184        let content = std::fs::read_to_string(&xml_path)
185            .map_err(|e| CleanroomError::io_error(format!("Failed to read file: {}", e)))?;
186
187        assert!(content.contains(r#"tests="0" failures="0""#));
188
189        Ok(())
190    }
191
192    #[test]
193    fn test_junit_reporter_xml_escaping() -> Result<()> {
194        // Arrange
195        let temp_dir = TempDir::new()
196            .map_err(|e| CleanroomError::io_error(format!("Failed to create temp dir: {}", e)))?;
197        let xml_path = temp_dir.path().join("junit.xml");
198
199        let mut report = ValidationReport::new();
200        report.add_fail(
201            "test_with_<>",
202            r#"Error: "Value" & 'Expected' < 10 > 5"#.to_string(),
203        );
204
205        // Act
206        JunitReporter::write(&xml_path, &report)?;
207
208        // Assert
209        let content = std::fs::read_to_string(&xml_path)
210            .map_err(|e| CleanroomError::io_error(format!("Failed to read file: {}", e)))?;
211
212        assert!(content.contains("&lt;"));
213        assert!(content.contains("&gt;"));
214        assert!(content.contains("&amp;"));
215        assert!(content.contains("&quot;"));
216        assert!(content.contains("&apos;"));
217        // Should NOT contain raw special characters
218        assert!(!content.contains("Error: \"Value\""));
219
220        Ok(())
221    }
222
223    #[test]
224    fn test_escape_xml_all_special_chars() {
225        // Arrange
226        let input = r#"<test>"value"&'data'"#;
227
228        // Act
229        let escaped = JunitReporter::escape_xml(input);
230
231        // Assert
232        assert_eq!(
233            escaped,
234            "&lt;test&gt;&quot;value&quot;&amp;&apos;data&apos;"
235        );
236    }
237
238    #[test]
239    fn test_escape_xml_no_special_chars() {
240        // Arrange
241        let input = "test_value_123";
242
243        // Act
244        let escaped = JunitReporter::escape_xml(input);
245
246        // Assert
247        assert_eq!(escaped, "test_value_123");
248    }
249}