Skip to main content

xacli_testing/report/
junit.rs

1//! JUnit XML report format support
2//!
3//! This module provides structures for generating JUnit XML reports,
4//! which is a widely supported format for CI/CD systems like Jenkins,
5//! GitHub Actions, GitLab CI, etc.
6//!
7//! # Example
8//!
9//! ```rust
10//! use xacli_testing::report::junit::{TestSuites, TestSuite, TestCase, TestCaseStatus};
11//!
12//! let mut suite = TestSuite::new("my_tests");
13//! suite.add_test_case(TestCase::passed("test_one", 0.1));
14//! suite.add_test_case(TestCase::failed("test_two", 0.2, "assertion failed", "expected 1, got 2"));
15//! suite.add_test_case(TestCase::skipped("test_three", "not implemented"));
16//!
17//! let mut suites = TestSuites::new();
18//! suites.add_suite(suite);
19//!
20//! let xml = suites.to_xml().unwrap();
21//! println!("{}", xml);
22//! ```
23
24use serde::Serialize;
25
26use super::xacli as xacli_report;
27
28// JUnit XML specific structures - independent from xacli report types
29
30/// Test case status for JUnit XML  
31#[derive(Debug, Clone)]
32pub enum TestCaseStatus {
33    /// Test passed
34    Passed,
35    /// Test failed
36    Failed { failure: TestFailure },
37    /// Test had an error
38    Error { error: TestError },
39    /// Test was skipped
40    Skipped { skipped: TestSkipped },
41}
42
43/// Failure information for JUnit XML
44#[derive(Debug, Clone, Serialize)]
45pub struct TestFailure {
46    #[serde(rename = "@message")]
47    pub message: String,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    #[serde(rename = "@type")]
50    pub failure_type: Option<String>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    #[serde(rename = "$text")]
53    pub content: Option<String>,
54}
55
56/// Error information for JUnit XML
57#[derive(Debug, Clone, Serialize)]
58pub struct TestError {
59    #[serde(rename = "@message")]
60    pub message: String,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    #[serde(rename = "@type")]
63    pub error_type: Option<String>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    #[serde(rename = "$text")]
66    pub content: Option<String>,
67}
68
69/// Skipped information for JUnit XML
70#[derive(Debug, Clone, Serialize)]
71pub struct TestSkipped {
72    #[serde(skip_serializing_if = "Option::is_none")]
73    #[serde(rename = "@message")]
74    pub message: Option<String>,
75}
76
77/// Root element containing multiple test suites
78///
79/// Represents the `<testsuites>` element in JUnit XML format.
80#[derive(Debug, Clone, Serialize, Default)]
81#[serde(rename = "testsuites")]
82pub struct TestSuites {
83    /// Name of the test run
84    #[serde(skip_serializing_if = "Option::is_none")]
85    #[serde(rename = "@name")]
86    pub name: Option<String>,
87
88    /// Total number of tests
89    #[serde(rename = "@tests")]
90    pub tests: u32,
91
92    /// Number of failed tests
93    #[serde(rename = "@failures")]
94    pub failures: u32,
95
96    /// Number of tests with errors
97    #[serde(rename = "@errors")]
98    pub errors: u32,
99
100    /// Number of skipped tests
101    #[serde(rename = "@skipped")]
102    pub skipped: u32,
103
104    /// Total time in seconds
105    #[serde(rename = "@time")]
106    pub time: f64,
107
108    /// Timestamp when tests were run
109    #[serde(skip_serializing_if = "Option::is_none")]
110    #[serde(rename = "@timestamp")]
111    pub timestamp: Option<String>,
112
113    /// Individual test suites
114    #[serde(rename = "testsuite")]
115    pub suites: Vec<TestSuite>,
116}
117
118impl TestSuites {
119    /// Create a new empty TestSuites container
120    pub fn new() -> Self {
121        Self::default()
122    }
123
124    /// Create a new TestSuites with a name
125    pub fn with_name(name: impl Into<String>) -> Self {
126        Self {
127            name: Some(name.into()),
128            ..Default::default()
129        }
130    }
131
132    /// Add a test suite and update aggregate statistics
133    pub fn add_suite(&mut self, suite: TestSuite) {
134        self.tests += suite.tests;
135        self.failures += suite.failures;
136        self.errors += suite.errors;
137        self.skipped += suite.skipped;
138        self.time += suite.time;
139        self.suites.push(suite);
140    }
141
142    /// Set the timestamp
143    pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
144        self.timestamp = Some(timestamp.into());
145        self
146    }
147
148    /// Serialize to JUnit XML string
149    pub fn to_xml(&self) -> Result<String, quick_xml::SeError> {
150        let mut xml = String::from(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
151        xml.push('\n');
152        xml.push_str(&quick_xml::se::to_string(self)?);
153        Ok(xml)
154    }
155
156    /// Serialize to JUnit XML with pretty formatting
157    pub fn to_xml_pretty(&self) -> Result<String, quick_xml::SeError> {
158        let mut xml = String::from(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
159        xml.push('\n');
160
161        let mut buffer = String::new();
162        let mut serializer = quick_xml::se::Serializer::new(&mut buffer);
163        serializer.indent(' ', 2);
164        serde::Serialize::serialize(self, serializer)?;
165        xml.push_str(&buffer);
166        Ok(xml)
167    }
168}
169
170/// A single test suite containing multiple test cases
171///
172/// Represents the `<testsuite>` element in JUnit XML format.
173#[derive(Debug, Clone, Serialize, Default)]
174#[serde(rename = "testsuite")]
175pub struct TestSuite {
176    /// Name of the test suite
177    #[serde(rename = "@name")]
178    pub name: String,
179
180    /// Number of tests in this suite
181    #[serde(rename = "@tests")]
182    pub tests: u32,
183
184    /// Number of failed tests
185    #[serde(rename = "@failures")]
186    pub failures: u32,
187
188    /// Number of tests with errors
189    #[serde(rename = "@errors")]
190    pub errors: u32,
191
192    /// Number of skipped tests
193    #[serde(rename = "@skipped")]
194    pub skipped: u32,
195
196    /// Total time in seconds
197    #[serde(rename = "@time")]
198    pub time: f64,
199
200    /// Timestamp when suite was run
201    #[serde(skip_serializing_if = "Option::is_none")]
202    #[serde(rename = "@timestamp")]
203    pub timestamp: Option<String>,
204
205    /// Hostname where tests were run
206    #[serde(skip_serializing_if = "Option::is_none")]
207    #[serde(rename = "@hostname")]
208    pub hostname: Option<String>,
209
210    /// Package or module name
211    #[serde(skip_serializing_if = "Option::is_none")]
212    #[serde(rename = "@package")]
213    pub package: Option<String>,
214
215    /// Test suite properties
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub properties: Option<Properties>,
218
219    /// Individual test cases
220    #[serde(rename = "testcase")]
221    pub test_cases: Vec<TestCase>,
222
223    /// Standard output captured during test run
224    #[serde(skip_serializing_if = "Option::is_none")]
225    #[serde(rename = "system-out")]
226    pub system_out: Option<SystemOutput>,
227
228    /// Standard error captured during test run
229    #[serde(skip_serializing_if = "Option::is_none")]
230    #[serde(rename = "system-err")]
231    pub system_err: Option<SystemOutput>,
232}
233
234impl TestSuite {
235    /// Create a new test suite with the given name
236    pub fn new(name: impl Into<String>) -> Self {
237        Self {
238            name: name.into(),
239            ..Default::default()
240        }
241    }
242
243    /// Add a test case and update statistics
244    pub fn add_test_case(&mut self, test_case: TestCase) {
245        self.tests += 1;
246        self.time += test_case.time;
247
248        match &test_case.status {
249            TestCaseStatus::Passed => {}
250            TestCaseStatus::Failed { .. } => self.failures += 1,
251            TestCaseStatus::Error { .. } => self.errors += 1,
252            TestCaseStatus::Skipped { .. } => self.skipped += 1,
253        }
254
255        self.test_cases.push(test_case);
256    }
257
258    /// Set the timestamp
259    pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
260        self.timestamp = Some(timestamp.into());
261        self
262    }
263
264    /// Set the hostname
265    pub fn with_hostname(mut self, hostname: impl Into<String>) -> Self {
266        self.hostname = Some(hostname.into());
267        self
268    }
269
270    /// Set the package name
271    pub fn with_package(mut self, package: impl Into<String>) -> Self {
272        self.package = Some(package.into());
273        self
274    }
275
276    /// Set system output
277    pub fn with_system_out(mut self, output: impl Into<String>) -> Self {
278        self.system_out = Some(SystemOutput(output.into()));
279        self
280    }
281
282    /// Set system error output
283    pub fn with_system_err(mut self, output: impl Into<String>) -> Self {
284        self.system_err = Some(SystemOutput(output.into()));
285        self
286    }
287
288    /// Add a property
289    pub fn add_property(&mut self, name: impl Into<String>, value: impl Into<String>) {
290        let property = Property {
291            name: name.into(),
292            value: value.into(),
293        };
294        if let Some(props) = &mut self.properties {
295            props.properties.push(property);
296        } else {
297            self.properties = Some(Properties {
298                properties: vec![property],
299            });
300        }
301    }
302}
303
304/// Collection of properties for a test suite
305#[derive(Debug, Clone, Serialize, Default)]
306pub struct Properties {
307    #[serde(rename = "property")]
308    pub properties: Vec<Property>,
309}
310
311/// A single property key-value pair
312#[derive(Debug, Clone, Serialize)]
313pub struct Property {
314    #[serde(rename = "@name")]
315    pub name: String,
316    #[serde(rename = "@value")]
317    pub value: String,
318}
319
320/// System output (stdout or stderr) wrapper
321#[derive(Debug, Clone, Serialize)]
322pub struct SystemOutput(#[serde(rename = "$text")] pub String);
323
324/// A single test case
325///
326/// Represents the `<testcase>` element in JUnit XML format.
327#[derive(Debug, Clone)]
328pub struct TestCase {
329    /// Name of the test case
330    pub name: String,
331
332    /// Class name (usually the module or file containing the test)
333    pub classname: Option<String>,
334
335    /// Time taken in seconds
336    pub time: f64,
337
338    /// Test case status (passed, failed, error, or skipped)
339    pub status: TestCaseStatus,
340
341    /// Standard output captured for this test case
342    pub system_out: Option<SystemOutput>,
343
344    /// Standard error captured for this test case
345    pub system_err: Option<SystemOutput>,
346}
347
348impl serde::Serialize for TestCase {
349    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
350    where
351        S: serde::Serializer,
352    {
353        use serde::ser::SerializeMap;
354
355        // Count fields: name, time, optional classname, optional status elements, optional system-out/err
356        let mut field_count = 2; // name, time
357        if self.classname.is_some() {
358            field_count += 1;
359        }
360        match &self.status {
361            TestCaseStatus::Passed => {}
362            TestCaseStatus::Failed { .. } => field_count += 1,
363            TestCaseStatus::Error { .. } => field_count += 1,
364            TestCaseStatus::Skipped { .. } => field_count += 1,
365        }
366        if self.system_out.is_some() {
367            field_count += 1;
368        }
369        if self.system_err.is_some() {
370            field_count += 1;
371        }
372
373        let mut map = serializer.serialize_map(Some(field_count))?;
374        map.serialize_entry("@name", &self.name)?;
375        if let Some(ref classname) = self.classname {
376            map.serialize_entry("@classname", classname)?;
377        }
378        map.serialize_entry("@time", &self.time)?;
379
380        match &self.status {
381            TestCaseStatus::Passed => {
382                // No child element for passed tests
383            }
384            TestCaseStatus::Failed { failure } => {
385                map.serialize_entry("failure", failure)?;
386            }
387            TestCaseStatus::Error { error } => {
388                map.serialize_entry("error", error)?;
389            }
390            TestCaseStatus::Skipped { skipped } => {
391                map.serialize_entry("skipped", skipped)?;
392            }
393        }
394
395        if let Some(ref stdout) = self.system_out {
396            map.serialize_entry("system-out", stdout)?;
397        }
398        if let Some(ref stderr) = self.system_err {
399            map.serialize_entry("system-err", stderr)?;
400        }
401
402        map.end()
403    }
404}
405
406impl TestCase {
407    /// Create a new passing test case
408    pub fn passed(name: impl Into<String>, time: f64) -> Self {
409        Self {
410            name: name.into(),
411            classname: None,
412            time,
413            status: TestCaseStatus::Passed,
414            system_out: None,
415            system_err: None,
416        }
417    }
418
419    /// Create a new failed test case
420    pub fn failed(
421        name: impl Into<String>,
422        time: f64,
423        message: impl Into<String>,
424        details: impl Into<String>,
425    ) -> Self {
426        Self {
427            name: name.into(),
428            classname: None,
429            time,
430            status: TestCaseStatus::Failed {
431                failure: TestFailure {
432                    message: message.into(),
433                    failure_type: None,
434                    content: Some(details.into()),
435                },
436            },
437            system_out: None,
438            system_err: None,
439        }
440    }
441
442    /// Create a new test case with an error
443    pub fn error(
444        name: impl Into<String>,
445        time: f64,
446        message: impl Into<String>,
447        details: impl Into<String>,
448    ) -> Self {
449        Self {
450            name: name.into(),
451            classname: None,
452            time,
453            status: TestCaseStatus::Error {
454                error: TestError {
455                    message: message.into(),
456                    error_type: None,
457                    content: Some(details.into()),
458                },
459            },
460            system_out: None,
461            system_err: None,
462        }
463    }
464
465    /// Create a new skipped test case
466    pub fn skipped(name: impl Into<String>, message: impl Into<String>) -> Self {
467        Self {
468            name: name.into(),
469            classname: None,
470            time: 0.0,
471            status: TestCaseStatus::Skipped {
472                skipped: TestSkipped {
473                    message: Some(message.into()),
474                },
475            },
476            system_out: None,
477            system_err: None,
478        }
479    }
480
481    /// Set the classname
482    pub fn with_classname(mut self, classname: impl Into<String>) -> Self {
483        self.classname = Some(classname.into());
484        self
485    }
486
487    /// Set system output
488    pub fn with_system_out(mut self, output: impl Into<String>) -> Self {
489        self.system_out = Some(SystemOutput(output.into()));
490        self
491    }
492
493    /// Set system error output
494    pub fn with_system_err(mut self, output: impl Into<String>) -> Self {
495        self.system_err = Some(SystemOutput(output.into()));
496        self
497    }
498}
499
500// ============================================================================
501// From trait implementations for converting xacli report types to JUnit types
502// ============================================================================
503
504impl From<&xacli_report::TestSuitesResult> for TestSuites {
505    fn from(results: &xacli_report::TestSuitesResult) -> Self {
506        let mut suites = TestSuites::new();
507        for (suite_name, suite_result) in &results.suites {
508            suites.add_suite(TestSuite::from((suite_name.as_str(), suite_result)));
509        }
510        suites
511    }
512}
513
514impl From<xacli_report::TestSuitesResult> for TestSuites {
515    fn from(results: xacli_report::TestSuitesResult) -> Self {
516        TestSuites::from(&results)
517    }
518}
519
520impl From<(&str, &xacli_report::TestSuiteResult)> for TestSuite {
521    fn from((name, result): (&str, &xacli_report::TestSuiteResult)) -> Self {
522        let mut suite = TestSuite::new(name);
523        for (test_name, test_result) in &result.tests {
524            suite.add_test_case(TestCase::from((test_name.as_str(), test_result)));
525        }
526        suite
527    }
528}
529
530impl From<(&str, &xacli_report::TestCaseResult)> for TestCase {
531    fn from((name, result): (&str, &xacli_report::TestCaseResult)) -> Self {
532        let duration_secs = result.duration.as_secs_f64();
533
534        match &result.status {
535            xacli_report::TestCaseStatus::Passed => TestCase::passed(name, duration_secs),
536            xacli_report::TestCaseStatus::Failed { failure } => {
537                TestCase::failed(name, duration_secs, &failure.message, "")
538            }
539            xacli_report::TestCaseStatus::Error { error } => {
540                TestCase::error(name, duration_secs, &error.message, "")
541            }
542            xacli_report::TestCaseStatus::Skipped { skipped } => {
543                TestCase::skipped(name, skipped.message.as_deref().unwrap_or(""))
544            }
545        }
546        .with_system_out(&result.stdout)
547        .with_system_err(&result.stderr)
548    }
549}