junit2json/
lib.rs

1//! # junit2json-rs
2//!
3//! junit2json-rs is a tool to convert JUnit XML format to JSON.
4//! From a library perspective, it provides a function to serialize Junit XML to Struct.
5//!
6//! junit2json-rs is a reimplementation of [ts-junit2json](https://github.com/Kesin11/ts-junit2json) that is my previous work in TypeScript.
7//!
8//! # Purpose
9//! junit2json-rs is designed for uploading test result data to BigQuery or any other DB that supports JSON.
10//!
11//! Many languages and test frameworks support to output test result data as JUnit XML format, which is de fact standard in today.
12//! On the other hand, most DBs do not support to import XML but support JSON.
13//!
14//! For this purpose, junit2json-rs provides a simple JUnit XML to JSON converter.
15//!
16//! # Install
17//! ```
18//! cargo install junit2json
19//! ```
20//!
21//! # Usage
22//! ```
23//! junit2json -p <junit_xml_file>
24//! ```
25//!
26//! # Output example
27//! ```json
28//! {
29//!   "testsuites": {
30//!     "name": "gcf_junit_xml_to_bq_dummy",
31//!     "time": 8.018,
32//!     "tests": 12,
33//!     "failures": 2,
34//!     "testsuite": [
35//!       {
36//!         "name": "__tests__/gen_dummy_junit/dummy1.test.js",
37//!         "tests": 4,
38//!         "failures": 1,
39//!         "errors": 0,
40//!         "time": 4.772,
41//!         "skipped": 0,
42//!         "timestamp": "2020-01-12T16:33:13",
43//!         "testcase": [
44//!           {
45//!             "name": "dummy1 Always success tests should be wait 0-2sec",
46//!             "classname": "dummy1 Always success tests should be wait 0-2sec",
47//!             "time": 0.414
48//!           },
49//!           {
50//!             "name": "dummy1 Always success tests should be wait 1-3sec",
51//!             "classname": "dummy1 Always success tests should be wait 1-3sec",
52//!             "time": 1.344
53//!           },
54//!           {
55//!             "name": "dummy1 Randomly fail tests should be wait 0-1sec and fail 50%",
56//!             "classname": "dummy1 Randomly fail tests should be wait 0-1sec and fail 50%",
57//!             "time": 0.673,
58//!             "failure": {
59//!               "inner": "Error: expect(received).toBeGreaterThan(expected)\n\nExpected: > 50\nReceived:   4.897277513425746\n    at Object.it (/Users/kesin/github/gcf_junit_xml_to_bq/__tests__/gen_dummy_junit/dummy1.test.js:22:17)"
60//!             }
61//!           },
62//!           {
63//!             "name": "dummy1 Randomly fail tests should be wait 1-2sec and fail 30%",
64//!             "classname": "dummy1 Randomly fail tests should be wait 1-2sec and fail 30%",
65//!             "time": 1.604
66//!           }
67//!         ]
68//!       },
69//!       {
70//!         "name": "__tests__/gen_dummy_junit/dummy3.test.js",
71//!         "tests": 4,
72//!         "failures": 1,
73//!         "errors": 0,
74//!         "time": 6.372,
75//!         "skipped": 0,
76//!         "timestamp": "2020-01-12T16:33:13",
77//!         "testcase": [
78//!           {
79//!             "name": "dummy3 Always success tests should be wait 0-2sec",
80//!             "classname": "dummy3 Always success tests should be wait 0-2sec",
81//!             "time": 1.328
82//!           },
83//!           {
84//!             "name": "dummy3 Always success tests should be wait 1-3sec",
85//!             "classname": "dummy3 Always success tests should be wait 1-3sec",
86//!             "time": 2.598
87//!           },
88//!           {
89//!             "name": "dummy3 Randomly fail tests should be wait 0-1sec and fail 30%",
90//!             "classname": "dummy3 Randomly fail tests should be wait 0-1sec and fail 30%",
91//!             "time": 0.455,
92//!             "failure": {
93//!               "inner": "Error: expect(received).toBeGreaterThan(expected)\n\nExpected: > 30\nReceived:   12.15901879426653\n    at Object.it (/Users/kesin/github/gcf_junit_xml_to_bq/__tests__/gen_dummy_junit/dummy3.test.js:22:17)"
94//!             }
95//!           },
96//!           {
97//!             "name": "dummy3 Randomly fail tests should be wait 1-2sec and fail 20%",
98//!             "classname": "dummy3 Randomly fail tests should be wait 1-2sec and fail 20%",
99//!             "time": 1.228
100//!           }
101//!         ]
102//!       }
103//!     ]
104//!   }
105//! }
106//! ```
107//!
108//! # With `jq` examples
109//! Show testsuites test count
110//!
111//! ```
112//! junit2json <junit_xml_file> | jq .testsuites.tests
113//! ```
114//!
115//! Show testsuite names
116//!
117//! ```
118//! junit2json <junit_xml_file> | jq .testsuites.testsuite[].name
119//! ```
120//!
121//! Show testcase classnames
122//!
123//! ```
124//! junit2json <junit_xml_file> | jq .testsuites.testsuite[].testcase[].classname
125//! ```
126//!
127//! # Notice
128//! junit2json-rs has some major changes from ts-junit2json.
129//! Most of the changes are to compliant with the JUnit XML Schema.
130//!
131//! - A `testsuites` or `testsuite` key appears in the root of JSON.
132//! - `properties` has `property` array. ts-junit2json has `property` array of object directly.
133//! - `skipped`, `error`, `failure` are object, not array of object.
134//! - If XML has undefined tag, it will be ignored. ts-junit2json will be converted to JSON if possible.
135//!
136//! Referenced JUnit XML Schema:
137//! - <https://llg.cubic.org/docs/junit/>
138//! - <https://github.com/testmoapp/junitxml/tree/main>
139//!
140//! # WASI
141//! junit2json-rs also provides WASI executable.
142//!
143//! If you have wasm runtime (ex. wasmtime), you can execute `junit2json.wasm` that can download from [GitHub Releases](https://github.com/Kesin11/junit2json-rs/releases) instead of native binary.
144//!
145//! ```
146//! wasmtime --dir=. junit2json.wasm -- -p <junit_xml_file>
147//! ```
148//!
149
150use cli::PossibleFilterTags;
151use quick_xml::de;
152use serde::{Deserialize, Serialize};
153use serde_with::skip_serializing_none;
154use std::default;
155use std::io;
156
157pub mod cli;
158
159fn trim_default_items<T: default::Default + PartialEq + Clone>(vec: &mut Option<Vec<T>>) {
160    match vec {
161        Some(v) => {
162            *vec = v
163                .iter()
164                .filter(|&item| item != &Default::default())
165                .cloned()
166                .collect::<Vec<_>>()
167                .into();
168        }
169        None => {}
170    }
171}
172
173/// It corresponds to `<testsuites> or <testsuite>`
174///
175/// ```xml
176/// <testsuites name="testsuites1" tests=1 time=0.1>
177///     <tetssuite>
178///     </testsuite>
179/// </testsuites>
180/// ```
181///
182/// ```xml
183/// <testsuite name="testsuite1" tests=1 time=0.1>
184/// </testsuite>
185/// ```
186#[derive(Serialize, Deserialize, Debug, PartialEq)]
187#[serde(rename_all = "lowercase")]
188pub enum TestSuitesOrTestSuite {
189    TestSuites(TestSuites),
190    TestSuite(Box<TestSuite>),
191}
192impl TestSuitesOrTestSuite {
193    /// Remove all `system-out` and `system-err` from each `testsuite` and `testcase`.
194    ///
195    /// # Examples
196    /// ```
197    /// use junit2json;
198    ///
199    /// let xml = r#"
200    ///   <?xml version="1.0" encoding="UTF-8"?>
201    ///   <testsuites>
202    ///       <testsuite name="suite1">
203    ///           <system-out>system out text</system-out>
204    ///           <system-err>system error text</system-err>
205    ///           <testcase name="case1">
206    ///             <system-out>system out text</system-out>
207    ///             <system-err>system error text</system-err>
208    ///           </testcase>
209    ///       </testsuite>
210    ///   </testsuites>
211    /// "#;
212    /// let mut testsuites = junit2json::from_str(xml).unwrap();
213    /// testsuites.filter_tags(&vec![
214    ///   junit2json::cli::PossibleFilterTags::SystemOut,
215    ///   junit2json::cli::PossibleFilterTags::SystemErr,
216    /// ]);
217    /// println!("{:#?}", testsuites);
218    /// ```
219    pub fn filter_tags(&mut self, tags: &[PossibleFilterTags]) {
220        match self {
221            TestSuitesOrTestSuite::TestSuites(ref mut testsuites) => {
222                testsuites.filter_tags(tags);
223            }
224            TestSuitesOrTestSuite::TestSuite(ref mut testsuite) => {
225                testsuite.filter_tags(tags);
226            }
227        }
228    }
229}
230
231/// It corresponds to `<testsuites>`
232///
233/// ```xml
234/// <testsuites name="testsuites1" tests=1 time=0.1>
235/// </testsuites>
236/// ```
237#[skip_serializing_none]
238#[derive(Serialize, Deserialize, Debug, Default, PartialEq)]
239pub struct TestSuites {
240    #[serde(rename(deserialize = "@name"))]
241    pub name: Option<String>,
242    #[serde(rename(deserialize = "@time"))]
243    pub time: Option<f32>,
244    #[serde(rename(deserialize = "@tests"))]
245    pub tests: Option<u32>,
246    #[serde(rename(deserialize = "@failures"))]
247    pub failures: Option<u32>,
248    #[serde(rename(deserialize = "@errors"))]
249    pub errors: Option<u32>,
250
251    pub testsuite: Option<Vec<TestSuite>>,
252}
253impl TestSuites {
254    pub fn trim_empty_items(&mut self) {
255        match &mut self.testsuite {
256            Some(testsuite) => testsuite
257                .iter_mut()
258                .for_each(|item| item.trim_empty_items()),
259            None => {}
260        }
261    }
262    pub fn filter_tags(&mut self, tags: &[PossibleFilterTags]) {
263        match &mut self.testsuite {
264            Some(testsuite) => testsuite.iter_mut().for_each(|item| item.filter_tags(tags)),
265            None => {}
266        }
267    }
268}
269
270/// It corresponds to `<testsuite>`
271///
272/// ```xml
273/// <testsuite name="testsuite1" tests=1 time=0.1>
274/// </testsuite>
275/// ```
276#[skip_serializing_none]
277#[derive(Serialize, Deserialize, Debug, Default, PartialEq)]
278pub struct TestSuite {
279    #[serde(rename(deserialize = "@name"))]
280    pub name: Option<String>,
281    #[serde(rename(deserialize = "@tests"))]
282    pub tests: Option<u32>,
283    #[serde(rename(deserialize = "@failures"))]
284    pub failures: Option<u32>,
285    #[serde(rename(deserialize = "@errors"))]
286    pub errors: Option<u32>,
287    #[serde(rename(deserialize = "@group"))]
288    pub group: Option<String>,
289    #[serde(rename(deserialize = "@time"))]
290    pub time: Option<f32>,
291    #[serde(rename(deserialize = "@disabled"))]
292    pub disabled: Option<u32>,
293    #[serde(rename(deserialize = "@skipped"))]
294    pub skipped: Option<u32>,
295    #[serde(rename(deserialize = "@timestamp"))]
296    pub timestamp: Option<String>,
297    #[serde(rename(deserialize = "@hostname"))]
298    pub hostname: Option<String>,
299    #[serde(rename(deserialize = "@id"))]
300    pub id: Option<String>,
301    #[serde(rename(deserialize = "@package"))]
302    pub package: Option<String>,
303    #[serde(rename(deserialize = "@file"))]
304    pub file: Option<String>,
305    #[serde(rename(deserialize = "@log"))]
306    pub log: Option<String>,
307    #[serde(rename(deserialize = "@url"))]
308    pub url: Option<String>,
309
310    #[serde(rename = "system-out")]
311    pub system_out: Option<Vec<String>>,
312    #[serde(rename = "system-err")]
313    pub system_err: Option<Vec<String>>,
314    pub properties: Option<Properties>,
315    pub testcase: Option<Vec<TestCase>>,
316}
317impl TestSuite {
318    pub fn trim_empty_items(&mut self) {
319        trim_default_items(&mut self.system_out);
320        trim_default_items(&mut self.system_err);
321
322        match &mut self.properties {
323            Some(properties) => {
324                properties.trim_empty_items();
325                if properties.property.is_none() {
326                    self.properties = None;
327                }
328            }
329            None => {}
330        }
331        match &mut self.testcase {
332            Some(testcase) => testcase.iter_mut().for_each(|item| item.trim_empty_items()),
333            None => {}
334        }
335    }
336    pub fn filter_tags(&mut self, tags: &[PossibleFilterTags]) {
337        for tag in tags.iter() {
338            match tag {
339                PossibleFilterTags::SystemOut => self.system_out = None,
340                PossibleFilterTags::SystemErr => self.system_err = None,
341            }
342        }
343        match &mut self.testcase {
344            Some(testcase) => testcase.iter_mut().for_each(|item| item.filter_tags(tags)),
345            None => {}
346        }
347    }
348}
349
350/// It corresponds to `<testcase>`
351///
352/// ```xml
353/// <testcase name="testcase1" time=0.1>
354/// </testcase>
355/// ```
356#[skip_serializing_none]
357#[derive(Serialize, Deserialize, Debug, Default, PartialEq)]
358pub struct TestCase {
359    #[serde(rename(deserialize = "@name"))]
360    pub name: Option<String>,
361    #[serde(rename(deserialize = "@classname"))]
362    pub classname: Option<String>,
363    #[serde(rename(deserialize = "@assertions"))]
364    pub assertions: Option<u32>,
365    #[serde(rename(deserialize = "@time"))]
366    pub time: Option<f32>,
367    #[serde(rename(deserialize = "@status"))]
368    pub status: Option<String>,
369    #[serde(rename(deserialize = "@file"))]
370    pub file: Option<String>,
371    #[serde(rename(deserialize = "@line"))]
372    pub line: Option<u32>,
373
374    #[serde(rename = "system-out")]
375    pub system_out: Option<Vec<String>>,
376    #[serde(rename = "system-err")]
377    pub system_err: Option<Vec<String>>,
378    pub skipped: Option<Detail>,
379    pub error: Option<Detail>,
380    pub failure: Option<Detail>,
381}
382impl TestCase {
383    pub fn trim_empty_items(&mut self) {
384        trim_default_items(&mut self.system_out);
385        trim_default_items(&mut self.system_err);
386    }
387    pub fn filter_tags(&mut self, tags: &[PossibleFilterTags]) {
388        for tag in tags.iter() {
389            match tag {
390                PossibleFilterTags::SystemOut => self.system_out = None,
391                PossibleFilterTags::SystemErr => self.system_err = None,
392            }
393        }
394    }
395}
396
397/// It corresponds to `<skipped>, <error>, <failure>`
398///
399/// ```xml
400/// <testcase>
401///    <skipped message="foo" type="bar">Skipped</skipped>
402///    <error message="foo" type="bar">Error</error>
403///    <failure message="foo" type="bar">Failure</failure>
404/// </testcase>
405/// ```
406#[skip_serializing_none]
407#[derive(Serialize, Deserialize, Debug, Default, PartialEq)]
408pub struct Detail {
409    #[serde(rename(deserialize = "@message"))]
410    pub message: Option<String>,
411    #[serde(rename(deserialize = "@type"))]
412    pub r#type: Option<String>,
413    #[serde(rename(deserialize = "$value"))]
414    pub inner: Option<String>,
415}
416
417/// It corresponds to `<properties>`
418///
419/// ```xml
420/// <properties>
421///    <property name="foo" value="bar" />
422/// </properties>
423/// ```
424#[skip_serializing_none]
425#[derive(Serialize, Deserialize, Debug, Default, PartialEq)]
426pub struct Properties {
427    pub property: Option<Vec<Property>>,
428}
429impl Properties {
430    pub fn trim_empty_items(&mut self) {
431        trim_default_items(&mut self.property);
432    }
433}
434
435/// It corresponds to `<property>`
436///
437/// ```xml
438/// <property name="foo" value="bar" />
439/// ```
440#[skip_serializing_none]
441#[derive(Serialize, Deserialize, Debug, Default, PartialEq, Clone)]
442pub struct Property {
443    #[serde(rename(deserialize = "@name"))]
444    pub name: Option<String>,
445    #[serde(rename(deserialize = "@value"))]
446    pub value: Option<String>,
447}
448
449/// Deserialize JUnit XML from a reader.
450///
451/// # Examples
452/// ```
453/// use junit2json;
454/// use std::process;
455/// use std::fs::File;
456/// use std::io::BufReader;
457///
458/// let path = "tests/fixtures/cargo-nextest.xml";
459/// let file = File::open(path).unwrap_or_else(|msg| {
460///     eprintln!("File::open error: {}", msg);
461///     process::exit(1);
462/// });
463/// let reader = BufReader::new(file);
464/// let testsuites = junit2json::from_reader(reader).unwrap_or_else(|msg| {
465///     eprintln!("junit2json::from_reader error: {}", msg);
466///     process::exit(1);
467/// });
468/// println!("{:#?}", testsuites);
469/// ```
470pub fn from_reader<T>(reader: io::BufReader<T>) -> Result<TestSuitesOrTestSuite, quick_xml::DeError>
471where
472    T: io::Read,
473{
474    let mut root: TestSuitesOrTestSuite = de::from_reader(reader)?;
475    match root {
476        TestSuitesOrTestSuite::TestSuites(ref mut testsuites) => testsuites.trim_empty_items(),
477        TestSuitesOrTestSuite::TestSuite(ref mut testsuite) => testsuite.trim_empty_items(),
478    }
479    Ok(root)
480}
481
482/// Deserialize JUnit XML from a string.
483///
484/// # Examples
485/// ```
486/// use junit2json;
487/// use std::process;
488///
489/// let xml = r#"
490///     <?xml version="1.0" encoding="UTF-8"?>
491///     <testsuites>
492///         <testsuite failures="1" tests="2">
493///         </testsuite>
494///     </testsuites>
495/// "#;
496/// let testsuites = junit2json::from_str(xml).unwrap_or_else(|msg| {
497///     eprintln!("junit2json::from_str error: {}", msg);
498///     process::exit(1);
499/// });
500/// println!("{:#?}", testsuites);
501/// ```
502pub fn from_str(s: &str) -> Result<TestSuitesOrTestSuite, quick_xml::DeError> {
503    from_reader(io::BufReader::new(s.as_bytes()))
504}