#![cfg_attr(docsrs, feature(doc_cfg))]
#![warn(missing_docs)]
#![warn(rust_2018_idioms)]
use std::io;
use std::path::PathBuf;
use std::process::Command;
use dev_report::{CheckResult, Evidence, Severity};
use serde::{Deserialize, Serialize};
pub mod baseline;
pub use baseline::{Baseline, BaselineStore, JsonFileBaselineStore};
mod producer;
pub use producer::CoverageProducer;
#[derive(Debug, Clone)]
pub struct CoverageRun {
name: String,
version: String,
workdir: Option<PathBuf>,
workspace: bool,
excludes: Vec<String>,
features: Vec<String>,
all_features: bool,
no_default_features: bool,
per_file: bool,
}
impl CoverageRun {
pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
Self {
name: name.into(),
version: version.into(),
workdir: None,
workspace: false,
excludes: Vec::new(),
features: Vec::new(),
all_features: false,
no_default_features: false,
per_file: false,
}
}
pub fn in_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.workdir = Some(dir.into());
self
}
pub fn subject(&self) -> &str {
&self.name
}
pub fn subject_version(&self) -> &str {
&self.version
}
pub fn workspace(mut self) -> Self {
self.workspace = true;
self
}
pub fn exclude(mut self, pattern: impl Into<String>) -> Self {
self.excludes.push(pattern.into());
self
}
pub fn feature(mut self, name: impl Into<String>) -> Self {
self.features.push(name.into());
self
}
pub fn all_features(mut self) -> Self {
self.all_features = true;
self
}
pub fn no_default_features(mut self) -> Self {
self.no_default_features = true;
self
}
pub fn per_file(mut self) -> Self {
self.per_file = true;
self
}
pub fn execute(&self) -> Result<CoverageResult, CoverageError> {
detect_tool()?;
let stdout = self.run_llvm_cov()?;
parse_llvm_cov_json(&stdout, self.name.clone(), self.version.clone())
}
fn run_llvm_cov(&self) -> Result<String, CoverageError> {
let mut cmd = Command::new("cargo");
cmd.arg("llvm-cov");
if !self.per_file {
cmd.arg("--summary-only");
}
cmd.arg("--json");
if self.workspace {
cmd.arg("--workspace");
}
for pat in &self.excludes {
cmd.args(["--exclude", pat]);
}
if self.all_features {
cmd.arg("--all-features");
}
if self.no_default_features {
cmd.arg("--no-default-features");
}
for feat in &self.features {
cmd.args(["--features", feat]);
}
if let Some(dir) = self.workdir.as_ref() {
cmd.current_dir(dir);
}
let output = cmd
.output()
.map_err(|e| CoverageError::SubprocessFailed(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
return Err(CoverageError::SubprocessFailed(stderr));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
}
fn detect_tool() -> Result<(), CoverageError> {
let probe = Command::new("cargo")
.args(["llvm-cov", "--version"])
.output();
match probe {
Ok(out) if out.status.success() => Ok(()),
Ok(_) => Err(CoverageError::ToolNotInstalled),
Err(_) => Err(CoverageError::ToolNotInstalled),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoverageResult {
pub name: String,
pub version: String,
pub line_pct: f64,
pub function_pct: f64,
pub region_pct: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch_pct: Option<f64>,
pub total_lines: u64,
pub covered_lines: u64,
pub total_functions: u64,
pub covered_functions: u64,
pub total_regions: u64,
pub covered_regions: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files: Vec<FileCoverage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileCoverage {
pub filename: String,
pub line_pct: f64,
pub function_pct: f64,
pub region_pct: f64,
pub total_lines: u64,
pub covered_lines: u64,
}
impl CoverageResult {
pub fn into_check_result(self, threshold: CoverageThreshold) -> CheckResult {
let (actual, target, label) = threshold.applied_to(&self);
let name = format!("coverage::{}", self.name);
let detail = format!("{label} coverage {actual:.2}% (threshold {target:.2}%)");
let mut check = if actual < target {
CheckResult::fail(name, Severity::Warning).with_detail(detail)
} else {
CheckResult::pass(name).with_detail(detail)
};
check = check
.with_tag("coverage")
.with_evidence(Evidence::numeric(format!("{label}_pct"), actual))
.with_evidence(Evidence::numeric(format!("{label}_pct_threshold"), target))
.with_evidence(Evidence::numeric_int(
"total_lines",
self.total_lines as i64,
))
.with_evidence(Evidence::numeric_int(
"covered_lines",
self.covered_lines as i64,
));
check
}
pub fn diff(&self, baseline: &Baseline, tolerance_pct: f64) -> CoverageDiff {
let line = self.line_pct - baseline.line_pct;
let func = self.function_pct - baseline.function_pct;
let region = self.region_pct - baseline.region_pct;
let worst = line.min(func).min(region);
CoverageDiff {
line_pct_delta: line,
function_pct_delta: func,
region_pct_delta: region,
regressed: worst < -tolerance_pct,
}
}
pub fn to_baseline(&self) -> Baseline {
Baseline {
name: self.name.clone(),
line_pct: self.line_pct,
function_pct: self.function_pct,
region_pct: self.region_pct,
}
}
pub fn least_covered_files(&self, n: usize) -> Vec<&FileCoverage> {
let mut refs: Vec<&FileCoverage> = self.files.iter().collect();
refs.sort_by(|a, b| {
a.line_pct
.partial_cmp(&b.line_pct)
.unwrap_or(std::cmp::Ordering::Equal)
});
refs.into_iter().take(n).collect()
}
}
#[derive(Debug, Clone, Copy)]
pub enum CoverageThreshold {
MinLinePct(f64),
MinFunctionPct(f64),
MinRegionPct(f64),
}
impl CoverageThreshold {
pub fn min_line_pct(pct: f64) -> Self {
Self::MinLinePct(pct)
}
pub fn min_function_pct(pct: f64) -> Self {
Self::MinFunctionPct(pct)
}
pub fn min_region_pct(pct: f64) -> Self {
Self::MinRegionPct(pct)
}
fn applied_to(self, r: &CoverageResult) -> (f64, f64, &'static str) {
match self {
Self::MinLinePct(p) => (r.line_pct, p, "line"),
Self::MinFunctionPct(p) => (r.function_pct, p, "function"),
Self::MinRegionPct(p) => (r.region_pct, p, "region"),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct CoverageDiff {
pub line_pct_delta: f64,
pub function_pct_delta: f64,
pub region_pct_delta: f64,
pub regressed: bool,
}
#[derive(Debug)]
pub enum CoverageError {
ToolNotInstalled,
SubprocessFailed(String),
ParseError(String),
Io(io::Error),
}
impl std::fmt::Display for CoverageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ToolNotInstalled => {
write!(
f,
"cargo-llvm-cov is not installed; run `cargo install cargo-llvm-cov`"
)
}
Self::SubprocessFailed(s) => write!(f, "cargo llvm-cov failed: {s}"),
Self::ParseError(s) => write!(f, "could not parse cargo llvm-cov output: {s}"),
Self::Io(e) => write!(f, "io error: {e}"),
}
}
}
impl std::error::Error for CoverageError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(e) => Some(e),
_ => None,
}
}
}
impl From<io::Error> for CoverageError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
#[derive(Deserialize)]
struct LlvmCovExport {
#[serde(default)]
data: Vec<LlvmCovData>,
}
#[derive(Deserialize)]
struct LlvmCovData {
#[serde(default)]
files: Vec<LlvmCovFile>,
totals: LlvmCovTotals,
}
#[derive(Deserialize)]
struct LlvmCovFile {
filename: String,
summary: LlvmCovTotals,
}
#[derive(Deserialize)]
struct LlvmCovTotals {
lines: LlvmCovMetric,
functions: LlvmCovMetric,
regions: LlvmCovMetric,
#[serde(default)]
branches: Option<LlvmCovMetric>,
}
#[derive(Deserialize, Default, Clone, Copy)]
struct LlvmCovMetric {
#[serde(default)]
count: u64,
#[serde(default)]
covered: u64,
#[serde(default)]
percent: f64,
}
fn parse_llvm_cov_json(
json: &str,
name: String,
version: String,
) -> Result<CoverageResult, CoverageError> {
let export: LlvmCovExport =
serde_json::from_str(json).map_err(|e| CoverageError::ParseError(e.to_string()))?;
let data = export
.data
.into_iter()
.next()
.ok_or_else(|| CoverageError::ParseError("export.data was empty".into()))?;
let totals = data.totals;
let files = data
.files
.into_iter()
.map(|f| FileCoverage {
filename: f.filename,
line_pct: f.summary.lines.percent,
function_pct: f.summary.functions.percent,
region_pct: f.summary.regions.percent,
total_lines: f.summary.lines.count,
covered_lines: f.summary.lines.covered,
})
.collect();
Ok(CoverageResult {
name,
version,
line_pct: totals.lines.percent,
function_pct: totals.functions.percent,
region_pct: totals.regions.percent,
branch_pct: totals.branches.map(|b| b.percent),
total_lines: totals.lines.count,
covered_lines: totals.lines.covered,
total_functions: totals.functions.count,
covered_functions: totals.functions.covered,
total_regions: totals.regions.count,
covered_regions: totals.regions.covered,
files,
})
}
#[cfg(test)]
mod tests {
use super::*;
use dev_report::Verdict;
fn sample_result(line: f64, func: f64, region: f64) -> CoverageResult {
CoverageResult {
name: "x".into(),
version: "0.1.0".into(),
line_pct: line,
function_pct: func,
region_pct: region,
branch_pct: None,
total_lines: 100,
covered_lines: (line as u64),
total_functions: 20,
covered_functions: 16,
total_regions: 50,
covered_regions: 40,
files: Vec::new(),
}
}
#[test]
fn threshold_pass_when_above() {
let c = sample_result(90.0, 85.0, 80.0)
.into_check_result(CoverageThreshold::min_line_pct(80.0));
assert_eq!(c.verdict, Verdict::Pass);
assert!(c.has_tag("coverage"));
assert!(c.evidence.iter().any(|e| e.label == "line_pct"));
}
#[test]
fn threshold_fail_when_below() {
let c = sample_result(50.0, 60.0, 40.0)
.into_check_result(CoverageThreshold::min_line_pct(80.0));
assert_eq!(c.verdict, Verdict::Fail);
assert_eq!(c.severity, Some(Severity::Warning));
}
#[test]
fn threshold_function_and_region_paths() {
let r = sample_result(90.0, 50.0, 90.0);
let c = r
.clone()
.into_check_result(CoverageThreshold::min_function_pct(80.0));
assert_eq!(c.verdict, Verdict::Fail);
let c2 = sample_result(90.0, 85.0, 50.0)
.into_check_result(CoverageThreshold::min_region_pct(80.0));
assert_eq!(c2.verdict, Verdict::Fail);
}
#[test]
fn diff_signs_deltas_correctly() {
let r = sample_result(75.0, 80.0, 70.0);
let b = Baseline {
name: "x".into(),
line_pct: 80.0,
function_pct: 85.0,
region_pct: 75.0,
};
let d = r.diff(&b, 0.0);
assert!(d.line_pct_delta < 0.0);
assert!(d.function_pct_delta < 0.0);
assert!(d.region_pct_delta < 0.0);
assert!(d.regressed);
}
#[test]
fn diff_tolerance_accepts_small_drops() {
let r = sample_result(79.5, 84.5, 74.5);
let b = Baseline {
name: "x".into(),
line_pct: 80.0,
function_pct: 85.0,
region_pct: 75.0,
};
let d = r.diff(&b, 1.0);
assert!(!d.regressed);
}
#[test]
fn diff_improvement_is_not_regression() {
let r = sample_result(95.0, 95.0, 95.0);
let b = Baseline {
name: "x".into(),
line_pct: 80.0,
function_pct: 85.0,
region_pct: 75.0,
};
let d = r.diff(&b, 0.0);
assert!(d.line_pct_delta > 0.0);
assert!(!d.regressed);
}
#[test]
fn least_covered_files_returns_sorted_subset() {
let mut r = sample_result(80.0, 80.0, 80.0);
r.files = vec![
FileCoverage {
filename: "a.rs".into(),
line_pct: 90.0,
function_pct: 90.0,
region_pct: 90.0,
total_lines: 10,
covered_lines: 9,
},
FileCoverage {
filename: "b.rs".into(),
line_pct: 50.0,
function_pct: 50.0,
region_pct: 50.0,
total_lines: 10,
covered_lines: 5,
},
FileCoverage {
filename: "c.rs".into(),
line_pct: 70.0,
function_pct: 70.0,
region_pct: 70.0,
total_lines: 10,
covered_lines: 7,
},
];
let least = r.least_covered_files(2);
assert_eq!(least.len(), 2);
assert_eq!(least[0].filename, "b.rs");
assert_eq!(least[1].filename, "c.rs");
}
#[test]
fn parse_llvm_cov_summary_only() {
let json = r#"{
"type": "llvm.coverage.json.export",
"version": "2.0.1",
"data": [{
"files": [],
"totals": {
"lines": { "count": 200, "covered": 170, "percent": 85.0 },
"functions": { "count": 50, "covered": 45, "percent": 90.0 },
"regions": { "count": 100, "covered": 80, "percent": 80.0 },
"branches": { "count": 30, "covered": 24, "percent": 80.0 }
}
}]
}"#;
let r = parse_llvm_cov_json(json, "x".into(), "0.1.0".into()).unwrap();
assert_eq!(r.line_pct, 85.0);
assert_eq!(r.function_pct, 90.0);
assert_eq!(r.region_pct, 80.0);
assert_eq!(r.branch_pct, Some(80.0));
assert_eq!(r.total_lines, 200);
assert_eq!(r.covered_lines, 170);
assert!(r.files.is_empty());
}
#[test]
fn parse_llvm_cov_with_files() {
let json = r#"{
"type": "llvm.coverage.json.export",
"version": "2.0.1",
"data": [{
"files": [
{
"filename": "/abs/path/src/lib.rs",
"summary": {
"lines": { "count": 100, "covered": 90, "percent": 90.0 },
"functions": { "count": 20, "covered": 18, "percent": 90.0 },
"regions": { "count": 50, "covered": 42, "percent": 84.0 }
}
}
],
"totals": {
"lines": { "count": 100, "covered": 90, "percent": 90.0 },
"functions": { "count": 20, "covered": 18, "percent": 90.0 },
"regions": { "count": 50, "covered": 42, "percent": 84.0 }
}
}]
}"#;
let r = parse_llvm_cov_json(json, "x".into(), "0.1.0".into()).unwrap();
assert_eq!(r.files.len(), 1);
assert_eq!(r.files[0].filename, "/abs/path/src/lib.rs");
assert_eq!(r.files[0].line_pct, 90.0);
assert!(r.branch_pct.is_none());
}
#[test]
fn parse_llvm_cov_rejects_empty_data() {
let json = r#"{ "type": "llvm.coverage.json.export", "version": "2", "data": [] }"#;
let r = parse_llvm_cov_json(json, "x".into(), "0.1.0".into());
assert!(matches!(r, Err(CoverageError::ParseError(_))));
}
#[test]
fn parse_llvm_cov_rejects_garbage() {
let r = parse_llvm_cov_json("not json", "x".into(), "0.1.0".into());
assert!(matches!(r, Err(CoverageError::ParseError(_))));
}
#[test]
fn coverage_result_round_trips_through_json() {
let r = sample_result(85.0, 88.0, 80.0);
let s = serde_json::to_string(&r).unwrap();
let back: CoverageResult = serde_json::from_str(&s).unwrap();
assert_eq!(back.name, r.name);
assert_eq!(back.line_pct, r.line_pct);
}
#[test]
fn to_baseline_strips_per_file_detail() {
let mut r = sample_result(85.0, 88.0, 80.0);
r.files.push(FileCoverage {
filename: "a.rs".into(),
line_pct: 50.0,
function_pct: 50.0,
region_pct: 50.0,
total_lines: 10,
covered_lines: 5,
});
let b = r.to_baseline();
assert_eq!(b.name, "x");
assert_eq!(b.line_pct, 85.0);
let s = serde_json::to_string(&b).unwrap();
assert!(!s.contains("a.rs"));
}
}