quick_junit/
deserialize.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crate::{
5    DeserializeError, DeserializeErrorKind, NonSuccessKind, PathElement, Property, Report,
6    ReportUuid, TestCase, TestCaseStatus, TestRerun, TestSuite, XmlString,
7};
8use chrono::{DateTime, FixedOffset};
9use indexmap::IndexMap;
10use newtype_uuid::GenericUuid;
11use quick_xml::{
12    escape::{resolve_xml_entity, unescape_with},
13    events::{BytesStart, Event},
14    Reader,
15};
16use std::{io::BufRead, time::Duration};
17
18impl Report {
19    /// **Experimental**: Deserializes a JUnit XML report from a reader.
20    ///
21    /// The deserializer should work with JUnit reports generated by the
22    /// `quick-junit` crate, but might not work with JUnit reports generated by
23    /// other tools. Patches to fix this are welcome.
24    ///
25    /// # Errors
26    ///
27    /// Returns an error if the XML is malformed, or if required attributes are
28    /// missing.
29    pub fn deserialize<R: BufRead>(reader: R) -> Result<Self, DeserializeError> {
30        let mut xml_reader = Reader::from_reader(reader);
31        xml_reader.config_mut().trim_text(false);
32        deserialize_report(&mut xml_reader)
33    }
34
35    /// Deserializes a JUnit XML report from a string.
36    ///
37    /// # Errors
38    ///
39    /// Returns an error if the XML is malformed, or if required attributes are
40    /// missing.
41    ///
42    /// # Examples
43    ///
44    /// ```rust
45    /// use quick_junit::Report;
46    ///
47    /// let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
48    /// <testsuites name="my-test-run" tests="1" failures="0" errors="0">
49    ///     <testsuite name="my-test-suite" tests="1" disabled="0" errors="0" failures="0">
50    ///         <testcase name="success-case"/>
51    ///     </testsuite>
52    /// </testsuites>
53    /// "#;
54    ///
55    /// let report = Report::deserialize_from_str(xml).unwrap();
56    /// assert_eq!(report.name.as_str(), "my-test-run");
57    /// assert_eq!(report.tests, 1);
58    /// ```
59    pub fn deserialize_from_str(xml: &str) -> Result<Self, DeserializeError> {
60        Self::deserialize(xml.as_bytes())
61    }
62}
63
64/// Deserializes a Report from XML.
65fn deserialize_report<R: BufRead>(reader: &mut Reader<R>) -> Result<Report, DeserializeError> {
66    let mut buf = Vec::new();
67    let mut report: Option<Report> = None;
68    let mut properly_closed = false;
69    let root_path = vec![PathElement::TestSuites];
70
71    loop {
72        match reader.read_event_into(&mut buf) {
73            Ok(Event::Start(e)) if e.name().as_ref() == b"testsuites" => {
74                let mut name = None;
75                let mut uuid = None;
76                let mut timestamp = None;
77                let mut time = None;
78                let mut tests = 0;
79                let mut failures = 0;
80                let mut errors = 0;
81
82                for attr in e.attributes() {
83                    let attr = attr.map_err(|e| {
84                        DeserializeError::new(DeserializeErrorKind::AttrError(e), root_path.clone())
85                    })?;
86                    let mut attr_path = root_path.clone();
87                    match attr.key.as_ref() {
88                        b"name" => {
89                            attr_path.push(PathElement::Attribute("name".to_string()));
90                            name = Some(parse_xml_string(&attr.value, &attr_path)?);
91                        }
92                        b"uuid" => {
93                            attr_path.push(PathElement::Attribute("uuid".to_string()));
94                            uuid = Some(parse_uuid(&attr.value, &attr_path)?);
95                        }
96                        b"timestamp" => {
97                            attr_path.push(PathElement::Attribute("timestamp".to_string()));
98                            timestamp = Some(parse_timestamp(&attr.value, &attr_path)?);
99                        }
100                        b"time" => {
101                            attr_path.push(PathElement::Attribute("time".to_string()));
102                            time = Some(parse_duration(&attr.value, &attr_path)?);
103                        }
104                        b"tests" => {
105                            attr_path.push(PathElement::Attribute("tests".to_string()));
106                            tests = parse_usize(&attr.value, &attr_path)?;
107                        }
108                        b"failures" => {
109                            attr_path.push(PathElement::Attribute("failures".to_string()));
110                            failures = parse_usize(&attr.value, &attr_path)?;
111                        }
112                        b"errors" => {
113                            attr_path.push(PathElement::Attribute("errors".to_string()));
114                            errors = parse_usize(&attr.value, &attr_path)?;
115                        }
116                        _ => {} // Ignore unknown attributes
117                    }
118                }
119
120                let name = name.ok_or_else(|| {
121                    let mut attr_path = root_path.clone();
122                    attr_path.push(PathElement::Attribute("name".to_string()));
123                    DeserializeError::new(
124                        DeserializeErrorKind::MissingAttribute("name".to_string()),
125                        attr_path,
126                    )
127                })?;
128
129                let test_suites = Vec::new();
130
131                report = Some(Report {
132                    name,
133                    uuid,
134                    timestamp,
135                    time,
136                    tests,
137                    failures,
138                    errors,
139                    test_suites,
140                });
141            }
142            Ok(Event::Empty(e)) if e.name().as_ref() == b"testsuites" => {
143                let mut name = None;
144                let mut uuid = None;
145                let mut timestamp = None;
146                let mut time = None;
147                let mut tests = 0;
148                let mut failures = 0;
149                let mut errors = 0;
150
151                for attr in e.attributes() {
152                    let attr = attr.map_err(|e| {
153                        DeserializeError::new(DeserializeErrorKind::AttrError(e), root_path.clone())
154                    })?;
155                    let mut attr_path = root_path.clone();
156                    match attr.key.as_ref() {
157                        b"name" => {
158                            attr_path.push(PathElement::Attribute("name".to_string()));
159                            name = Some(parse_xml_string(&attr.value, &attr_path)?);
160                        }
161                        b"uuid" => {
162                            attr_path.push(PathElement::Attribute("uuid".to_string()));
163                            uuid = Some(parse_uuid(&attr.value, &attr_path)?);
164                        }
165                        b"timestamp" => {
166                            attr_path.push(PathElement::Attribute("timestamp".to_string()));
167                            timestamp = Some(parse_timestamp(&attr.value, &attr_path)?);
168                        }
169                        b"time" => {
170                            attr_path.push(PathElement::Attribute("time".to_string()));
171                            time = Some(parse_duration(&attr.value, &attr_path)?);
172                        }
173                        b"tests" => {
174                            attr_path.push(PathElement::Attribute("tests".to_string()));
175                            tests = parse_usize(&attr.value, &attr_path)?;
176                        }
177                        b"failures" => {
178                            attr_path.push(PathElement::Attribute("failures".to_string()));
179                            failures = parse_usize(&attr.value, &attr_path)?;
180                        }
181                        b"errors" => {
182                            attr_path.push(PathElement::Attribute("errors".to_string()));
183                            errors = parse_usize(&attr.value, &attr_path)?;
184                        }
185                        _ => {} // Ignore unknown attributes
186                    }
187                }
188
189                let name = name.ok_or_else(|| {
190                    let mut attr_path = root_path.clone();
191                    attr_path.push(PathElement::Attribute("name".to_string()));
192                    DeserializeError::new(
193                        DeserializeErrorKind::MissingAttribute("name".to_string()),
194                        attr_path,
195                    )
196                })?;
197
198                report = Some(Report {
199                    name,
200                    uuid,
201                    timestamp,
202                    time,
203                    tests,
204                    failures,
205                    errors,
206                    test_suites: Vec::new(),
207                });
208                properly_closed = true; // Empty elements are self-closing
209            }
210            Ok(Event::Start(e)) if e.name().as_ref() == b"testsuite" => {
211                if let Some(ref mut report) = report {
212                    let suite_index = report.test_suites.len();
213                    let test_suite = deserialize_test_suite(reader, &e, &root_path, suite_index)?;
214                    report.test_suites.push(test_suite);
215                }
216            }
217            Ok(Event::Empty(e)) if e.name().as_ref() == b"testsuite" => {
218                if let Some(ref mut report) = report {
219                    let suite_index = report.test_suites.len();
220                    let test_suite = deserialize_test_suite_empty(&e, &root_path, suite_index)?;
221                    report.test_suites.push(test_suite);
222                }
223            }
224            Ok(Event::End(e)) if e.name().as_ref() == b"testsuites" => {
225                properly_closed = true;
226                break;
227            }
228            Ok(Event::Eof) => break,
229            Ok(_) => {}
230            Err(e) => {
231                return Err(DeserializeError::new(
232                    DeserializeErrorKind::XmlError(e),
233                    root_path.clone(),
234                ))
235            }
236        }
237        buf.clear();
238    }
239
240    if !properly_closed && report.is_some() {
241        return Err(DeserializeError::new(
242            DeserializeErrorKind::InvalidStructure(
243                "unexpected EOF, <testsuites> not properly closed".to_string(),
244            ),
245            root_path,
246        ));
247    }
248
249    report.ok_or_else(|| {
250        DeserializeError::new(
251            DeserializeErrorKind::InvalidStructure("missing <testsuites> element".to_string()),
252            Vec::new(),
253        )
254    })
255}
256
257/// Deserializes a TestSuite from XML (for <testsuite> start tag).
258fn deserialize_test_suite<R: BufRead>(
259    reader: &mut Reader<R>,
260    start_element: &BytesStart<'_>,
261    path: &[PathElement],
262    suite_index: usize,
263) -> Result<TestSuite, DeserializeError> {
264    let mut name = None;
265    let mut tests = 0;
266    let mut disabled = 0;
267    let mut errors = 0;
268    let mut failures = 0;
269    let mut timestamp = None;
270    let mut time = None;
271    let mut extra = IndexMap::new();
272
273    // First pass: extract name and other attributes
274    for attr in start_element.attributes() {
275        let attr = attr.map_err(|e| {
276            let mut suite_path = path.to_vec();
277            suite_path.push(PathElement::TestSuite(suite_index, None));
278            DeserializeError::new(DeserializeErrorKind::AttrError(e), suite_path)
279        })?;
280        let mut attr_path = path.to_vec();
281        attr_path.push(PathElement::TestSuite(suite_index, None));
282        match attr.key.as_ref() {
283            b"name" => {
284                attr_path.push(PathElement::Attribute("name".to_string()));
285                name = Some(parse_xml_string(&attr.value, &attr_path)?);
286            }
287            b"tests" => {
288                attr_path.push(PathElement::Attribute("tests".to_string()));
289                tests = parse_usize(&attr.value, &attr_path)?;
290            }
291            b"disabled" => {
292                attr_path.push(PathElement::Attribute("disabled".to_string()));
293                disabled = parse_usize(&attr.value, &attr_path)?;
294            }
295            b"errors" => {
296                attr_path.push(PathElement::Attribute("errors".to_string()));
297                errors = parse_usize(&attr.value, &attr_path)?;
298            }
299            b"failures" => {
300                attr_path.push(PathElement::Attribute("failures".to_string()));
301                failures = parse_usize(&attr.value, &attr_path)?;
302            }
303            b"timestamp" => {
304                attr_path.push(PathElement::Attribute("timestamp".to_string()));
305                timestamp = Some(parse_timestamp(&attr.value, &attr_path)?);
306            }
307            b"time" => {
308                attr_path.push(PathElement::Attribute("time".to_string()));
309                time = Some(parse_duration(&attr.value, &attr_path)?);
310            }
311            _ => {
312                // Store unknown attributes in extra
313                let key = parse_xml_string(attr.key.as_ref(), &attr_path)?;
314                let value = parse_xml_string(&attr.value, &attr_path)?;
315                extra.insert(key, value);
316            }
317        }
318    }
319
320    let name_value = name.clone().ok_or_else(|| {
321        let mut attr_path = path.to_vec();
322        attr_path.push(PathElement::TestSuite(suite_index, None));
323        attr_path.push(PathElement::Attribute("name".to_string()));
324        DeserializeError::new(
325            DeserializeErrorKind::MissingAttribute("name".to_string()),
326            attr_path,
327        )
328    })?;
329
330    // Build the test suite path with the name
331    let mut suite_path = path.to_vec();
332    suite_path.push(PathElement::TestSuite(
333        suite_index,
334        Some(name_value.as_str().to_string()),
335    ));
336
337    let mut test_cases = Vec::new();
338    let mut properties = Vec::new();
339    let mut system_out = None;
340    let mut system_err = None;
341    let mut buf = Vec::new();
342
343    loop {
344        match reader.read_event_into(&mut buf) {
345            Ok(Event::Start(ref e)) => {
346                let element_name = e.name().as_ref().to_vec();
347                if &element_name == b"testcase" {
348                    let test_case =
349                        deserialize_test_case(reader, e, &suite_path, test_cases.len())?;
350                    test_cases.push(test_case);
351                } else if &element_name == b"properties" {
352                    properties = deserialize_properties(reader, &suite_path)?;
353                } else if &element_name == b"system-out" {
354                    let mut child_path = suite_path.clone();
355                    child_path.push(PathElement::SystemOut);
356                    system_out = Some(read_text_content(reader, b"system-out", &child_path)?);
357                } else if &element_name == b"system-err" {
358                    let mut child_path = suite_path.clone();
359                    child_path.push(PathElement::SystemErr);
360                    system_err = Some(read_text_content(reader, b"system-err", &child_path)?);
361                } else {
362                    // Skip unknown elements.
363                    let tag_name = e.name().to_owned();
364                    reader
365                        .read_to_end_into(tag_name, &mut Vec::new())
366                        .map_err(|e| {
367                            DeserializeError::new(
368                                DeserializeErrorKind::XmlError(e),
369                                suite_path.clone(),
370                            )
371                        })?;
372                }
373            }
374            Ok(Event::Empty(ref e)) => {
375                if e.name().as_ref() == b"testcase" {
376                    let test_case = deserialize_test_case_empty(e, &suite_path, test_cases.len())?;
377                    test_cases.push(test_case);
378                }
379            }
380            Ok(Event::End(ref e)) if e.name().as_ref() == b"testsuite" => break,
381            Ok(Event::Eof) => {
382                return Err(DeserializeError::new(
383                    DeserializeErrorKind::InvalidStructure(
384                        "unexpected EOF in <testsuite>".to_string(),
385                    ),
386                    suite_path,
387                ))
388            }
389            Ok(_) => {}
390            Err(e) => {
391                return Err(DeserializeError::new(
392                    DeserializeErrorKind::XmlError(e),
393                    suite_path,
394                ))
395            }
396        }
397        buf.clear();
398    }
399
400    Ok(TestSuite {
401        name: name_value,
402        tests,
403        disabled,
404        errors,
405        failures,
406        timestamp,
407        time,
408        test_cases,
409        properties,
410        system_out,
411        system_err,
412        extra,
413    })
414}
415
416/// Deserializes an empty TestSuite from XML (for <testsuite/> empty tag).
417fn deserialize_test_suite_empty(
418    element: &BytesStart<'_>,
419    path: &[PathElement],
420    suite_index: usize,
421) -> Result<TestSuite, DeserializeError> {
422    let mut name = None;
423    let mut tests = 0;
424    let mut disabled = 0;
425    let mut errors = 0;
426    let mut failures = 0;
427    let mut timestamp = None;
428    let mut time = None;
429    let mut extra = IndexMap::new();
430
431    // First pass: extract name and other attributes
432    for attr in element.attributes() {
433        let attr = attr.map_err(|e| {
434            let mut suite_path = path.to_vec();
435            suite_path.push(PathElement::TestSuite(suite_index, None));
436            DeserializeError::new(DeserializeErrorKind::AttrError(e), suite_path)
437        })?;
438        let mut attr_path = path.to_vec();
439        attr_path.push(PathElement::TestSuite(suite_index, None));
440        match attr.key.as_ref() {
441            b"name" => {
442                attr_path.push(PathElement::Attribute("name".to_string()));
443                name = Some(parse_xml_string(&attr.value, &attr_path)?);
444            }
445            b"tests" => {
446                attr_path.push(PathElement::Attribute("tests".to_string()));
447                tests = parse_usize(&attr.value, &attr_path)?;
448            }
449            b"disabled" => {
450                attr_path.push(PathElement::Attribute("disabled".to_string()));
451                disabled = parse_usize(&attr.value, &attr_path)?;
452            }
453            b"errors" => {
454                attr_path.push(PathElement::Attribute("errors".to_string()));
455                errors = parse_usize(&attr.value, &attr_path)?;
456            }
457            b"failures" => {
458                attr_path.push(PathElement::Attribute("failures".to_string()));
459                failures = parse_usize(&attr.value, &attr_path)?;
460            }
461            b"timestamp" => {
462                attr_path.push(PathElement::Attribute("timestamp".to_string()));
463                timestamp = Some(parse_timestamp(&attr.value, &attr_path)?);
464            }
465            b"time" => {
466                attr_path.push(PathElement::Attribute("time".to_string()));
467                time = Some(parse_duration(&attr.value, &attr_path)?);
468            }
469            _ => {
470                let key = parse_xml_string(attr.key.as_ref(), &attr_path)?;
471                let value = parse_xml_string(&attr.value, &attr_path)?;
472                extra.insert(key, value);
473            }
474        }
475    }
476
477    let name = name.ok_or_else(|| {
478        let mut attr_path = path.to_vec();
479        attr_path.push(PathElement::TestSuite(suite_index, None));
480        attr_path.push(PathElement::Attribute("name".to_string()));
481        DeserializeError::new(
482            DeserializeErrorKind::MissingAttribute("name".to_string()),
483            attr_path,
484        )
485    })?;
486
487    Ok(TestSuite {
488        name,
489        tests,
490        disabled,
491        errors,
492        failures,
493        timestamp,
494        time,
495        test_cases: Vec::new(),
496        properties: Vec::new(),
497        system_out: None,
498        system_err: None,
499        extra,
500    })
501}
502
503/// Deserializes a TestCase from XML (for <testcase> start tag).
504fn deserialize_test_case<R: BufRead>(
505    reader: &mut Reader<R>,
506    start_element: &BytesStart<'_>,
507    path: &[PathElement],
508    case_index: usize,
509) -> Result<TestCase, DeserializeError> {
510    let mut name = None;
511    let mut classname = None;
512    let mut assertions = None;
513    let mut timestamp = None;
514    let mut time = None;
515    let mut extra = IndexMap::new();
516
517    // First pass: extract name and other attributes
518    for attr in start_element.attributes() {
519        let attr = attr.map_err(|e| {
520            let mut case_path = path.to_vec();
521            case_path.push(PathElement::TestCase(case_index, None));
522            DeserializeError::new(DeserializeErrorKind::AttrError(e), case_path)
523        })?;
524        let mut attr_path = path.to_vec();
525        attr_path.push(PathElement::TestCase(case_index, None));
526        match attr.key.as_ref() {
527            b"name" => {
528                attr_path.push(PathElement::Attribute("name".to_string()));
529                name = Some(parse_xml_string(&attr.value, &attr_path)?);
530            }
531            b"classname" => {
532                attr_path.push(PathElement::Attribute("classname".to_string()));
533                classname = Some(parse_xml_string(&attr.value, &attr_path)?);
534            }
535            b"assertions" => {
536                attr_path.push(PathElement::Attribute("assertions".to_string()));
537                assertions = Some(parse_usize(&attr.value, &attr_path)?);
538            }
539            b"timestamp" => {
540                attr_path.push(PathElement::Attribute("timestamp".to_string()));
541                timestamp = Some(parse_timestamp(&attr.value, &attr_path)?);
542            }
543            b"time" => {
544                attr_path.push(PathElement::Attribute("time".to_string()));
545                time = Some(parse_duration(&attr.value, &attr_path)?);
546            }
547            _ => {
548                let key = parse_xml_string(attr.key.as_ref(), &attr_path)?;
549                let value = parse_xml_string(&attr.value, &attr_path)?;
550                extra.insert(key, value);
551            }
552        }
553    }
554
555    let name_value = name.clone().ok_or_else(|| {
556        let mut attr_path = path.to_vec();
557        attr_path.push(PathElement::TestCase(case_index, None));
558        attr_path.push(PathElement::Attribute("name".to_string()));
559        DeserializeError::new(
560            DeserializeErrorKind::MissingAttribute("name".to_string()),
561            attr_path,
562        )
563    })?;
564
565    // Build the test case path with the name
566    let mut case_path = path.to_vec();
567    case_path.push(PathElement::TestCase(
568        case_index,
569        Some(name_value.as_str().to_string()),
570    ));
571
572    let mut properties = Vec::new();
573    let mut system_out = None;
574    let mut system_err = None;
575    let mut status_elements = Vec::new();
576    let mut buf = Vec::new();
577
578    loop {
579        match reader.read_event_into(&mut buf) {
580            Ok(Event::Start(ref e)) => {
581                let element_name = e.name().as_ref().to_vec();
582                let is_status_element = matches!(
583                    element_name.as_slice(),
584                    b"failure"
585                        | b"error"
586                        | b"skipped"
587                        | b"flakyFailure"
588                        | b"flakyError"
589                        | b"rerunFailure"
590                        | b"rerunError"
591                );
592
593                if is_status_element {
594                    let status_element = deserialize_status_element(reader, e, false, &case_path)?;
595                    status_elements.push(status_element);
596                } else if &element_name == b"properties" {
597                    properties = deserialize_properties(reader, &case_path)?;
598                } else if &element_name == b"system-out" {
599                    let mut child_path = case_path.clone();
600                    child_path.push(PathElement::SystemOut);
601                    system_out = Some(read_text_content(reader, b"system-out", &child_path)?);
602                } else if &element_name == b"system-err" {
603                    let mut child_path = case_path.clone();
604                    child_path.push(PathElement::SystemErr);
605                    system_err = Some(read_text_content(reader, b"system-err", &child_path)?);
606                } else {
607                    // Skip unknown Start elements
608                    let tag_name = e.name().to_owned();
609                    reader
610                        .read_to_end_into(tag_name, &mut Vec::new())
611                        .map_err(|e| {
612                            DeserializeError::new(
613                                DeserializeErrorKind::XmlError(e),
614                                case_path.clone(),
615                            )
616                        })?;
617                }
618            }
619            Ok(Event::Empty(ref e)) => {
620                let element_name = e.name().as_ref().to_vec();
621                let is_status_element = matches!(
622                    element_name.as_slice(),
623                    b"failure"
624                        | b"error"
625                        | b"skipped"
626                        | b"flakyFailure"
627                        | b"flakyError"
628                        | b"rerunFailure"
629                        | b"rerunError"
630                );
631
632                if is_status_element {
633                    let status_element = deserialize_status_element(reader, e, true, &case_path)?;
634                    status_elements.push(status_element);
635                }
636                // Empty elements don't need special handling for properties, system-out, system-err
637            }
638            Ok(Event::End(ref e)) if e.name().as_ref() == b"testcase" => break,
639            Ok(Event::Eof) => {
640                return Err(DeserializeError::new(
641                    DeserializeErrorKind::InvalidStructure(
642                        "unexpected EOF in <testcase>".to_string(),
643                    ),
644                    case_path,
645                ))
646            }
647            Ok(_) => {}
648            Err(e) => {
649                return Err(DeserializeError::new(
650                    DeserializeErrorKind::XmlError(e),
651                    case_path,
652                ))
653            }
654        }
655        buf.clear();
656    }
657
658    let status = build_test_case_status(status_elements, &case_path)?;
659
660    Ok(TestCase {
661        name: name_value,
662        classname,
663        assertions,
664        timestamp,
665        time,
666        status,
667        system_out,
668        system_err,
669        extra,
670        properties,
671    })
672}
673
674/// Deserializes an empty TestCase from XML (for <testcase/> empty tag).
675fn deserialize_test_case_empty(
676    element: &BytesStart<'_>,
677    path: &[PathElement],
678    case_index: usize,
679) -> Result<TestCase, DeserializeError> {
680    let mut name = None;
681    let mut classname = None;
682    let mut assertions = None;
683    let mut timestamp = None;
684    let mut time = None;
685    let mut extra = IndexMap::new();
686
687    for attr in element.attributes() {
688        let attr = attr.map_err(|e| {
689            let mut case_path = path.to_vec();
690            case_path.push(PathElement::TestCase(case_index, None));
691            DeserializeError::new(DeserializeErrorKind::AttrError(e), case_path)
692        })?;
693        let mut attr_path = path.to_vec();
694        attr_path.push(PathElement::TestCase(case_index, None));
695        match attr.key.as_ref() {
696            b"name" => {
697                attr_path.push(PathElement::Attribute("name".to_string()));
698                name = Some(parse_xml_string(&attr.value, &attr_path)?);
699            }
700            b"classname" => {
701                attr_path.push(PathElement::Attribute("classname".to_string()));
702                classname = Some(parse_xml_string(&attr.value, &attr_path)?);
703            }
704            b"assertions" => {
705                attr_path.push(PathElement::Attribute("assertions".to_string()));
706                assertions = Some(parse_usize(&attr.value, &attr_path)?);
707            }
708            b"timestamp" => {
709                attr_path.push(PathElement::Attribute("timestamp".to_string()));
710                timestamp = Some(parse_timestamp(&attr.value, &attr_path)?);
711            }
712            b"time" => {
713                attr_path.push(PathElement::Attribute("time".to_string()));
714                time = Some(parse_duration(&attr.value, &attr_path)?);
715            }
716            _ => {
717                let key = parse_xml_string(attr.key.as_ref(), &attr_path)?;
718                let value = parse_xml_string(&attr.value, &attr_path)?;
719                extra.insert(key, value);
720            }
721        }
722    }
723
724    let name_value = name.ok_or_else(|| {
725        let mut attr_path = path.to_vec();
726        attr_path.push(PathElement::TestCase(case_index, None));
727        attr_path.push(PathElement::Attribute("name".to_string()));
728        DeserializeError::new(
729            DeserializeErrorKind::MissingAttribute("name".to_string()),
730            attr_path,
731        )
732    })?;
733
734    Ok(TestCase {
735        name: name_value,
736        classname,
737        assertions,
738        timestamp,
739        time,
740        status: TestCaseStatus::success(),
741        system_out: None,
742        system_err: None,
743        extra,
744        properties: Vec::new(),
745    })
746}
747
748/// Represents a parsed status element (failure, error, skipped, etc.)
749#[derive(Debug)]
750/// Common data for all status elements
751struct StatusElementData {
752    message: Option<XmlString>,
753    ty: Option<XmlString>,
754    description: Option<XmlString>,
755    stack_trace: Option<XmlString>,
756    system_out: Option<XmlString>,
757    system_err: Option<XmlString>,
758    timestamp: Option<DateTime<FixedOffset>>,
759    time: Option<Duration>,
760}
761
762/// Main status element kind (failure, error, or skipped)
763#[derive(Debug, PartialEq, Eq, Clone, Copy)]
764enum MainStatusKind {
765    Failure,
766    Error,
767    Skipped,
768}
769
770/// Main status element
771struct MainStatusElement {
772    kind: MainStatusKind,
773    data: StatusElementData,
774}
775
776/// Rerun/flaky status element kind (failure or error)
777#[derive(Debug, PartialEq, Eq, Clone, Copy)]
778enum RerunStatusKind {
779    Failure,
780    Error,
781}
782
783/// Rerun or flaky status element
784struct RerunStatusElement {
785    kind: RerunStatusKind,
786    data: StatusElementData,
787}
788
789/// Categorized status element
790enum StatusElement {
791    Main(MainStatusElement),
792    Flaky(RerunStatusElement),
793    Rerun(RerunStatusElement),
794}
795
796enum StatusCategory {
797    Main(MainStatusKind),
798    Flaky(RerunStatusKind),
799    Rerun(RerunStatusKind),
800}
801
802/// Deserializes a status element (failure, error, skipped, flaky*, rerun*).
803fn deserialize_status_element<R: BufRead>(
804    reader: &mut Reader<R>,
805    element: &BytesStart<'_>,
806    is_empty: bool,
807    path: &[PathElement],
808) -> Result<StatusElement, DeserializeError> {
809    let (category, status_path_elem) = match element.name().as_ref() {
810        b"failure" => (
811            StatusCategory::Main(MainStatusKind::Failure),
812            PathElement::Failure,
813        ),
814        b"error" => (
815            StatusCategory::Main(MainStatusKind::Error),
816            PathElement::Error,
817        ),
818        b"skipped" => (
819            StatusCategory::Main(MainStatusKind::Skipped),
820            PathElement::Skipped,
821        ),
822        b"flakyFailure" => (
823            StatusCategory::Flaky(RerunStatusKind::Failure),
824            PathElement::FlakyFailure,
825        ),
826        b"flakyError" => (
827            StatusCategory::Flaky(RerunStatusKind::Error),
828            PathElement::FlakyError,
829        ),
830        b"rerunFailure" => (
831            StatusCategory::Rerun(RerunStatusKind::Failure),
832            PathElement::RerunFailure,
833        ),
834        b"rerunError" => (
835            StatusCategory::Rerun(RerunStatusKind::Error),
836            PathElement::RerunError,
837        ),
838        _ => {
839            return Err(DeserializeError::new(
840                DeserializeErrorKind::UnexpectedElement(
841                    String::from_utf8_lossy(element.name().as_ref()).to_string(),
842                ),
843                path.to_vec(),
844            ))
845        }
846    };
847
848    let mut status_path = path.to_vec();
849    status_path.push(status_path_elem);
850
851    let mut message = None;
852    let mut ty = None;
853    let mut timestamp = None;
854    let mut time = None;
855
856    for attr in element.attributes() {
857        let attr = attr.map_err(|e| {
858            DeserializeError::new(DeserializeErrorKind::AttrError(e), status_path.clone())
859        })?;
860        let mut attr_path = status_path.clone();
861        match attr.key.as_ref() {
862            b"message" => {
863                attr_path.push(PathElement::Attribute("message".to_string()));
864                message = Some(parse_xml_string(&attr.value, &attr_path)?);
865            }
866            b"type" => {
867                attr_path.push(PathElement::Attribute("type".to_string()));
868                ty = Some(parse_xml_string(&attr.value, &attr_path)?);
869            }
870            b"timestamp" => {
871                attr_path.push(PathElement::Attribute("timestamp".to_string()));
872                timestamp = Some(parse_timestamp(&attr.value, &attr_path)?);
873            }
874            b"time" => {
875                attr_path.push(PathElement::Attribute("time".to_string()));
876                time = Some(parse_duration(&attr.value, &attr_path)?);
877            }
878            _ => {} // Ignore unknown attributes
879        }
880    }
881
882    let mut description_text = String::new();
883    let mut stack_trace = None;
884    let mut system_out = None;
885    let mut system_err = None;
886
887    // Only read child content if this is not an empty element.
888    if !is_empty {
889        let mut buf = Vec::new();
890        loop {
891            match reader.read_event_into(&mut buf) {
892                Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
893                    let element_name = e.name().as_ref().to_vec();
894                    if &element_name == b"stackTrace" {
895                        let mut child_path = status_path.clone();
896                        child_path.push(PathElement::Attribute("stackTrace".to_string()));
897                        stack_trace = Some(read_text_content(reader, b"stackTrace", &child_path)?);
898                    } else if &element_name == b"system-out" {
899                        let mut child_path = status_path.clone();
900                        child_path.push(PathElement::SystemOut);
901                        system_out = Some(read_text_content(reader, b"system-out", &child_path)?);
902                    } else if &element_name == b"system-err" {
903                        let mut child_path = status_path.clone();
904                        child_path.push(PathElement::SystemErr);
905                        system_err = Some(read_text_content(reader, b"system-err", &child_path)?);
906                    } else {
907                        // Skip unknown Start elements
908                        let tag_name = e.name().to_owned();
909                        reader
910                            .read_to_end_into(tag_name, &mut Vec::new())
911                            .map_err(|e| {
912                                DeserializeError::new(
913                                    DeserializeErrorKind::XmlError(e),
914                                    status_path.clone(),
915                                )
916                            })?;
917                    }
918                }
919                Ok(Event::Text(ref e)) => {
920                    let text = std::str::from_utf8(e.as_ref()).map_err(|e| {
921                        DeserializeError::new(
922                            DeserializeErrorKind::Utf8Error(e),
923                            status_path.clone(),
924                        )
925                    })?;
926                    // Unescape XML entities in the text content and accumulate
927                    let unescaped = unescape_with(text, resolve_xml_entity).map_err(|e| {
928                        DeserializeError::new(
929                            DeserializeErrorKind::EscapeError(e),
930                            status_path.clone(),
931                        )
932                    })?;
933                    description_text.push_str(&unescaped);
934                }
935                Ok(Event::CData(ref e)) => {
936                    // CDATA sections are already unescaped, just accumulate
937                    let text = std::str::from_utf8(e.as_ref()).map_err(|e| {
938                        DeserializeError::new(
939                            DeserializeErrorKind::Utf8Error(e),
940                            status_path.clone(),
941                        )
942                    })?;
943                    description_text.push_str(text);
944                }
945                Ok(Event::GeneralRef(ref e)) => {
946                    // Handle entity references like &quot;, &amp;, etc.
947                    let entity_name = std::str::from_utf8(e.as_ref()).map_err(|e| {
948                        DeserializeError::new(
949                            DeserializeErrorKind::Utf8Error(e),
950                            status_path.clone(),
951                        )
952                    })?;
953                    let unescaped = resolve_xml_entity(entity_name).ok_or_else(|| {
954                        DeserializeError::new(
955                            DeserializeErrorKind::InvalidStructure(format!(
956                                "unrecognized entity: {entity_name}",
957                            )),
958                            status_path.clone(),
959                        )
960                    })?;
961                    description_text.push_str(unescaped);
962                }
963                Ok(Event::End(ref e))
964                    if matches!(
965                        e.name().as_ref(),
966                        b"failure"
967                            | b"error"
968                            | b"skipped"
969                            | b"flakyFailure"
970                            | b"flakyError"
971                            | b"rerunFailure"
972                            | b"rerunError"
973                    ) =>
974                {
975                    break
976                }
977                Ok(Event::Eof) => {
978                    return Err(DeserializeError::new(
979                        DeserializeErrorKind::InvalidStructure(
980                            "unexpected EOF in status element".to_string(),
981                        ),
982                        status_path,
983                    ))
984                }
985                Ok(_) => {}
986                Err(e) => {
987                    return Err(DeserializeError::new(
988                        DeserializeErrorKind::XmlError(e),
989                        status_path,
990                    ))
991                }
992            }
993            buf.clear();
994        }
995    }
996
997    // Convert accumulated text to final description, trimming whitespace
998    let description = if !description_text.trim().is_empty() {
999        Some(XmlString::new(description_text.trim()))
1000    } else {
1001        None
1002    };
1003
1004    let data = StatusElementData {
1005        message,
1006        ty,
1007        description,
1008        stack_trace,
1009        system_out,
1010        system_err,
1011        timestamp,
1012        time,
1013    };
1014
1015    Ok(match category {
1016        StatusCategory::Main(kind) => StatusElement::Main(MainStatusElement { kind, data }),
1017        StatusCategory::Flaky(kind) => StatusElement::Flaky(RerunStatusElement { kind, data }),
1018        StatusCategory::Rerun(kind) => StatusElement::Rerun(RerunStatusElement { kind, data }),
1019    })
1020}
1021
1022/// Builds a TestCaseStatus from parsed status elements.
1023fn build_test_case_status(
1024    status_elements: Vec<StatusElement>,
1025    path: &[PathElement],
1026) -> Result<TestCaseStatus, DeserializeError> {
1027    if status_elements.is_empty() {
1028        return Ok(TestCaseStatus::success());
1029    }
1030
1031    // Separate the main status from reruns and flaky runs.
1032    let mut main_status: Option<&MainStatusElement> = None;
1033    let mut flaky_runs = Vec::new();
1034    let mut reruns = Vec::new();
1035
1036    for element in &status_elements {
1037        match element {
1038            StatusElement::Main(main) => {
1039                if main_status.is_some() {
1040                    return Err(DeserializeError::new(
1041                        DeserializeErrorKind::InvalidStructure(
1042                            "multiple main status elements (failure/error/skipped) are not allowed"
1043                                .to_string(),
1044                        ),
1045                        path.to_vec(),
1046                    ));
1047                }
1048                main_status = Some(main);
1049            }
1050            StatusElement::Flaky(flaky) => {
1051                flaky_runs.push(flaky);
1052            }
1053            StatusElement::Rerun(rerun) => {
1054                reruns.push(rerun);
1055            }
1056        }
1057    }
1058
1059    // Build the status.
1060    if let Some(main) = main_status {
1061        match main.kind {
1062            MainStatusKind::Skipped => Ok(TestCaseStatus::Skipped {
1063                message: main.data.message.clone(),
1064                ty: main.data.ty.clone(),
1065                description: main.data.description.clone(),
1066            }),
1067            MainStatusKind::Failure | MainStatusKind::Error => {
1068                let kind = if main.kind == MainStatusKind::Failure {
1069                    NonSuccessKind::Failure
1070                } else {
1071                    NonSuccessKind::Error
1072                };
1073
1074                let reruns = reruns.into_iter().map(build_test_rerun).collect();
1075
1076                Ok(TestCaseStatus::NonSuccess {
1077                    kind,
1078                    message: main.data.message.clone(),
1079                    ty: main.data.ty.clone(),
1080                    description: main.data.description.clone(),
1081                    reruns,
1082                })
1083            }
1084        }
1085    } else if !flaky_runs.is_empty() {
1086        // Success with flaky runs
1087        let flaky_runs = flaky_runs.into_iter().map(build_test_rerun).collect();
1088
1089        Ok(TestCaseStatus::Success { flaky_runs })
1090    } else {
1091        Err(DeserializeError::new(
1092            DeserializeErrorKind::InvalidStructure("invalid status elements".to_string()),
1093            path.to_vec(),
1094        ))
1095    }
1096}
1097
1098/// Builds a TestRerun from a rerun status element.
1099///
1100/// The type system ensures only flaky/rerun elements can be passed here.
1101fn build_test_rerun(element: &RerunStatusElement) -> TestRerun {
1102    let kind = match element.kind {
1103        RerunStatusKind::Failure => NonSuccessKind::Failure,
1104        RerunStatusKind::Error => NonSuccessKind::Error,
1105    };
1106
1107    TestRerun {
1108        kind,
1109        timestamp: element.data.timestamp,
1110        time: element.data.time,
1111        message: element.data.message.clone(),
1112        ty: element.data.ty.clone(),
1113        stack_trace: element.data.stack_trace.clone(),
1114        system_out: element.data.system_out.clone(),
1115        system_err: element.data.system_err.clone(),
1116        description: element.data.description.clone(),
1117    }
1118}
1119
1120/// Deserializes properties from XML.
1121fn deserialize_properties<R: BufRead>(
1122    reader: &mut Reader<R>,
1123    path: &[PathElement],
1124) -> Result<Vec<Property>, DeserializeError> {
1125    let mut properties = Vec::new();
1126    let mut buf = Vec::new();
1127    let mut prop_path = path.to_vec();
1128    prop_path.push(PathElement::Properties);
1129
1130    loop {
1131        match reader.read_event_into(&mut buf) {
1132            Ok(Event::Empty(e)) if e.name().as_ref() == b"property" => {
1133                let mut elem_path = prop_path.clone();
1134                elem_path.push(PathElement::Property(properties.len()));
1135                let property = deserialize_property(&e, &elem_path)?;
1136                properties.push(property);
1137            }
1138            Ok(Event::End(e)) if e.name().as_ref() == b"properties" => break,
1139            Ok(Event::Eof) => {
1140                return Err(DeserializeError::new(
1141                    DeserializeErrorKind::InvalidStructure(
1142                        "unexpected EOF in <properties>".to_string(),
1143                    ),
1144                    prop_path,
1145                ))
1146            }
1147            Ok(_) => {}
1148            Err(e) => {
1149                return Err(DeserializeError::new(
1150                    DeserializeErrorKind::XmlError(e),
1151                    prop_path,
1152                ))
1153            }
1154        }
1155        buf.clear();
1156    }
1157
1158    Ok(properties)
1159}
1160
1161/// Deserializes a single property.
1162fn deserialize_property(
1163    element: &BytesStart<'_>,
1164    path: &[PathElement],
1165) -> Result<Property, DeserializeError> {
1166    let mut name = None;
1167    let mut value = None;
1168
1169    for attr in element.attributes() {
1170        let attr = attr.map_err(|e| {
1171            DeserializeError::new(DeserializeErrorKind::AttrError(e), path.to_vec())
1172        })?;
1173        let mut attr_path = path.to_vec();
1174        match attr.key.as_ref() {
1175            b"name" => {
1176                attr_path.push(PathElement::Attribute("name".to_string()));
1177                name = Some(parse_xml_string(&attr.value, &attr_path)?);
1178            }
1179            b"value" => {
1180                attr_path.push(PathElement::Attribute("value".to_string()));
1181                value = Some(parse_xml_string(&attr.value, &attr_path)?);
1182            }
1183            _ => {} // Ignore unknown attributes
1184        }
1185    }
1186
1187    let name = name.ok_or_else(|| {
1188        let mut attr_path = path.to_vec();
1189        attr_path.push(PathElement::Attribute("name".to_string()));
1190        DeserializeError::new(
1191            DeserializeErrorKind::MissingAttribute("name".to_string()),
1192            attr_path,
1193        )
1194    })?;
1195    let value = value.ok_or_else(|| {
1196        let mut attr_path = path.to_vec();
1197        attr_path.push(PathElement::Attribute("value".to_string()));
1198        DeserializeError::new(
1199            DeserializeErrorKind::MissingAttribute("value".to_string()),
1200            attr_path,
1201        )
1202    })?;
1203
1204    Ok(Property { name, value })
1205}
1206
1207/// Reads text content from an element.
1208fn read_text_content<R: BufRead>(
1209    reader: &mut Reader<R>,
1210    element_name: &[u8],
1211    path: &[PathElement],
1212) -> Result<XmlString, DeserializeError> {
1213    let mut text = String::new();
1214    let mut buf = Vec::new();
1215
1216    loop {
1217        match reader.read_event_into(&mut buf) {
1218            Ok(Event::Text(e)) => {
1219                let s = std::str::from_utf8(e.as_ref()).map_err(|e| {
1220                    DeserializeError::new(DeserializeErrorKind::Utf8Error(e), path.to_vec())
1221                })?;
1222                let unescaped = unescape_with(s, resolve_xml_entity).map_err(|e| {
1223                    DeserializeError::new(DeserializeErrorKind::EscapeError(e), path.to_vec())
1224                })?;
1225                text.push_str(&unescaped);
1226            }
1227            Ok(Event::CData(e)) => {
1228                // CDATA sections are already unescaped, just convert to UTF-8.
1229                let s = std::str::from_utf8(e.as_ref()).map_err(|e| {
1230                    DeserializeError::new(DeserializeErrorKind::Utf8Error(e), path.to_vec())
1231                })?;
1232                text.push_str(s);
1233            }
1234            Ok(Event::GeneralRef(e)) => {
1235                let entity_name = std::str::from_utf8(e.as_ref()).map_err(|e| {
1236                    DeserializeError::new(DeserializeErrorKind::Utf8Error(e), path.to_vec())
1237                })?;
1238                let unescaped = resolve_xml_entity(entity_name).ok_or_else(|| {
1239                    DeserializeError::new(
1240                        DeserializeErrorKind::InvalidStructure(format!(
1241                            "unrecognized entity: {entity_name}",
1242                        )),
1243                        path.to_vec(),
1244                    )
1245                })?;
1246                text.push_str(unescaped);
1247            }
1248            Ok(Event::End(e)) if e.name().as_ref() == element_name => break,
1249            Ok(Event::Eof) => {
1250                return Err(DeserializeError::new(
1251                    DeserializeErrorKind::InvalidStructure(format!(
1252                        "unexpected EOF in <{}>",
1253                        String::from_utf8_lossy(element_name)
1254                    )),
1255                    path.to_vec(),
1256                ))
1257            }
1258            Ok(_) => {}
1259            Err(e) => {
1260                return Err(DeserializeError::new(
1261                    DeserializeErrorKind::XmlError(e),
1262                    path.to_vec(),
1263                ))
1264            }
1265        }
1266        buf.clear();
1267    }
1268
1269    // Trim leading and trailing whitespace from the text content.
1270    Ok(XmlString::new(text.trim()))
1271}
1272
1273// ---
1274// Helper functions
1275// ---
1276
1277fn parse_xml_string(bytes: &[u8], path: &[PathElement]) -> Result<XmlString, DeserializeError> {
1278    let s = std::str::from_utf8(bytes)
1279        .map_err(|e| DeserializeError::new(DeserializeErrorKind::Utf8Error(e), path.to_vec()))?;
1280    let unescaped = unescape_with(s, resolve_xml_entity)
1281        .map_err(|e| DeserializeError::new(DeserializeErrorKind::EscapeError(e), path.to_vec()))?;
1282    Ok(XmlString::new(unescaped.as_ref()))
1283}
1284
1285fn parse_usize(bytes: &[u8], path: &[PathElement]) -> Result<usize, DeserializeError> {
1286    let s = std::str::from_utf8(bytes)
1287        .map_err(|e| DeserializeError::new(DeserializeErrorKind::Utf8Error(e), path.to_vec()))?;
1288    s.parse()
1289        .map_err(|e| DeserializeError::new(DeserializeErrorKind::ParseIntError(e), path.to_vec()))
1290}
1291
1292fn parse_duration(bytes: &[u8], path: &[PathElement]) -> Result<Duration, DeserializeError> {
1293    let s = std::str::from_utf8(bytes)
1294        .map_err(|e| DeserializeError::new(DeserializeErrorKind::Utf8Error(e), path.to_vec()))?;
1295    let seconds: f64 = s.parse().map_err(|_| {
1296        DeserializeError::new(
1297            DeserializeErrorKind::ParseDurationError(s.to_string()),
1298            path.to_vec(),
1299        )
1300    })?;
1301
1302    Duration::try_from_secs_f64(seconds).map_err(|_| {
1303        DeserializeError::new(
1304            DeserializeErrorKind::ParseDurationError(s.to_string()),
1305            path.to_vec(),
1306        )
1307    })
1308}
1309
1310fn parse_timestamp(
1311    bytes: &[u8],
1312    path: &[PathElement],
1313) -> Result<DateTime<FixedOffset>, DeserializeError> {
1314    let s = std::str::from_utf8(bytes)
1315        .map_err(|e| DeserializeError::new(DeserializeErrorKind::Utf8Error(e), path.to_vec()))?;
1316    DateTime::parse_from_rfc3339(s).map_err(|_| {
1317        DeserializeError::new(
1318            DeserializeErrorKind::ParseTimestampError(s.to_string()),
1319            path.to_vec(),
1320        )
1321    })
1322}
1323
1324fn parse_uuid(bytes: &[u8], path: &[PathElement]) -> Result<ReportUuid, DeserializeError> {
1325    let s = std::str::from_utf8(bytes)
1326        .map_err(|e| DeserializeError::new(DeserializeErrorKind::Utf8Error(e), path.to_vec()))?;
1327    let uuid = s.parse().map_err(|e| {
1328        DeserializeError::new(DeserializeErrorKind::ParseUuidError(e), path.to_vec())
1329    })?;
1330    Ok(ReportUuid::from_untyped_uuid(uuid))
1331}
1332
1333#[cfg(test)]
1334mod tests {
1335    use super::*;
1336
1337    #[test]
1338    fn test_parse_simple_report() {
1339        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1340<testsuites name="my-test-run" tests="1" failures="0" errors="0">
1341    <testsuite name="my-test-suite" tests="1" disabled="0" errors="0" failures="0">
1342        <testcase name="success-case"/>
1343    </testsuite>
1344</testsuites>
1345"#;
1346
1347        let report = Report::deserialize_from_str(xml).unwrap();
1348        assert_eq!(report.name.as_str(), "my-test-run");
1349        assert_eq!(report.tests, 1);
1350        assert_eq!(report.failures, 0);
1351        assert_eq!(report.errors, 0);
1352        assert_eq!(report.test_suites.len(), 1);
1353
1354        let suite = &report.test_suites[0];
1355        assert_eq!(suite.name.as_str(), "my-test-suite");
1356        assert_eq!(suite.test_cases.len(), 1);
1357
1358        let case = &suite.test_cases[0];
1359        assert_eq!(case.name.as_str(), "success-case");
1360        assert!(matches!(case.status, TestCaseStatus::Success { .. }));
1361    }
1362
1363    #[test]
1364    fn test_parse_report_with_failure() {
1365        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1366<testsuites name="test-run" tests="1" failures="1" errors="0">
1367    <testsuite name="suite" tests="1" disabled="0" errors="0" failures="1">
1368        <testcase name="failing-test">
1369            <failure message="assertion failed">Expected true but got false</failure>
1370        </testcase>
1371    </testsuite>
1372</testsuites>
1373"#;
1374
1375        let report = Report::deserialize_from_str(xml).unwrap();
1376        let case = &report.test_suites[0].test_cases[0];
1377
1378        match &case.status {
1379            TestCaseStatus::NonSuccess {
1380                kind,
1381                message,
1382                description,
1383                ..
1384            } => {
1385                assert_eq!(*kind, NonSuccessKind::Failure);
1386                assert_eq!(message.as_ref().unwrap().as_str(), "assertion failed");
1387                assert_eq!(
1388                    description.as_ref().unwrap().as_str(),
1389                    "Expected true but got false"
1390                );
1391            }
1392            _ => panic!("Expected NonSuccess status"),
1393        }
1394    }
1395
1396    #[test]
1397    fn test_parse_report_with_properties() {
1398        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1399<testsuites name="test-run" tests="1" failures="0" errors="0">
1400    <testsuite name="suite" tests="1" disabled="0" errors="0" failures="0">
1401        <properties>
1402            <property name="env" value="test"/>
1403            <property name="platform" value="linux"/>
1404        </properties>
1405        <testcase name="test"/>
1406    </testsuite>
1407</testsuites>
1408"#;
1409
1410        let report = Report::deserialize_from_str(xml).unwrap();
1411        let suite = &report.test_suites[0];
1412
1413        assert_eq!(suite.properties.len(), 2);
1414        assert_eq!(suite.properties[0].name.as_str(), "env");
1415        assert_eq!(suite.properties[0].value.as_str(), "test");
1416        assert_eq!(suite.properties[1].name.as_str(), "platform");
1417        assert_eq!(suite.properties[1].value.as_str(), "linux");
1418    }
1419}