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}