Skip to main content

libverify_core/
junit.rs

1//! JUnit XML parser for converting CI test reports into `HarnessResult` evidence.
2//!
3//! Parses the most common JUnit XML format: `<testsuites>` containing `<testsuite>` elements.
4//! Each `<testsuite>` becomes one `HarnessResult`.
5//!
6//! Uses simple string scanning to avoid adding an XML parser dependency.
7
8use crate::evidence::HarnessResult;
9
10/// Parse JUnit XML content into `HarnessResult` entries.
11///
12/// Each `<testsuite>` becomes one `HarnessResult`. Supports both wrapped
13/// (`<testsuites><testsuite .../>...</testsuites>`) and unwrapped
14/// (`<testsuite .../>`) formats.
15pub fn parse_junit_xml(xml: &str) -> Result<Vec<HarnessResult>, String> {
16    let mut results = Vec::new();
17
18    // Find all <testsuite ...> tags (self-closing or opening)
19    let mut search_pos = 0;
20    while let Some(start) = xml[search_pos..].find("<testsuite ") {
21        let abs_start = search_pos + start;
22        // Find the end of the opening tag (either /> or >)
23        let tag_end = xml[abs_start..]
24            .find('>')
25            .ok_or_else(|| "Malformed XML: unclosed <testsuite> tag".to_string())?;
26        let tag = &xml[abs_start..abs_start + tag_end + 1];
27
28        let name = extract_attr(tag, "name").unwrap_or_default();
29        let tests: u32 = extract_attr(tag, "tests")
30            .and_then(|s| s.parse().ok())
31            .unwrap_or(0);
32        let failures: u32 = extract_attr(tag, "failures")
33            .and_then(|s| s.parse().ok())
34            .unwrap_or(0);
35        let errors: u32 = extract_attr(tag, "errors")
36            .and_then(|s| s.parse().ok())
37            .unwrap_or(0);
38        let skipped: u32 = extract_attr(tag, "skipped")
39            .and_then(|s| s.parse().ok())
40            .unwrap_or(0);
41        let time: Option<f64> = extract_attr(tag, "time").and_then(|s| s.parse().ok());
42
43        let failed_count = failures + errors;
44        let passed_count = tests.saturating_sub(failed_count + skipped);
45        let passed = failed_count == 0;
46
47        results.push(HarnessResult {
48            name: if name.is_empty() {
49                format!("testsuite-{}", results.len())
50            } else {
51                name
52            },
53            passed,
54            total: tests,
55            passed_count,
56            failed_count,
57            skipped_count: skipped,
58            duration_secs: time,
59            source_format: Some("junit-xml".to_string()),
60        });
61
62        search_pos = abs_start + tag_end + 1;
63    }
64
65    if results.is_empty() {
66        return Err("No <testsuite> elements found in XML".to_string());
67    }
68
69    Ok(results)
70}
71
72/// Extract an attribute value from an XML tag string.
73/// Given `<testsuite name="foo" tests="10">`, `extract_attr(tag, "name")` returns `Some("foo")`.
74fn extract_attr(tag: &str, attr: &str) -> Option<String> {
75    let pattern = format!("{attr}=\"");
76    let start = tag.find(&pattern)?;
77    let value_start = start + pattern.len();
78    let value_end = tag[value_start..].find('"')?;
79    Some(tag[value_start..value_start + value_end].to_string())
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn parse_single_testsuite() {
88        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
89<testsuites>
90  <testsuite name="unit-tests" tests="42" failures="0" errors="0" skipped="2" time="1.234">
91  </testsuite>
92</testsuites>"#;
93        let results = parse_junit_xml(xml).unwrap();
94        assert_eq!(results.len(), 1);
95        assert_eq!(results[0].name, "unit-tests");
96        assert!(results[0].passed);
97        assert_eq!(results[0].total, 42);
98        assert_eq!(results[0].passed_count, 40);
99        assert_eq!(results[0].failed_count, 0);
100        assert_eq!(results[0].skipped_count, 2);
101        assert!((results[0].duration_secs.unwrap() - 1.234).abs() < 0.001);
102        assert_eq!(results[0].source_format.as_deref(), Some("junit-xml"));
103    }
104
105    #[test]
106    fn parse_multiple_testsuites() {
107        let xml = r#"<testsuites>
108  <testsuite name="unit" tests="10" failures="0" errors="0" time="0.5">
109  </testsuite>
110  <testsuite name="integration" tests="5" failures="2" errors="1" time="3.0">
111  </testsuite>
112</testsuites>"#;
113        let results = parse_junit_xml(xml).unwrap();
114        assert_eq!(results.len(), 2);
115        assert!(results[0].passed);
116        assert!(!results[1].passed);
117        assert_eq!(results[1].failed_count, 3); // 2 failures + 1 error
118        assert_eq!(results[1].passed_count, 2); // 5 - 3 = 2
119    }
120
121    #[test]
122    fn parse_with_failures() {
123        let xml = r#"<testsuite name="lint" tests="100" failures="3" errors="0" skipped="0">"#;
124        let results = parse_junit_xml(xml).unwrap();
125        assert_eq!(results.len(), 1);
126        assert!(!results[0].passed);
127        assert_eq!(results[0].failed_count, 3);
128        assert_eq!(results[0].passed_count, 97);
129    }
130
131    #[test]
132    fn parse_unwrapped_testsuite() {
133        let xml = r#"<testsuite name="mytest" tests="5" failures="0" errors="0" />"#;
134        let results = parse_junit_xml(xml).unwrap();
135        assert_eq!(results.len(), 1);
136        assert!(results[0].passed);
137    }
138
139    #[test]
140    fn error_on_empty_xml() {
141        let result = parse_junit_xml("<testsuites></testsuites>");
142        assert!(result.is_err());
143        assert!(result.unwrap_err().contains("No <testsuite> elements"));
144    }
145
146    #[test]
147    fn error_on_no_testsuite() {
148        let result = parse_junit_xml("not xml at all");
149        assert!(result.is_err());
150    }
151
152    #[test]
153    fn unnamed_testsuite_gets_default_name() {
154        let xml = r#"<testsuite tests="1" failures="0" errors="0" />"#;
155        let results = parse_junit_xml(xml).unwrap();
156        assert_eq!(results[0].name, "testsuite-0");
157    }
158
159    #[test]
160    fn missing_optional_attrs_default_to_zero() {
161        let xml = r#"<testsuite name="minimal" tests="5" failures="0" errors="0" />"#;
162        let results = parse_junit_xml(xml).unwrap();
163        assert_eq!(results[0].skipped_count, 0);
164        assert!(results[0].duration_secs.is_none());
165    }
166}