diff-coverage 0.6.1

Diff-coverage, supercharged in Rust. Fast, memory-efficient coverage on changed lines for CI.
use std::io::{BufReader, Error, ErrorKind, Read, Result};
use std::path::Path;

use quick_xml::events::Event;
use quick_xml::Reader;

use super::{CoverageParser, CoverageSink};
use crate::util::path::normalize_path;

pub struct CloverParser;

impl CoverageParser for CloverParser {
    fn can_parse<R: Read>(&self, reader: R) -> Result<bool> {
        let mut limited = reader.take(8192);
        let mut buf = String::new();
        limited.read_to_string(&mut buf).map_err(|err| {
            Error::new(
                ErrorKind::InvalidData,
                format!("Failed to read Clover XML: {err}"),
            )
        })?;
        let haystack = buf.as_str();

        let has_file = haystack.contains("<file");
        let has_count = haystack.contains("count=");
        let has_num = haystack.contains("num=");

        Ok(has_file && (has_count || has_num))
    }

    fn parse<R: Read>(&self, reader: R, sink: &mut dyn CoverageSink) -> Result<()> {
        let mut xml = Reader::from_reader(BufReader::new(reader));
        xml.config_mut().trim_text(true);
        let mut buf = Vec::new();
        let mut current_file: Option<String> = None;

        loop {
            match xml.read_event_into(&mut buf) {
                Ok(Event::Start(event)) => match event.name().as_ref() {
                    b"file" => {
                        if let Some(path) =
                            read_attribute_any(&event, &[b"name", b"path", b"filename"])?
                        {
                            let normalized = normalize_coverage_path(&path);
                            current_file = Some(normalized.clone());
                            sink.on_file(&normalized);
                        }
                    }
                    b"line" => {
                        if let Some(file_path) = current_file.as_deref() {
                            if let Some((number, hits)) = read_line_attributes(&event)? {
                                sink.on_line(file_path, number, hits);
                            }
                        }
                    }
                    _ => {}
                },
                Ok(Event::Empty(event)) => match event.name().as_ref() {
                    b"file" => {
                        if let Some(path) =
                            read_attribute_any(&event, &[b"name", b"path", b"filename"])?
                        {
                            let normalized = normalize_coverage_path(&path);
                            sink.on_file(&normalized);
                        }
                    }
                    b"line" => {
                        if let Some(file_path) = current_file.as_deref() {
                            if let Some((number, hits)) = read_line_attributes(&event)? {
                                sink.on_line(file_path, number, hits);
                            }
                        }
                    }
                    _ => {}
                },
                Ok(Event::End(event)) => {
                    if event.name().as_ref() == b"file" {
                        current_file = None;
                    }
                }
                Ok(Event::Eof) => break,
                Err(err) => {
                    return Err(Error::new(
                        ErrorKind::InvalidData,
                        format!("Failed to parse Clover XML: {err}"),
                    ))
                }
                _ => {}
            }
            buf.clear();
        }

        Ok(())
    }
}

fn read_attribute_any(
    event: &quick_xml::events::BytesStart<'_>,
    keys: &[&[u8]],
) -> Result<Option<String>> {
    for attr in event.attributes() {
        let attr = attr.map_err(|err| Error::new(ErrorKind::InvalidData, err))?;
        if keys.iter().any(|key| attr.key.as_ref() == *key) {
            let value = attr
                .unescape_value()
                .map_err(|err| Error::new(ErrorKind::InvalidData, err))?;
            return Ok(Some(value.into_owned()));
        }
    }
    Ok(None)
}

fn read_line_attributes(event: &quick_xml::events::BytesStart<'_>) -> Result<Option<(u32, u32)>> {
    let mut number: Option<u32> = None;
    let mut hits: Option<u32> = None;
    let mut line_type: Option<String> = None;

    for attr in event.attributes() {
        let attr = attr.map_err(|err| Error::new(ErrorKind::InvalidData, err))?;
        match attr.key.as_ref() {
            b"num" | b"number" => {
                let value = attr
                    .unescape_value()
                    .map_err(|err| Error::new(ErrorKind::InvalidData, err))?;
                number = parse_u32_lossy(&value);
            }
            b"count" | b"hits" => {
                let value = attr
                    .unescape_value()
                    .map_err(|err| Error::new(ErrorKind::InvalidData, err))?;
                hits = parse_u32_lossy(&value);
            }
            b"type" => {
                let value = attr
                    .unescape_value()
                    .map_err(|err| Error::new(ErrorKind::InvalidData, err))?;
                line_type = Some(value.into_owned());
            }
            _ => {}
        }
    }

    if let Some(line_type) = line_type.as_deref() {
        if line_type == "method" {
            return Ok(None);
        }
    }

    match (number, hits) {
        (Some(number), Some(hits)) => Ok(Some((number, hits))),
        _ => Ok(None),
    }
}

fn normalize_coverage_path(path: &str) -> String {
    let path_obj = Path::new(path);
    if path_obj.is_absolute() {
        if let Ok(cwd) = std::env::current_dir() {
            if let Ok(stripped) = path_obj.strip_prefix(&cwd) {
                return normalize_path(&stripped.to_string_lossy());
            }
        }
    }
    normalize_path(path)
}

fn parse_u32_lossy(value: &str) -> Option<u32> {
    value.parse().ok().or_else(|| {
        value
            .parse::<f64>()
            .ok()
            .map(|parsed| parsed.max(0.0) as u32)
    })
}

#[cfg(test)]
mod tests {
    use super::CloverParser;
    use crate::coverage::store::CoverageStore;
    use crate::coverage::CoverageParser;
    use std::io::Cursor;

    #[test]
    fn parses_statement_counts() {
        let file = Cursor::new(include_str!(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/tests/fixtures/coverage_clover.xml"
        )));
        let mut store = CoverageStore::default();
        CloverParser.parse(file, &mut store).expect("parse clover");

        let coverage = store
            .file_coverage("src/Calculator.php")
            .expect("lookup")
            .expect("file coverage");
        assert!(coverage.is_covered(11));
        assert!(!coverage.is_covered(16));
    }

    #[test]
    fn matches_changed_lines_from_diff() {
        let diff_reader = std::io::BufReader::new(Cursor::new(include_str!(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/tests/fixtures/git_diff.diff"
        ))));
        let changed = crate::diff::git::parse_unified_diff(diff_reader).expect("parse diff");

        let cov_file = Cursor::new(include_str!(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/tests/fixtures/coverage_clover.xml"
        )));
        let mut store = CoverageStore::default();
        CloverParser
            .parse(cov_file, &mut store)
            .expect("parse clover");

        let report = crate::coverage::analyze_changed_coverage(&changed, &store, true, &[]);
        let report = report.expect("report");
        let calc = report
            .uncovered_files
            .iter()
            .find(|file| file.path.ends_with("src/Calculator.php"))
            .expect("calculator coverage");
        assert!(!calc.uncovered_lines.contains(&11));
    }

    #[test]
    fn ignores_method_lines() {
        let file = Cursor::new(include_str!(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/tests/fixtures/coverage_clover.xml"
        )));
        let mut store = CoverageStore::default();
        CloverParser.parse(file, &mut store).expect("parse clover");

        let coverage = store
            .file_coverage("src/Calculator.php")
            .expect("lookup")
            .expect("file coverage");
        assert!(!coverage.is_covered(9));
        assert!(coverage.is_covered(11));
    }

    #[test]
    fn can_parse_clover_fixture() {
        let file = Cursor::new(include_str!(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/tests/fixtures/coverage_clover.xml"
        )));
        assert!(CloverParser.can_parse(file).expect("detect clover"));
    }

    #[test]
    fn does_not_parse_cobertura_fixture() {
        let file = Cursor::new(include_str!(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/tests/fixtures/coverage_cobertura.xml"
        )));
        assert!(!CloverParser.can_parse(file).expect("detect cobertura"));
    }
}