patchcov 0.4.1

Patch coverage for git diffs: attribute lcov, llvm-cov, Cobertura, Go and JaCoCo reports to a diff and gate on it.
Documentation
//! Cobertura XML coverage parser (line coverage only).
//!
//! Cobertura records coverage under `<class filename="…">` elements, each
//! containing `<lines><line number="N" hits="H"/></lines>`. A single source
//! file may be split across several `<class>` elements (one per class/closure);
//! [`CoverageReport::insert`][super::model::CoverageReport::insert] merges them.
//! The default parser ignores branch data; [`parse_with_branches`] retains it.

use anyhow::{Context, Result};
use quick_xml::events::{BytesStart, Event};
use quick_xml::reader::Reader;

use super::model::{CoverageReport, FileCoverage};

/// Parses cobertura XML text into a [`CoverageReport`].
pub fn parse(content: &str) -> Result<CoverageReport> {
    parse_impl(content, false)
}

/// Parses line hits and aggregate condition-coverage counts for branch-aware diffs.
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);
    // Be lenient about unclosed/mismatched tags: we only read `class`/`line`
    // attributes, and some coverage tools emit slightly non-well-formed XML.
    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);
                }
            }
            _ => {}
        }
    }

    // Tolerate a final class with no explicit close.
    if let Some(file) = current.take() {
        report.insert(file);
    }

    Ok(report)
}

/// Handles a `<class>` (opens a file) or `<line>` (records a hit) start tag.
fn handle_start(
    e: &BytesStart,
    current: &mut Option<FileCoverage>,
    report: &mut CoverageReport,
    branch_coverage: bool,
) -> Result<()> {
    match e.name().as_ref() {
        b"class" => {
            // A new <class> closes any previous one missing an explicit </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(())
}

/// Reads the exact counts in `50% (1/2)` rather than its rounded percentage.
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))
}

/// Reads attribute `key` off `e`, unescaping its value.
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() {
        // No </class>: the trailing-class fallback must still record it.
        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());
        }
    }
}