use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Deserialize;
#[derive(Deserialize)]
struct Report {
data: Vec<ReportData>,
}
#[derive(Deserialize)]
struct ReportData {
files: Vec<FileCoverage>,
}
#[derive(Deserialize)]
struct FileCoverage {
filename: String,
summary: FileSummary,
}
#[derive(Deserialize)]
struct FileSummary {
lines: LinesSummary,
}
#[derive(Deserialize)]
struct LinesSummary {
count: u32,
covered: u32,
}
struct ModuleStats {
total: u32,
covered: u32,
}
impl ModuleStats {
fn percent(&self) -> f64 {
if self.total == 0 {
return 100.0;
}
f64::from(self.covered) / f64::from(self.total) * 100.0
}
}
enum ModuleStatus {
New,
Same,
Increased { from: u64 },
Decreased { from: u64 },
}
fn module_dir(filename: &str, src_dir: &Path) -> Option<PathBuf> {
let rel = Path::new(filename).strip_prefix(src_dir).ok()?;
let segment = rel.components().next()?.as_os_str().to_str()?;
if Path::new(segment)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("rs"))
{
Some(src_dir.to_path_buf())
} else {
Some(src_dir.join(segment))
}
}
fn module_name(mod_path: &Path, src_dir: &Path) -> String {
let raw = mod_path
.strip_prefix(src_dir)
.ok()
.map_or_else(String::new, |p| p.to_string_lossy().into_owned());
if raw.is_empty() {
String::from("(root)")
} else {
raw
}
}
fn floor_percent_int(covered: u32, total: u32) -> u64 {
if total == 0 {
return 100;
}
u64::from(covered)
.saturating_mul(100)
.checked_div(u64::from(total))
.unwrap_or(100)
}
fn read_summary(path: &Path) -> Result<BTreeMap<String, u64>> {
if !path.exists() {
return Ok(BTreeMap::new());
}
let text =
fs::read_to_string(path).with_context(|| format!("reading summary {}", path.display()))?;
serde_json::from_str(&text).context("parsing summary JSON")
}
fn write_summary(summary: &BTreeMap<String, u64>, path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(summary).context("serializing summary")?;
fs::write(path, json).with_context(|| format!("writing summary to {}", path.display()))
}
fn aggregate_modules(report: Report, src_dir: &Path) -> BTreeMap<PathBuf, ModuleStats> {
let mut modules: BTreeMap<PathBuf, ModuleStats> = BTreeMap::new();
for file in report.data.into_iter().flat_map(|d| d.files) {
let Some(dir) = module_dir(&file.filename, src_dir) else {
continue;
};
let entry = modules.entry(dir).or_insert(ModuleStats {
total: 0,
covered: 0,
});
entry.total = entry.total.saturating_add(file.summary.lines.count);
entry.covered = entry.covered.saturating_add(file.summary.lines.covered);
}
modules
}
fn compare_modules(
modules: BTreeMap<PathBuf, ModuleStats>,
baseline: &BTreeMap<String, u64>,
src_dir: &Path,
) -> BTreeMap<String, (ModuleStats, u64, ModuleStatus)> {
modules
.into_iter()
.map(|(mod_path, stats)| {
let name = module_name(&mod_path, src_dir);
let current = floor_percent_int(stats.covered, stats.total);
let status = match baseline.get(&name).copied() {
Option::None => ModuleStatus::New,
Some(b) if current < b => ModuleStatus::Decreased { from: b },
Some(b) if current > b => ModuleStatus::Increased { from: b },
Some(_) => ModuleStatus::Same,
};
(name, (stats, current, status))
})
.collect()
}
fn update_baseline(
results: &BTreeMap<String, (ModuleStats, u64, ModuleStatus)>,
baseline: &mut BTreeMap<String, u64>,
path: &Path,
) -> Result<()> {
for (name, (_, current, status)) in results {
match status {
ModuleStatus::New | ModuleStatus::Same | ModuleStatus::Increased { .. } => {
baseline.insert(name.clone(), *current);
}
ModuleStatus::Decreased { .. } => {}
}
}
write_summary(baseline, path)
}
fn print_table(
results: &BTreeMap<String, (ModuleStats, u64, ModuleStatus)>,
) -> Result<(Vec<String>, Vec<String>, Vec<String>)> {
let stdout = std::io::stdout();
let mut out = stdout.lock();
let mut new_modules: Vec<String> = Vec::new();
let mut decreased: Vec<String> = Vec::new();
let mut increased: Vec<String> = Vec::new();
writeln!(
out,
"{:<25} {:>10} {:>10} Status",
"Module", "Coverage", "Baseline"
)?;
writeln!(out, "{}", "-".repeat(60))?;
for (name, (stats, current, status)) in results {
let (base_str, status_str): (String, &str) = match status {
ModuleStatus::New => {
new_modules.push(name.clone());
(String::from("—"), "new — commit")
}
ModuleStatus::Same => (format!("{current}"), "ok"),
ModuleStatus::Increased { from } => {
increased.push(name.clone());
(format!("{from}"), "↑ commit")
}
ModuleStatus::Decreased { from } => {
decreased.push(name.clone());
(format!("{from}"), "FAIL ↓")
}
};
writeln!(
out,
"{:<25} {:>9.1}% {:>10} {}",
name,
stats.percent(),
base_str,
status_str,
)?;
}
writeln!(out, "{}", "-".repeat(60))?;
Ok((new_modules, decreased, increased))
}
fn report_errors(new_modules: &[String], decreased: &[String], increased: &[String]) -> Result<()> {
let mut errors: Vec<String> = Vec::new();
if !new_modules.is_empty() {
errors.push(format!(
"New modules detected: {}. Commit the new summary to your change",
new_modules.join(", ")
));
}
if !decreased.is_empty() {
errors.push(format!(
"Module coverage was lowered on modules: {}. Please add tests",
decreased.join(", ")
));
}
if !increased.is_empty() {
errors.push(format!(
"Module coverage increased on modules: {}. Commit the new summary to your change",
increased.join(", ")
));
}
if errors.is_empty() {
return Ok(());
}
let stderr = std::io::stderr();
let mut err = stderr.lock();
for error in &errors {
writeln!(err, "{error}")?;
}
drop(err);
anyhow::bail!("coverage check failed")
}
pub fn run(bin_name: &str, src_dir: &Path, summary_path: &Path) -> Result<()> {
let tmp = tempfile::NamedTempFile::new().context("creating temp file for coverage report")?;
let tmp_path = tmp.path().to_path_buf();
let tmp_str = tmp_path.to_str().context("non-UTF-8 temp path")?;
let status = std::process::Command::new("cargo")
.args([
"llvm-cov",
"--bin",
bin_name,
"--all-features",
"--json",
"--output-path",
tmp_str,
])
.status()
.context("spawning cargo llvm-cov")?;
anyhow::ensure!(status.success(), "cargo llvm-cov exited with failure");
let content =
fs::read_to_string(&tmp_path).context("reading coverage report from temp file")?;
let report: Report = serde_json::from_str(&content).context("parsing coverage report")?;
let src_dir = src_dir
.canonicalize()
.with_context(|| format!("resolving src dir: {}", src_dir.display()))?;
let modules = aggregate_modules(report, &src_dir);
let mut baseline = read_summary(summary_path)?;
let results = compare_modules(modules, &baseline, &src_dir);
update_baseline(&results, &mut baseline, summary_path)?;
let (new_modules, decreased, increased) = print_table(&results)?;
report_errors(&new_modules, &decreased, &increased)
}