rvtest 0.3.0

A Next Level Testing Library for Rust — BDD specs, property-based testing, parametrized tests, rich reporting, and code coverage. Just a library, not a framework.
use std::fmt;
use std::path::{Path, PathBuf};
use std::process::Command;

use crate::core::{CoverageFormat, CoverageReport};

use super::parser::parse_raw_profile;
use super::types::CoverageTotals;

pub fn compute_coverage_from_profraw(path: &Path) -> Result<CoverageTotals, String> {
    let data = std::fs::read(path).map_err(|e| format!("read {:?}: {e}", path))?;
    let profile = parse_raw_profile(&data)?;

    if profile.functions.is_empty() {
        return Ok(CoverageTotals::new());
    }

    let total_counters = profile
        .functions
        .iter()
        .map(|f| f.num_counters as u64)
        .sum::<u64>();
    let covered_counters = profile
        .functions
        .iter()
        .map(|f| f.covered as u64)
        .sum::<u64>();

    let total_funcs = profile.functions.len() as u64;
    let covered_funcs = profile
        .functions
        .iter()
        .filter(|f| f.covered > 0)
        .count() as u64;

    Ok(CoverageTotals {
        total_counters,
        covered_counters,
        total_functions: total_funcs,
        covered_functions: covered_funcs,
    })
}

pub struct RawCoverageRunner {
    pub output_dir: PathBuf,
    pub extra_test_args: Vec<String>,
}

impl RawCoverageRunner {
    pub fn run(&self, format: CoverageFormat) -> Result<CoverageReport, String> {
        let out_dir = &self.output_dir;
        std::fs::create_dir_all(out_dir)
            .map_err(|e| format!("mkdir {:?}: {e}", out_dir))?;

        let profraw_pattern = out_dir.join("test_%p.profraw");

        let build = self.cargo_test_no_run()?;
        let binaries = parse_test_binaries(&build.stdout);

        if binaries.is_empty() {
            return Err("no test binaries produced".into());
        }

        for bin in &binaries {
            let status = Command::new(bin)
                .env(
                    "LLVM_PROFILE_FILE",
                    profraw_pattern.to_str().unwrap(),
                )
                .args(&self.extra_test_args)
                .status()
                .map_err(|e| format!("run {:?}: {e}", bin))?;
            if !status.success() {
                eprintln!("warning: {:?} exited non-zero", bin);
            }
        }

        let mut totals = CoverageTotals::new();

        let entries = std::fs::read_dir(out_dir)
            .map_err(|e| format!("read_dir {:?}: {e}", out_dir))?;
        for entry in entries {
            let entry = entry.map_err(|e| format!("entry: {e}"))?;
            let path = entry.path();
            if path.extension().map_or(true, |e| e != "profraw") {
                continue;
            }
            match compute_coverage_from_profraw(&path) {
                Ok(t) => totals.add(&t),
                Err(e) => {
                    eprintln!("warning: skipping {:?}: {e}", path);
                }
            }
            let _ = std::fs::remove_file(&path);
        }

        if totals.total_counters == 0 {
            return Err("no .profraw files generated or all were empty".into());
        }

        let line_cov = totals.line_pct();
        let func_cov = totals.func_pct();
        let region_cov = totals.region_pct();

        let report_path = match format {
            CoverageFormat::Summary => None,
            _ => {
                let path = out_dir.join(report_filename(format));
                let summary = format!(
                    "Lines:    {:.1}%\nFunctions:  {:.1}%\nRegions:   {:.1}%\n",
                    line_cov, func_cov, region_cov
                );
                std::fs::write(&path, &summary)
                    .map_err(|e| format!("write {:?}: {e}", path))?;
                Some(path)
            }
        };

        Ok(CoverageReport {
            line_coverage: line_cov,
            function_coverage: func_cov,
            region_coverage: region_cov,
            format,
            report_path,
        })
    }

    fn cargo_test_no_run(&self) -> Result<std::process::Output, String> {
        let mut cmd = Command::new("cargo");
        cmd.args(["test", "--no-run", "--message-format=json"])
            .env("CARGO_INCREMENTAL", "0")
            .env("RUSTFLAGS", "-Cinstrument-coverage")
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::inherit());

        if !self.extra_test_args.is_empty() {
            cmd.arg("--").args(&self.extra_test_args);
        }

        cmd.output()
            .map_err(|e| format!("cargo test --no-run: {e}"))
    }
}

pub fn write_report(format: CoverageFormat, line_cov: f64, func_cov: f64, region_cov: f64, path: &Path) -> Result<(), String> {
    let content = match format {
        CoverageFormat::Summary => String::new(),
        _ => format!(
            "Lines:    {:.1}%\nFunctions:  {:.1}%\nRegions:   {:.1}%\n",
            line_cov, func_cov, region_cov
        ),
    };
    if !content.is_empty() {
        std::fs::write(path, &content).map_err(|e| format!("write {:?}: {e}", path))?;
    }
    Ok(())
}

pub fn report_filename(format: CoverageFormat) -> String {
    match format {
        CoverageFormat::Summary => "summary.txt".into(),
        CoverageFormat::Html => "index.html".into(),
        CoverageFormat::Lcov => "lcov.info".into(),
        CoverageFormat::Json => "coverage.json".into(),
        CoverageFormat::Cobertura => "cobertura.xml".into(),
    }
}

pub fn parse_test_binaries(json_output: &[u8]) -> Vec<PathBuf> {
    use serde::Deserialize;

    #[derive(Deserialize)]
    struct CargoArtifact {
        reason: String,
        filenames: Vec<String>,
        #[serde(default)]
        target_kind: Vec<String>,
        #[serde(default)]
        profile: Option<ArtifactProfile>,
    }

    #[derive(Deserialize)]
    struct ArtifactProfile {
        #[serde(rename = "test")]
        is_test: bool,
    }

    let text = String::from_utf8_lossy(json_output);
    let mut binaries = Vec::new();
    for line in text.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        if let Ok(artifact) = serde_json::from_str::<CargoArtifact>(line) {
            if artifact.reason != "compiler-artifact" {
                continue;
            }
            let is_test_bin = artifact
                .profile
                .as_ref()
                .map(|p| p.is_test)
                .unwrap_or(false)
                || artifact.target_kind.iter().any(|k| k == "bin" || k == "test");
            if !is_test_bin {
                continue;
            }
            let only_doc_test = artifact.target_kind.iter().all(|k| k == "test")
                && artifact.filenames.iter().any(|f| {
                    let stem = Path::new(f).file_stem().and_then(|s| s.to_str()).unwrap_or("");
                    !stem.contains("integration") && !stem.contains("cargo_rvtest") && !stem.contains("rvtest-")
                });
            if only_doc_test {
                continue;
            }

            for filename in &artifact.filenames {
                let path = PathBuf::from(filename);
                if path.is_file() {
                    binaries.push(path);
                }
            }
        }
    }
    binaries
}

impl fmt::Display for CoverageFormat {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let s = match self {
            CoverageFormat::Summary => "summary",
            CoverageFormat::Html => "html",
            CoverageFormat::Lcov => "lcov",
            CoverageFormat::Json => "json",
            CoverageFormat::Cobertura => "cobertura",
        };
        write!(f, "{s}")
    }
}