git_perf/parsers/
junit_xml.rs

1use crate::parsers::types::{ParsedMeasurement, Parser, TestMeasurement, TestStatus};
2use anyhow::Result;
3use quick_xml::de::from_str;
4use serde::Deserialize;
5use std::collections::HashMap;
6use std::time::Duration;
7
8/// Parser for JUnit XML format
9pub struct JunitXmlParser;
10
11impl Parser for JunitXmlParser {
12    fn parse(&self, input: &str) -> Result<Vec<ParsedMeasurement>> {
13        // Check the root element name to determine which structure to parse
14        let trimmed = input.trim();
15        let is_testsuites = trimmed.contains("<testsuites");
16
17        if is_testsuites {
18            // Parse as multiple test suites
19            if let Ok(testsuites) = from_str::<TestSuites>(input) {
20                return Ok(testsuites.into_measurements());
21            }
22        } else {
23            // Parse as single test suite
24            if let Ok(testsuite) = from_str::<TestSuite>(input) {
25                return Ok(testsuite.into_measurements());
26            }
27        }
28
29        anyhow::bail!("Failed to parse JUnit XML: input is neither <testsuites> nor <testsuite>")
30    }
31}
32
33/// Root element for multiple test suites
34#[derive(Debug, Deserialize)]
35struct TestSuites {
36    #[serde(rename = "$value", default)]
37    testsuite: Vec<TestSuite>,
38}
39
40impl TestSuites {
41    fn into_measurements(self) -> Vec<ParsedMeasurement> {
42        self.testsuite
43            .into_iter()
44            .flat_map(|suite| suite.into_measurements())
45            .collect()
46    }
47}
48
49/// A test suite containing multiple test cases
50#[derive(Debug, Deserialize)]
51struct TestSuite {
52    #[serde(rename = "@name", default)]
53    name: String,
54    #[serde(rename = "$value", default)]
55    testcase: Vec<TestCase>,
56}
57
58impl TestSuite {
59    fn into_measurements(self) -> Vec<ParsedMeasurement> {
60        self.testcase
61            .into_iter()
62            .map(|tc| tc.into_measurement(&self.name))
63            .collect()
64    }
65}
66
67/// A single test case
68#[derive(Debug, Deserialize)]
69struct TestCase {
70    #[serde(rename = "@name")]
71    name: String,
72    #[serde(rename = "@classname", default)]
73    classname: String,
74    #[serde(rename = "@time")]
75    time: Option<f64>,
76    failure: Option<Failure>,
77    error: Option<Error>,
78    skipped: Option<Skipped>,
79}
80
81#[derive(Debug, Deserialize)]
82#[allow(dead_code)]
83struct Failure {
84    #[serde(default)]
85    message: String,
86    #[serde(rename = "type", default)]
87    failure_type: String,
88}
89
90#[derive(Debug, Deserialize)]
91#[allow(dead_code)]
92struct Error {
93    #[serde(default)]
94    message: String,
95    #[serde(rename = "type", default)]
96    error_type: String,
97}
98
99#[derive(Debug, Deserialize)]
100struct Skipped {}
101
102impl TestCase {
103    fn into_measurement(self, suite_name: &str) -> ParsedMeasurement {
104        let status = if self.skipped.is_some() {
105            TestStatus::Skipped
106        } else if self.error.is_some() {
107            TestStatus::Error
108        } else if self.failure.is_some() {
109            TestStatus::Failed
110        } else {
111            TestStatus::Passed
112        };
113
114        let duration = self.time.map(Duration::from_secs_f64);
115
116        let mut metadata = HashMap::new();
117        metadata.insert("type".to_string(), "test".to_string());
118        if !self.classname.is_empty() {
119            metadata.insert("classname".to_string(), self.classname);
120        }
121        if !suite_name.is_empty() {
122            metadata.insert("suite".to_string(), suite_name.to_string());
123        }
124        metadata.insert("status".to_string(), status.as_str().to_string());
125
126        ParsedMeasurement::Test(TestMeasurement {
127            name: self.name,
128            duration,
129            status,
130            metadata,
131        })
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn test_parse_single_testsuite() {
141        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
142<testsuite name="my_tests" tests="2" failures="0" errors="0" skipped="0" time="3.5">
143  <testcase name="test_one" classname="module::tests" time="1.5"/>
144  <testcase name="test_two" classname="module::tests" time="2.0"/>
145</testsuite>"#;
146
147        let parser = JunitXmlParser;
148        let result = parser.parse(xml).unwrap();
149
150        assert_eq!(result.len(), 2);
151
152        if let ParsedMeasurement::Test(test) = &result[0] {
153            assert_eq!(test.name, "test_one");
154            assert_eq!(test.duration, Some(Duration::from_secs_f64(1.5)));
155            assert_eq!(test.status, TestStatus::Passed);
156            assert_eq!(test.metadata.get("classname").unwrap(), "module::tests");
157            assert_eq!(test.metadata.get("status").unwrap(), "passed");
158        } else {
159            panic!("Expected Test measurement");
160        }
161    }
162
163    #[test]
164    fn test_parse_testsuites() {
165        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
166<testsuites tests="3" failures="1" errors="0" skipped="1" time="5.2">
167  <testsuite name="suite_one" tests="2" failures="0" time="3.5">
168    <testcase name="test_one" classname="module::tests" time="1.5"/>
169    <testcase name="test_two" classname="module::tests" time="2.0"/>
170  </testsuite>
171  <testsuite name="suite_two" tests="1" failures="1" time="1.7">
172    <testcase name="test_three" classname="other::tests" time="1.7">
173      <failure message="assertion failed" type="AssertionError"/>
174    </testcase>
175  </testsuite>
176</testsuites>"#;
177
178        let parser = JunitXmlParser;
179        let result = parser.parse(xml).unwrap();
180
181        assert_eq!(result.len(), 3);
182
183        if let ParsedMeasurement::Test(test) = &result[2] {
184            assert_eq!(test.name, "test_three");
185            assert_eq!(test.status, TestStatus::Failed);
186            assert_eq!(test.metadata.get("suite").unwrap(), "suite_two");
187        } else {
188            panic!("Expected Test measurement");
189        }
190    }
191
192    #[test]
193    fn test_parse_skipped_test() {
194        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
195<testsuite name="my_tests" tests="1" skipped="1">
196  <testcase name="test_skip" classname="module::tests" time="0.0">
197    <skipped/>
198  </testcase>
199</testsuite>"#;
200
201        let parser = JunitXmlParser;
202        let result = parser.parse(xml).unwrap();
203
204        if let ParsedMeasurement::Test(test) = &result[0] {
205            assert_eq!(test.status, TestStatus::Skipped);
206        } else {
207            panic!("Expected Test measurement");
208        }
209    }
210
211    #[test]
212    fn test_parse_error_test() {
213        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
214<testsuite name="my_tests" tests="1" errors="1">
215  <testcase name="test_error" classname="module::tests" time="0.5">
216    <error message="runtime error" type="RuntimeError"/>
217  </testcase>
218</testsuite>"#;
219
220        let parser = JunitXmlParser;
221        let result = parser.parse(xml).unwrap();
222
223        if let ParsedMeasurement::Test(test) = &result[0] {
224            assert_eq!(test.status, TestStatus::Error);
225        } else {
226            panic!("Expected Test measurement");
227        }
228    }
229
230    #[test]
231    fn test_parse_missing_time() {
232        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
233<testsuite name="my_tests" tests="1">
234  <testcase name="test_no_time" classname="module::tests"/>
235</testsuite>"#;
236
237        let parser = JunitXmlParser;
238        let result = parser.parse(xml).unwrap();
239
240        if let ParsedMeasurement::Test(test) = &result[0] {
241            assert_eq!(test.duration, None);
242        } else {
243            panic!("Expected Test measurement");
244        }
245    }
246
247    #[test]
248    fn test_parse_invalid_xml() {
249        let xml = "not valid xml";
250        let parser = JunitXmlParser;
251        assert!(parser.parse(xml).is_err());
252    }
253}