use anyhow::{Context, Result};
use quick_xml::events::{BytesStart, Event};
use quick_xml::reader::Reader;
use super::model::{CoverageReport, FileCoverage};
pub fn parse(content: &str) -> Result<CoverageReport> {
parse_impl(content, false)
}
pub fn parse_with_branches(content: &str) -> Result<CoverageReport> {
parse_impl(content, true)
}
fn parse_impl(content: &str, branch_coverage: bool) -> Result<CoverageReport> {
let mut reader = Reader::from_str(content);
reader.config_mut().check_end_names = false;
let mut report = CoverageReport::new();
let mut current: Option<FileCoverage> = None;
loop {
match reader.read_event().context("malformed cobertura XML")? {
Event::Eof => break,
Event::Start(e) | Event::Empty(e) => {
handle_start(&e, &mut current, &mut report, branch_coverage)?;
}
Event::End(e) if e.name().as_ref() == b"class" => {
if let Some(file) = current.take() {
report.insert(file);
}
}
_ => {}
}
}
if let Some(file) = current.take() {
report.insert(file);
}
Ok(report)
}
fn handle_start(
e: &BytesStart,
current: &mut Option<FileCoverage>,
report: &mut CoverageReport,
branch_coverage: bool,
) -> Result<()> {
match e.name().as_ref() {
b"class" => {
if let Some(file) = current.take() {
report.insert(file);
}
if let Some(filename) = attr(e, b"filename")? {
*current = Some(FileCoverage::new(filename));
}
}
b"line" => {
if let Some(file) = current.as_mut() {
let number = attr(e, b"number")?
.and_then(|s| s.parse::<u32>().ok())
.context("cobertura <line> missing/invalid number attribute")?;
let hits = attr(e, b"hits")?
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
file.record(number, hits);
if branch_coverage {
if let Some(value) = attr(e, b"condition-coverage")? {
let (covered, total) = condition_counts(&value)?;
file.missed_branches
.entry(number)
.and_modify(|v| *v = (*v).max(total - covered))
.or_insert(total - covered);
}
}
}
}
_ => {}
}
Ok(())
}
fn condition_counts(value: &str) -> Result<(u64, u64)> {
let (_, counts) = value
.split_once('(')
.context("invalid Cobertura condition-coverage counts")?;
let counts = counts
.trim()
.strip_suffix(')')
.context("invalid Cobertura condition-coverage counts")?;
let (covered, total) = counts
.split_once('/')
.context("invalid Cobertura condition-coverage counts")?;
let covered: u64 = covered
.trim()
.parse()
.context("invalid covered branch count")?;
let total: u64 = total.trim().parse().context("invalid total branch count")?;
anyhow::ensure!(covered <= total, "covered branch count exceeds total");
Ok((covered, total))
}
fn attr(e: &BytesStart, key: &[u8]) -> Result<Option<String>> {
for attr in e.attributes() {
let attr = attr.context("malformed cobertura attribute")?;
if attr.key.as_ref() == key {
let value = attr
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.context("invalid cobertura attribute value")?;
return Ok(Some(value.into_owned()));
}
}
Ok(None)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn parses_classes_and_lines() {
let xml = r#"<?xml version="1.0" ?>
<coverage>
<packages>
<package name="patchcov">
<classes>
<class name="a" filename="src/a.rs">
<lines>
<line number="1" hits="5"/>
<line number="2" hits="0"/>
</lines>
</class>
<class name="b" filename="src/b.rs">
<lines>
<line number="1" hits="1"/>
</lines>
</class>
</classes>
</package>
</packages>
</coverage>"#;
let report = parse(xml).unwrap();
assert_eq!(report.files.len(), 2);
assert_eq!(report.hits("src/a.rs", 1), Some(5));
assert_eq!(report.hits("src/a.rs", 2), Some(0));
assert_eq!(report.hits("src/b.rs", 1), Some(1));
}
#[test]
fn merges_split_file_classes() {
let xml = r#"<coverage><packages><package><classes>
<class filename="src/a.rs"><lines><line number="1" hits="0"/></lines></class>
<class filename="src/a.rs"><lines><line number="2" hits="3"/></lines></class>
</classes></package></packages></coverage>"#;
let report = parse(xml).unwrap();
assert_eq!(report.files.len(), 1);
assert_eq!(report.hits("src/a.rs", 1), Some(0));
assert_eq!(report.hits("src/a.rs", 2), Some(3));
}
#[test]
fn ignores_branch_conditions() {
let xml = r#"<coverage><packages><package><classes>
<class filename="src/a.rs"><lines>
<line number="1" hits="2" branch="true" condition-coverage="50% (1/2)">
<conditions><condition number="0" type="jump" coverage="50%"/></conditions>
</line>
</lines></class>
</classes></package></packages></coverage>"#;
let report = parse(xml).unwrap();
let f = &report.files["src/a.rs"];
assert_eq!(f.lines.len(), 1);
assert_eq!(f.lines.get(&1), Some(&2));
}
#[test]
fn tolerates_unclosed_class() {
let xml = r#"<coverage><class filename="a.rs"><lines><line number="1" hits="1"/></lines>"#;
let report = parse(xml).unwrap();
assert_eq!(report.hits("a.rs", 1), Some(1));
}
#[test]
fn new_class_closes_previous_unclosed_class() {
let xml = r#"<coverage>
<class filename="a.rs"><lines><line number="1" hits="1"/></lines>
<class filename="b.rs"><lines><line number="2" hits="0"/></lines></class>
</coverage>"#;
let report = parse(xml).unwrap();
assert_eq!(report.hits("a.rs", 1), Some(1));
assert_eq!(report.hits("b.rs", 2), Some(0));
}
#[test]
fn lines_outside_a_class_are_ignored() {
let xml = r#"<coverage><lines><line number="1" hits="1"/></lines></coverage>"#;
let report = parse(xml).unwrap();
assert!(report.files.is_empty());
}
#[test]
fn malformed_xml_errors() {
assert!(parse("<coverage><class").is_err());
}
#[test]
fn branch_counts_score_partial_full_and_zero_lines() {
let xml = r#"<coverage><class filename="a.rs"><lines>
<line number="1" hits="2" condition-coverage="50% (1/2)"/>
<line number="2" hits="2" condition-coverage="100% (2/2)"/>
<line number="3" hits="2" condition-coverage="0% (0/2)"/>
<line number="4" hits="0" condition-coverage="100% (2/2)"/>
<line number="5" hits="1"/>
<line number="6" hits="1" condition-coverage="100% (0/0)"/>
<line number="1" hits="3" condition-coverage="100% (2/2)"/>
</lines></class></coverage>"#;
let mut report = parse_with_branches(xml).unwrap();
report.apply_branch_coverage();
for line in [1, 3, 4] {
assert_eq!(report.hits("a.rs", line), Some(0));
}
for line in [2, 5, 6] {
assert!(report.hits("a.rs", line).unwrap() > 0);
}
assert_eq!(parse(xml).unwrap().covered_lines(), 5);
}
#[test]
fn malformed_condition_counts_fail_only_in_branch_mode() {
for value in [
"50%",
"50% (1/x)",
"50% (3/2)",
"50% (1/2",
"50% (1/2) junk",
] {
let xml = format!(
r#"<coverage><class filename="a.rs"><line number="1" hits="1" condition-coverage="{value}"/></class></coverage>"#
);
assert!(parse_with_branches(&xml).is_err(), "{value}");
assert!(parse(&xml).is_ok());
}
}
}