Skip to main content

omni_dev/coverage/
cobertura.rs

1//! Cobertura XML coverage parser (line coverage only).
2//!
3//! Cobertura records coverage under `<class filename="…">` elements, each
4//! containing `<lines><line number="N" hits="H"/></lines>`. A single source
5//! file may be split across several `<class>` elements (one per class/closure);
6//! [`CoverageReport::insert`][super::model::CoverageReport::insert] merges them.
7//! Branch (`condition-coverage`) data is ignored — v1 is line coverage only.
8
9use anyhow::{Context, Result};
10use quick_xml::events::{BytesStart, Event};
11use quick_xml::reader::Reader;
12
13use super::model::{CoverageReport, FileCoverage};
14
15/// Parses cobertura XML text into a [`CoverageReport`].
16pub fn parse(content: &str) -> Result<CoverageReport> {
17    let mut reader = Reader::from_str(content);
18    // Be lenient about unclosed/mismatched tags: we only read `class`/`line`
19    // attributes, and some coverage tools emit slightly non-well-formed XML.
20    reader.config_mut().check_end_names = false;
21    let mut report = CoverageReport::new();
22    let mut current: Option<FileCoverage> = None;
23
24    loop {
25        match reader.read_event().context("malformed cobertura XML")? {
26            Event::Eof => break,
27            Event::Start(e) | Event::Empty(e) => {
28                handle_start(&e, &mut current, &mut report)?;
29            }
30            Event::End(e) if e.name().as_ref() == b"class" => {
31                if let Some(file) = current.take() {
32                    report.insert(file);
33                }
34            }
35            _ => {}
36        }
37    }
38
39    // Tolerate a final class with no explicit close.
40    if let Some(file) = current.take() {
41        report.insert(file);
42    }
43
44    Ok(report)
45}
46
47/// Handles a `<class>` (opens a file) or `<line>` (records a hit) start tag.
48fn handle_start(
49    e: &BytesStart,
50    current: &mut Option<FileCoverage>,
51    report: &mut CoverageReport,
52) -> Result<()> {
53    match e.name().as_ref() {
54        b"class" => {
55            // A new <class> closes any previous one missing an explicit </class>.
56            if let Some(file) = current.take() {
57                report.insert(file);
58            }
59            if let Some(filename) = attr(e, b"filename")? {
60                *current = Some(FileCoverage::new(filename));
61            }
62        }
63        b"line" => {
64            if let Some(file) = current.as_mut() {
65                let number = attr(e, b"number")?
66                    .and_then(|s| s.parse::<u32>().ok())
67                    .context("cobertura <line> missing/invalid number attribute")?;
68                let hits = attr(e, b"hits")?
69                    .and_then(|s| s.parse::<u64>().ok())
70                    .unwrap_or(0);
71                file.record(number, hits);
72            }
73        }
74        _ => {}
75    }
76    Ok(())
77}
78
79/// Reads attribute `key` off `e`, unescaping its value.
80fn attr(e: &BytesStart, key: &[u8]) -> Result<Option<String>> {
81    for attr in e.attributes() {
82        let attr = attr.context("malformed cobertura attribute")?;
83        if attr.key.as_ref() == key {
84            let value = attr
85                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
86                .context("invalid cobertura attribute value")?;
87            return Ok(Some(value.into_owned()));
88        }
89    }
90    Ok(None)
91}
92
93#[cfg(test)]
94#[allow(clippy::unwrap_used, clippy::expect_used)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn parses_classes_and_lines() {
100        let xml = r#"<?xml version="1.0" ?>
101<coverage>
102  <packages>
103    <package name="omni-dev">
104      <classes>
105        <class name="a" filename="src/a.rs">
106          <lines>
107            <line number="1" hits="5"/>
108            <line number="2" hits="0"/>
109          </lines>
110        </class>
111        <class name="b" filename="src/b.rs">
112          <lines>
113            <line number="1" hits="1"/>
114          </lines>
115        </class>
116      </classes>
117    </package>
118  </packages>
119</coverage>"#;
120        let report = parse(xml).unwrap();
121        assert_eq!(report.files.len(), 2);
122        assert_eq!(report.hits("src/a.rs", 1), Some(5));
123        assert_eq!(report.hits("src/a.rs", 2), Some(0));
124        assert_eq!(report.hits("src/b.rs", 1), Some(1));
125    }
126
127    #[test]
128    fn merges_split_file_classes() {
129        let xml = r#"<coverage><packages><package><classes>
130            <class filename="src/a.rs"><lines><line number="1" hits="0"/></lines></class>
131            <class filename="src/a.rs"><lines><line number="2" hits="3"/></lines></class>
132        </classes></package></packages></coverage>"#;
133        let report = parse(xml).unwrap();
134        assert_eq!(report.files.len(), 1);
135        assert_eq!(report.hits("src/a.rs", 1), Some(0));
136        assert_eq!(report.hits("src/a.rs", 2), Some(3));
137    }
138
139    #[test]
140    fn ignores_branch_conditions() {
141        let xml = r#"<coverage><packages><package><classes>
142            <class filename="src/a.rs"><lines>
143              <line number="1" hits="2" branch="true" condition-coverage="50% (1/2)">
144                <conditions><condition number="0" type="jump" coverage="50%"/></conditions>
145              </line>
146            </lines></class>
147        </classes></package></packages></coverage>"#;
148        let report = parse(xml).unwrap();
149        let f = &report.files["src/a.rs"];
150        assert_eq!(f.lines.len(), 1);
151        assert_eq!(f.lines.get(&1), Some(&2));
152    }
153
154    #[test]
155    fn tolerates_unclosed_class() {
156        // No </class>: the trailing-class fallback must still record it.
157        let xml = r#"<coverage><class filename="a.rs"><lines><line number="1" hits="1"/></lines>"#;
158        let report = parse(xml).unwrap();
159        assert_eq!(report.hits("a.rs", 1), Some(1));
160    }
161
162    #[test]
163    fn new_class_closes_previous_unclosed_class() {
164        let xml = r#"<coverage>
165            <class filename="a.rs"><lines><line number="1" hits="1"/></lines>
166            <class filename="b.rs"><lines><line number="2" hits="0"/></lines></class>
167        </coverage>"#;
168        let report = parse(xml).unwrap();
169        assert_eq!(report.hits("a.rs", 1), Some(1));
170        assert_eq!(report.hits("b.rs", 2), Some(0));
171    }
172
173    #[test]
174    fn lines_outside_a_class_are_ignored() {
175        let xml = r#"<coverage><lines><line number="1" hits="1"/></lines></coverage>"#;
176        let report = parse(xml).unwrap();
177        assert!(report.files.is_empty());
178    }
179
180    #[test]
181    fn malformed_xml_errors() {
182        assert!(parse("<coverage><class").is_err());
183    }
184}