use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use git2::Repository;
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
use regex::RegexSet;
use crate::analysis::{analyze_with_markers, ExcludedFiles, Markers};
use crate::config::{load_config_content, resolve_config_dir_at};
use crate::format::resolve as resolve_format;
use crate::markers::{self, FileMarkers};
use crate::merge::check_shard;
use crate::paths::{self, PathMapping};
use crate::{
default_base_ref, go_coverprofile, parse, render, CoverageReport, DiffModel, DiffScope,
FileCoverage, Format, OutputFormat, RenderOptions,
};
const COVERAGE_CONFIG_FILE: &str = "config.yaml";
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "kebab-case")]
pub enum ReportFormat {
Auto,
Lcov,
LlvmCovJson,
Cobertura,
Jacoco,
GoCoverprofile,
}
impl ReportFormat {
fn into_format(self) -> Option<Format> {
match self {
Self::Auto => None,
Self::Lcov => Some(Format::Lcov),
Self::LlvmCovJson => Some(Format::LlvmCovJson),
Self::Cobertura => Some(Format::Cobertura),
Self::Jacoco => Some(Format::Jacoco),
Self::GoCoverprofile => Some(Format::GoCoverprofile),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "lowercase")]
pub enum OutputFormatArg {
Markdown,
Yaml,
Json,
}
impl From<OutputFormatArg> for OutputFormat {
fn from(arg: OutputFormatArg) -> Self {
match arg {
OutputFormatArg::Markdown => Self::Markdown,
OutputFormatArg::Yaml => Self::Yaml,
OutputFormatArg::Json => Self::Json,
}
}
}
#[derive(Parser)]
pub struct DiffCommand {
#[arg(long, value_name = "PATH", required = true)]
pub report: Vec<PathBuf>,
#[arg(long, value_enum, default_value_t = ReportFormat::Auto)]
pub report_format: ReportFormat,
#[arg(long, value_name = "REV")]
pub base_ref: Option<String>,
#[arg(long, value_name = "REV")]
pub head_ref: Option<String>,
#[arg(long, value_name = "PATH")]
pub baseline_report: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = ReportFormat::Auto)]
pub baseline_report_format: ReportFormat,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormatArg::Markdown)]
pub output: OutputFormatArg,
#[arg(long)]
pub no_explanation: bool,
#[arg(long = "format", hide = true)]
pub format: Option<OutputFormatArg>,
#[arg(long, value_name = "PCT")]
pub fail_under_patch: Option<f64>,
#[arg(long, value_name = "GLOB")]
pub fail_on_unmeasured: Vec<String>,
#[arg(long, value_name = "PCT")]
pub fail_under_lines: Option<f64>,
#[arg(long, value_name = "PATH")]
pub strip_prefix: Option<PathBuf>,
#[arg(long, value_name = "REGEX", value_delimiter = ',')]
pub ignore_filename_regex: Vec<String>,
#[arg(long, value_name = "PATH")]
pub config_dir: Option<PathBuf>,
#[arg(long)]
pub collapse_ranges: bool,
#[arg(long)]
pub all_files: bool,
#[arg(long, value_name = "URL")]
pub artifact_url: Option<String>,
#[arg(long, value_name = "URL")]
pub run_url: Option<String>,
#[arg(long, value_name = "SHA")]
pub base_sha: Option<String>,
#[arg(long, value_name = "SHA")]
pub head_sha: Option<String>,
#[arg(long, value_name = "URL")]
pub commit_url: Option<String>,
}
#[derive(Debug, Clone, Copy)]
enum Revision<'a> {
Head(Option<&'a str>),
Base(&'a str),
}
enum RevisionSource<'repo> {
Workdir(PathBuf),
Tree(&'repo Repository, git2::Tree<'repo>),
}
impl<'repo> RevisionSource<'repo> {
fn open(repo: &'repo Repository, revision: Revision<'_>) -> Result<Self> {
let rev = match revision {
Revision::Head(None) => match repo.workdir() {
Some(workdir) => return Ok(Self::Workdir(workdir.to_path_buf())),
None => "HEAD",
},
Revision::Head(Some(rev)) | Revision::Base(rev) => rev,
};
let tree = repo
.revparse_single(rev)
.with_context(|| format!("could not resolve ref `{rev}` to scan for coverage markers"))?
.peel_to_tree()
.with_context(|| format!("ref `{rev}` is not a tree-ish"))?;
Ok(Self::Tree(repo, tree))
}
fn read(&self, path: &str) -> Result<Option<String>> {
match self {
Self::Workdir(workdir) => match std::fs::read(workdir.join(path)) {
Ok(bytes) => Ok(String::from_utf8(bytes).ok()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(anyhow::Error::new(e).context(format!(
"could not read {} to scan for coverage markers",
workdir.join(path).display()
))),
},
Self::Tree(repo, tree) => {
let Ok(entry) = tree.get_path(Path::new(path)) else {
return Ok(None);
};
let object = entry.to_object(repo).with_context(|| {
format!("could not read `{path}` from the tree to scan for coverage markers")
})?;
let Some(blob) = object.as_blob() else {
return Ok(None);
};
Ok(String::from_utf8(blob.content().to_vec()).ok())
}
}
}
}
fn apply_ignored(report: &mut CoverageReport, markers: &BTreeMap<String, FileMarkers>) {
if markers.values().all(|m| m.ignored.is_empty()) {
return;
}
report.retain_lines(|path, line| markers.get(path).is_none_or(|m| !m.ignored.contains(&line)));
}
pub(super) fn anchor(path: &Path, repo_root: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
repo_root.join(path)
}
}
pub(super) fn read_report(
path: &Path,
format: ReportFormat,
repo_root: &Path,
) -> Result<CoverageReport> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("could not read coverage report {}", path.display()))?;
let resolved = resolve_format(&content, format.into_format())
.with_context(|| format!("could not parse coverage report {}", path.display()))?;
let mut report = parse(&content, Some(resolved))
.with_context(|| format!("could not parse coverage report {}", path.display()))?;
if resolved == Format::GoCoverprofile {
if let Some(module) = std::fs::read_to_string(repo_root.join("go.mod"))
.ok()
.and_then(|go_mod| go_coverprofile::module_path(&go_mod))
{
report.strip_prefix(Path::new(&module));
}
}
Ok(report)
}
fn normalise_report(
report: &mut CoverageReport,
strip_prefix: Option<&Path>,
ignore: Option<&RegexSet>,
) -> Vec<FileCoverage> {
if let Some(prefix) = strip_prefix {
report.strip_prefix(prefix);
}
match ignore {
Some(ignore) => report.retain_paths(|path| !ignore.is_match(path)),
None => Vec::new(),
}
}
#[derive(Debug, Default, serde::Deserialize)]
pub(super) struct CoverageConfig {
#[serde(default)]
diff: CoverageDiffConfig,
#[serde(default, rename = "lint-markers")]
pub(super) lint_markers: CoverageLintMarkersConfig,
}
#[derive(Debug, Default, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(super) struct CoverageLintMarkersConfig {
#[serde(default)]
pub(super) include: Vec<String>,
}
#[derive(Debug, Default, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
struct CoverageDiffConfig {
#[serde(default)]
path_mappings: Vec<PathMapping>,
#[serde(default)]
require_measured: Vec<String>,
#[serde(default)]
ignore_filename_regex: Vec<String>,
}
pub(super) fn load_coverage_config(config_dir: &Path) -> Result<CoverageConfig> {
let Some(content) = load_config_content(config_dir, COVERAGE_CONFIG_FILE)? else {
return Ok(CoverageConfig::default());
};
serde_yaml::from_str(&content).with_context(|| {
format!(
"could not parse coverage config {}/{COVERAGE_CONFIG_FILE}",
config_dir.display()
)
})
}
pub struct DiffOutcome {
pub rendered: String,
pub patch_percent: Option<f64>,
pub below_gate: bool,
pub unmeasured_failures: Vec<String>,
pub line_percent: Option<f64>,
pub below_line_gate: bool,
pub warnings: Vec<String>,
}
impl DiffCommand {
pub fn execute(mut self, repo: Option<&Path>) -> Result<()> {
if let Some(format) = self.format.take() {
eprintln!("warning: --format is deprecated; use -o/--output instead");
self.output = format;
}
let outcome = self.run(repo)?;
for warning in &outcome.warnings {
eprintln!("warning: {warning}");
}
println!("{}", outcome.rendered);
let failures = self.gate_failures(&outcome);
if !failures.is_empty() {
anyhow::bail!(failures.join("; "));
}
Ok(())
}
fn gate_failures(&self, outcome: &DiffOutcome) -> Vec<String> {
let mut failures = Vec::new();
if outcome.below_gate {
failures.push(format!(
"patch coverage {:.2}% is below the --fail-under-patch threshold of {:.2}%",
outcome.patch_percent.unwrap_or(0.0),
self.fail_under_patch.unwrap_or_default()
));
}
if outcome.below_line_gate {
let threshold = self.fail_under_lines.unwrap_or_default();
failures.push(match outcome.line_percent {
Some(pct) => format!(
"line coverage {pct:.2}% is below the --fail-under-lines threshold of {threshold:.2}%"
),
None => format!(
"the report has no executable lines, so the --fail-under-lines threshold of {threshold:.2}% cannot be met"
),
});
}
if !outcome.unmeasured_failures.is_empty() {
failures.push(format!("touched files absent from every coverage report (--fail-on-unmeasured / diff.require-measured): {}", outcome.unmeasured_failures.join(", ")));
}
failures
}
pub fn run(&self, repo_root: Option<&Path>) -> Result<DiffOutcome> {
let repo_path = repo_root.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let repo = Repository::open(&repo_path)
.with_context(|| format!("could not open git repository at {}", repo_path.display()))?;
let base_ref = match &self.base_ref {
Some(r) => r.clone(),
None => default_base_ref(&repo)?,
};
let strip_prefix = self
.strip_prefix
.clone()
.or_else(|| repo.workdir().map(std::path::Path::to_path_buf));
let config_dir = resolve_config_dir_at(self.config_dir.as_deref(), &repo_path);
let config = load_coverage_config(&config_dir)?.diff;
paths::validate(&config.path_mappings)?;
let ignore = self.compile_ignore(&config.ignore_filename_regex)?;
let require_measured = self.compile_require_measured(&config.require_measured)?;
let mut warnings = Vec::new();
let mut excluded = ExcludedFiles::default();
let head = self.load_head(
&config.path_mappings,
strip_prefix.as_deref(),
ignore.as_ref(),
&repo_path,
&mut warnings,
&mut excluded,
)?;
let baseline = match &self.baseline_report {
Some(path) => Some(self.load_baseline(
path,
&config.path_mappings,
strip_prefix.as_deref(),
ignore.as_ref(),
&repo_path,
&mut excluded,
)?),
None => None,
};
let diff = DiffModel::between(&repo, &base_ref, self.head_ref.as_deref())?;
let unmeasured_files = crate::analysis::unmeasured_files(&head, &diff, baseline.as_ref())
.into_iter()
.filter(|path| {
ignore.as_ref().is_none_or(|set| {
!set.is_match(path)
&& diff
.files
.get(path)
.and_then(|file| file.old_path.as_ref())
.is_none_or(|old| !set.is_match(old))
})
})
.collect::<Vec<_>>();
let unmeasured_failures = unmeasured_files
.iter()
.filter(|path| {
require_measured
.as_ref()
.is_some_and(|set| set.is_match(path))
})
.cloned()
.collect();
let mut markers = Markers {
head: self.scan_markers(&repo, Revision::Head(self.head_ref.as_deref()), &head)?,
base: match &baseline {
Some(baseline) => self.scan_markers(&repo, Revision::Base(&base_ref), baseline)?,
None => BTreeMap::new(),
},
};
let mut head = head;
let mut baseline = baseline;
apply_ignored(&mut head, &markers.head);
if let Some(baseline) = baseline.as_mut() {
apply_ignored(baseline, &markers.base);
}
markers.head.retain(|path, _| head.files.contains_key(path));
let scope = if self.all_files {
DiffScope::All
} else {
DiffScope::DiffOnly
};
let mut result = analyze_with_markers(&head, &diff, baseline.as_ref(), scope, &markers);
excluded.resolve(&diff);
result.excluded = excluded;
result.unmeasured_files = unmeasured_files;
let opts = self.render_options();
let rendered = render(&result, &opts, self.output.into())?;
let patch_percent = result.patch.percent();
let below_gate = match self.fail_under_patch {
Some(threshold) => patch_percent.is_some_and(|p| p < threshold),
None => false,
};
let line_percent = result.total_after;
let below_line_gate = self
.fail_under_lines
.is_some_and(|threshold| line_percent.is_none_or(|p| p < threshold));
Ok(DiffOutcome {
rendered,
patch_percent,
below_gate,
unmeasured_failures,
line_percent,
below_line_gate,
warnings,
})
}
fn load_head(
&self,
mappings: &[PathMapping],
strip_prefix: Option<&Path>,
ignore: Option<&RegexSet>,
repo_root: &Path,
warnings: &mut Vec<String>,
excluded: &mut ExcludedFiles,
) -> Result<CoverageReport> {
anyhow::ensure!(
!self.report.is_empty(),
"at least one coverage report is required"
);
let sharded = self.report.len() > 1;
let mut merged = CoverageReport::new();
for path in &self.report {
let path = anchor(path, repo_root);
let mut report = read_report(&path, self.report_format, repo_root)?;
let mapped_root = report.map_paths_with_root_match(mappings)?;
if sharded {
check_shard(
&path.display().to_string(),
&report,
strip_prefix.filter(|_| !mapped_root),
warnings,
)?;
}
excluded.record_head(normalise_report(&mut report, strip_prefix, ignore));
merged.merge(report);
}
Ok(merged)
}
fn load_baseline(
&self,
path: &Path,
mappings: &[PathMapping],
strip_prefix: Option<&Path>,
ignore: Option<&RegexSet>,
repo_root: &Path,
excluded: &mut ExcludedFiles,
) -> Result<CoverageReport> {
let mut report = read_report(
&anchor(path, repo_root),
self.baseline_report_format,
repo_root,
)?;
report.map_paths(mappings)?;
excluded.record_baseline(normalise_report(&mut report, strip_prefix, ignore));
Ok(report)
}
fn scan_markers(
&self,
repo: &Repository,
revision: Revision<'_>,
report: &CoverageReport,
) -> Result<BTreeMap<String, FileMarkers>> {
let mut found = BTreeMap::new();
let source = RevisionSource::open(repo, revision)?;
for path in report.files.keys() {
let Some(text) = source.read(path)? else {
continue;
};
let regions = markers::scan(path, &text)?;
if !regions.is_empty() {
found.insert(path.clone(), FileMarkers::new(regions));
}
}
Ok(found)
}
fn compile_ignore(&self, config_patterns: &[String]) -> Result<Option<RegexSet>> {
let patterns: Vec<&str> = self
.ignore_filename_regex
.iter()
.chain(config_patterns)
.map(String::as_str)
.filter(|p| !p.is_empty())
.collect();
if patterns.is_empty() {
return Ok(None);
}
let set = RegexSet::new(patterns).context(
"invalid ignore-filename-regex pattern (--ignore-filename-regex or config.yaml)",
)?;
Ok(Some(set))
}
fn compile_require_measured(&self, config: &[String]) -> Result<Option<GlobSet>> {
let mut builder = GlobSetBuilder::new();
if self.fail_on_unmeasured.is_empty() && config.is_empty() {
return Ok(None);
}
for pattern in self.fail_on_unmeasured.iter().chain(config) {
anyhow::ensure!(
!pattern.is_empty(),
"empty glob in --fail-on-unmeasured / diff.require-measured"
);
builder.add(GlobBuilder::new(pattern).literal_separator(true).build()
.with_context(|| format!("invalid glob `{pattern}` in --fail-on-unmeasured / diff.require-measured"))?);
}
Ok(Some(
builder
.build()
.context("could not compile require-measured globs")?,
))
}
fn render_options(&self) -> RenderOptions {
fn or_env(flag: &Option<String>, var: &str) -> Option<String> {
flag.clone()
.or_else(|| std::env::var(var).ok())
.filter(|s| !s.is_empty())
}
RenderOptions {
artifact_url: or_env(&self.artifact_url, "COVERAGE_ARTIFACT_URL"),
run_url: or_env(&self.run_url, "COVERAGE_RUN_URL"),
base_sha: or_env(&self.base_sha, "COVERAGE_BASE_SHA"),
head_sha: or_env(&self.head_sha, "COVERAGE_HEAD_SHA"),
commit_url: or_env(&self.commit_url, "COVERAGE_COMMIT_URL"),
collapse_ranges: self.collapse_ranges,
no_explanation: self.no_explanation,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use git2::{Repository, Signature};
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn repo_with_added_file() -> (TempDir, PathBuf, String) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().to_path_buf();
let repo = Repository::init(&path).unwrap();
{
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "Test").unwrap();
cfg.set_str("user.email", "test@example.com").unwrap();
}
let commit = |repo: &Repository, files: &[(&str, &str)], parent: Option<git2::Oid>| {
let mut index = repo.index().unwrap();
index.clear().unwrap();
for (name, content) in files {
fs::write(path.join(name), content).unwrap();
index.add_path(Path::new(name)).unwrap();
}
index.write().unwrap();
let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
let sig = Signature::now("Test", "test@example.com").unwrap();
let parent = parent.map(|id| repo.find_commit(id).unwrap());
let parents: Vec<&git2::Commit> = parent.as_ref().into_iter().collect();
repo.commit(Some("HEAD"), &sig, &sig, "c", &tree, &parents)
.unwrap()
};
let base = commit(&repo, &[("a.rs", "fn a() {}\n")], None);
commit(
&repo,
&[("a.rs", "fn a() {}\n"), ("b.rs", "one\ntwo\nthree\n")],
Some(base),
);
let workdir = repo.workdir().unwrap().to_path_buf();
(dir, workdir, base.to_string())
}
fn write_head_lcov(repo_path: &Path) -> PathBuf {
let lcov = format!(
"SF:{}\nDA:1,1\nDA:2,0\nDA:3,4\nend_of_record\n",
repo_path.join("b.rs").display()
);
let report = repo_path.join("head.lcov");
fs::write(&report, lcov).unwrap();
report
}
fn command(report: PathBuf, base_ref: &str) -> DiffCommand {
DiffCommand {
report: vec![report],
report_format: ReportFormat::Auto,
base_ref: Some(base_ref.to_string()),
head_ref: None,
baseline_report: None,
baseline_report_format: ReportFormat::Auto,
output: OutputFormatArg::Markdown,
no_explanation: false,
format: None,
fail_under_patch: None,
fail_on_unmeasured: Vec::new(),
fail_under_lines: None,
strip_prefix: None,
ignore_filename_regex: Vec::new(),
config_dir: None,
collapse_ranges: false,
all_files: false,
artifact_url: None,
run_url: None,
base_sha: None,
head_sha: None,
commit_url: None,
}
}
fn write_coverage_config(repo: &Path, patterns: &[&str]) -> PathBuf {
use std::fmt::Write as _;
let config_dir = repo.join(".patchcov");
fs::create_dir_all(&config_dir).unwrap();
let mut body = String::from("diff:\n");
if patterns.is_empty() {
body.push_str(" ignore-filename-regex: []\n");
} else {
body.push_str(" ignore-filename-regex:\n");
for p in patterns {
let _ = writeln!(body, " - '{p}'");
}
}
fs::write(config_dir.join("config.yaml"), body).unwrap();
config_dir
}
#[test]
fn unmeasured_files_render_and_gate_only_matching_policy() {
let (_dir, repo, base) = repo_with_added_file();
let report = repo.join("head.lcov");
fs::write(&report, "SF:other.rs\nDA:1,1\nend_of_record\n").unwrap();
let mut cmd = command(report, &base);
cmd.fail_under_patch = Some(80.0);
let outcome = cmd.run(Some(&repo)).unwrap();
assert!(!outcome.below_gate);
assert!(cmd.gate_failures(&outcome).is_empty());
assert!(outcome
.rendered
.contains("Touched files absent from every coverage report (1)"));
assert!(outcome.rendered.contains("`b.rs`"));
for format in [OutputFormatArg::Json, OutputFormatArg::Yaml] {
cmd.output = format;
let outcome = cmd.run(Some(&repo)).unwrap();
let value: serde_json::Value = if format == OutputFormatArg::Json {
serde_json::from_str(&outcome.rendered).unwrap()
} else {
serde_yaml::from_str(&outcome.rendered).unwrap()
};
assert_eq!(value["unmeasured_files"], serde_json::json!(["b.rs"]));
}
cmd.fail_on_unmeasured = vec!["docs/**".into()];
assert!(cmd.run(Some(&repo)).unwrap().unmeasured_failures.is_empty());
cmd.fail_on_unmeasured.push("**/*.rs".into());
let outcome = cmd.run(Some(&repo)).unwrap();
assert_eq!(outcome.unmeasured_failures, ["b.rs"]);
assert!(cmd.gate_failures(&outcome)[0].contains("b.rs"));
assert!(cmd.execute(Some(&repo)).is_err());
}
#[test]
fn require_measured_config_unions_cli_and_validates_before_loading() {
let (_dir, repo, base) = repo_with_added_file();
let dir = repo.join(".patchcov");
fs::create_dir(&dir).unwrap();
fs::write(
dir.join("config.yaml"),
"diff:\n require-measured: ['**/*.rs']\n",
)
.unwrap();
let report = repo.join("head.lcov");
fs::write(&report, "SF:other.rs\nDA:1,1\nend_of_record\n").unwrap();
let mut cmd = command(report, &base);
cmd.config_dir = Some(dir);
cmd.fail_on_unmeasured = vec!["docs/**".into()];
assert_eq!(cmd.run(Some(&repo)).unwrap().unmeasured_failures, ["b.rs"]);
cmd.report = vec![repo.join("missing.lcov")];
for pattern in ["[", ""] {
cmd.fail_on_unmeasured = vec![pattern.into()];
assert!(cmd
.run(Some(&repo))
.err()
.unwrap()
.to_string()
.contains("glob"));
}
let set = cmd.compile_require_measured(&["src/**/*.rs".into()]);
assert!(set.is_err()); cmd.fail_on_unmeasured.clear();
let set = cmd
.compile_require_measured(&["src/**/*.rs".into()])
.unwrap()
.unwrap();
assert!(set.is_match("src/a.rs"));
assert!(set.is_match("src/nested/a.rs"));
assert!(!set.is_match("docs/a.rs"));
assert!(!set.is_match("src/a.md"));
}
#[test]
fn unmeasured_exclusions_baseline_shards_and_markers() {
let (_dir, repo, base) = repo_with_added_file();
let report = repo.join("head.lcov");
fs::write(&report, "SF:other.rs\nDA:1,1\nend_of_record\n").unwrap();
let mut cmd = command(report, &base);
cmd.fail_on_unmeasured = vec!["**/*.rs".into()];
cmd.ignore_filename_regex = vec!["^b\\.rs$".into()];
assert!(cmd.run(Some(&repo)).unwrap().unmeasured_failures.is_empty());
cmd.ignore_filename_regex.clear();
let measured = write_head_lcov(&repo);
let unrelated = repo.join("other.lcov");
fs::write(&unrelated, "SF:other.rs\nDA:1,1\nend_of_record\n").unwrap();
cmd.report = vec![unrelated];
cmd.baseline_report = Some(measured.clone());
assert!(cmd.run(Some(&repo)).unwrap().unmeasured_failures.is_empty());
cmd.baseline_report = None;
cmd.report.push(measured);
assert!(cmd.run(Some(&repo)).unwrap().unmeasured_failures.is_empty());
fs::write(
repo.join("b.rs"),
format!(
"// {} coverage ignore reason=\"generated\"\none\n// {} coverage end\n",
"patchcov:", "patchcov:"
),
)
.unwrap();
assert!(cmd.run(Some(&repo)).unwrap().unmeasured_failures.is_empty());
}
#[test]
fn fail_on_unmeasured_parses_repeatable_globs() {
let cmd = DiffCommand::try_parse_from([
"diff",
"--report",
"r.lcov",
"--fail-on-unmeasured",
"src/**/*.rs",
"--fail-on-unmeasured",
"lib/**/*.py",
])
.unwrap();
assert_eq!(cmd.fail_on_unmeasured, ["src/**/*.rs", "lib/**/*.py"]);
}
#[test]
fn report_format_into_format() {
assert_eq!(ReportFormat::Auto.into_format(), None);
assert_eq!(ReportFormat::Jacoco.into_format(), Some(Format::Jacoco));
assert_eq!(ReportFormat::Lcov.into_format(), Some(Format::Lcov));
assert_eq!(
ReportFormat::LlvmCovJson.into_format(),
Some(Format::LlvmCovJson)
);
assert_eq!(
ReportFormat::Cobertura.into_format(),
Some(Format::Cobertura)
);
assert_eq!(
ReportFormat::GoCoverprofile.into_format(),
Some(Format::GoCoverprofile)
);
}
#[test]
fn output_format_arg_conversion() {
assert_eq!(
OutputFormat::from(OutputFormatArg::Markdown),
OutputFormat::Markdown
);
assert_eq!(
OutputFormat::from(OutputFormatArg::Yaml),
OutputFormat::Yaml
);
assert_eq!(
OutputFormat::from(OutputFormatArg::Json),
OutputFormat::Json
);
}
#[test]
fn run_markdown_reports_patch_coverage() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_head_lcov(&repo);
let outcome = command(report, &base).run(Some(&repo)).unwrap();
assert_eq!(outcome.patch_percent, Some(2.0 / 3.0 * 100.0));
assert!(!outcome.below_gate);
assert!(outcome.rendered.contains("### Patch coverage"));
assert!(outcome.rendered.contains("`b.rs:2`"));
}
#[test]
fn run_yaml_and_json_formats() {
let (_dir, repo, base) = repo_with_added_file();
for format in [OutputFormatArg::Yaml, OutputFormatArg::Json] {
let report = write_head_lcov(&repo);
let mut cmd = command(report, &base);
cmd.output = format;
let outcome = cmd.run(Some(&repo)).unwrap();
assert!(outcome.rendered.contains("patch_coverage"));
}
}
#[test]
fn no_explanation_removes_only_explanation_in_structured_output() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_head_lcov(&repo);
for format in ["json", "yaml"] {
for baseline in [false, true] {
let mut cmd = DiffCommand::try_parse_from([
"diff",
"--report",
report.to_str().unwrap(),
"--base-ref",
&base,
"-o",
format,
"--fail-under-patch",
"99",
])
.unwrap();
assert!(!cmd.no_explanation);
if baseline {
cmd.baseline_report = Some(report.clone());
}
let default = cmd.run(Some(&repo)).unwrap();
let flagged = DiffCommand::try_parse_from([
"diff",
"--report",
report.to_str().unwrap(),
"--no-explanation",
])
.unwrap();
cmd.no_explanation = flagged.no_explanation;
let compact = cmd.run(Some(&repo)).unwrap();
let parse = |s: &str| -> serde_json::Value {
if format == "json" {
serde_json::from_str(s).unwrap()
} else {
serde_yaml::from_str(s).unwrap()
}
};
let mut expected = parse(&default.rendered);
assert!(expected
.as_object_mut()
.unwrap()
.remove("explanation")
.is_some());
let actual = parse(&compact.rendered);
assert!(!actual.as_object().unwrap().contains_key("explanation"));
assert_eq!(actual, expected, "format={format}, baseline={baseline}");
assert_eq!(compact.patch_percent, default.patch_percent);
assert!(default.below_gate);
assert_eq!(compact.below_gate, default.below_gate);
assert_eq!(compact.warnings, default.warnings);
}
}
}
#[test]
fn no_explanation_leaves_markdown_unchanged() {
let (_dir, repo, base) = repo_with_added_file();
let mut cmd = command(write_head_lcov(&repo), &base);
let default = cmd.run(Some(&repo)).unwrap().rendered;
cmd.no_explanation = true;
assert_eq!(cmd.run(Some(&repo)).unwrap().rendered, default);
}
fn marked(kind: &str, reason: &str) -> String {
format!(
"// {} {kind} reason=\"{reason}\"",
crate::markers::INTRODUCER
)
}
fn repo_with_moved_marker_region(
kind: Option<&str>,
) -> (TempDir, PathBuf, String, PathBuf, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().to_path_buf();
let repo = Repository::init(&path).unwrap();
{
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "Test").unwrap();
cfg.set_str("user.email", "test@example.com").unwrap();
}
let (open, close) = match kind {
Some(kind) => (
marked(kind, "CPU-gated dispatch"),
format!("// {} end", crate::markers::INTRODUCER),
),
None => (
"// an ordinary comment".to_string(),
"// another".to_string(),
),
};
let base_src =
format!("{open}\nfn gated() {{}}\nfn also_gated() {{}}\n{close}\nfn plain() {{}}\n");
let head_src = format!(
"// a new comment\n// and another\n{open}\nfn gated() {{}}\nfn also_gated() {{}}\n{close}\nfn plain() {{}}\n"
);
let commit = |files: &[(&str, &str)], parent: Option<git2::Oid>| {
let mut index = repo.index().unwrap();
index.clear().unwrap();
for (name, content) in files {
let file = path.join(name);
fs::create_dir_all(file.parent().unwrap()).unwrap();
fs::write(&file, content).unwrap();
index.add_path(Path::new(name)).unwrap();
}
index.write().unwrap();
let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
let sig = Signature::now("Test", "test@example.com").unwrap();
let parent = parent.map(|id| repo.find_commit(id).unwrap());
let parents: Vec<&git2::Commit> = parent.as_ref().into_iter().collect();
repo.commit(Some("HEAD"), &sig, &sig, "c", &tree, &parents)
.unwrap()
};
let base = commit(&[("src/gated.rs", &base_src)], None);
commit(&[("src/gated.rs", &head_src)], Some(base));
let workdir = repo.workdir().unwrap().to_path_buf();
let gated = workdir.join("src/gated.rs");
let base_lcov = workdir.join("base.lcov");
fs::write(
&base_lcov,
format!(
"SF:{}\nDA:2,4\nDA:3,4\nDA:5,1\nend_of_record\n",
gated.display()
),
)
.unwrap();
let head_lcov = workdir.join("head.lcov");
fs::write(
&head_lcov,
format!(
"SF:{}\nDA:4,0\nDA:5,0\nDA:7,1\nend_of_record\n",
gated.display()
),
)
.unwrap();
(dir, workdir, base.to_string(), head_lcov, base_lcov)
}
#[test]
fn tolerate_marker_masks_a_flip_across_a_moved_region() {
let (_dir, repo, base, head_lcov, base_lcov) =
repo_with_moved_marker_region(Some("tolerate"));
let mut cmd = command(head_lcov, &base);
cmd.baseline_report = Some(base_lcov);
let rendered = cmd.run(Some(&repo)).unwrap().rendered;
assert!(
rendered.contains("Total: **33.33%**"),
"the displayed percentage must be the real one: {rendered}"
);
assert!(
rendered.contains("\u{26aa} 0 pp vs `main`"),
"the masked flip must not move the headline: {rendered}"
);
assert!(
!rendered.contains("\u{1f534}"),
"nothing should be painted red: {rendered}"
);
assert!(
rendered.contains("0 ignored region(s), 1 tolerated region(s)"),
"a region that merely moved is one region, not two: {rendered}"
);
assert!(
rendered.contains("| `src/gated.rs` | `tolerate` | 3-6 | both |"),
"the span reported is the one observed at head: {rendered}"
);
assert!(
rendered.contains("CPU-gated dispatch"),
"the reason must be shown: {rendered}"
);
}
#[test]
fn the_same_flip_moves_the_report_without_a_marker() {
let (_dir, repo, base, head_lcov, base_lcov) = repo_with_moved_marker_region(None);
let mut cmd = command(head_lcov, &base);
cmd.baseline_report = Some(base_lcov);
let rendered = cmd.run(Some(&repo)).unwrap().rendered;
assert!(
rendered.contains("\u{1f534}"),
"an unmarked flip must still be reported: {rendered}"
);
assert!(!rendered.contains("region(s)"), "{rendered}");
}
#[test]
fn ignore_marker_removes_lines_from_both_reports() {
let (_dir, repo, base, head_lcov, base_lcov) =
repo_with_moved_marker_region(Some("ignore"));
let mut cmd = command(head_lcov, &base);
cmd.baseline_report = Some(base_lcov);
let rendered = cmd.run(Some(&repo)).unwrap().rendered;
assert!(
rendered.contains("Total: **100%**"),
"only the covered `plain` line should remain in the denominator: {rendered}"
);
assert!(rendered.contains("1 ignored region(s)"), "{rendered}");
}
#[test]
fn a_malformed_marker_is_a_hard_error() {
let (_dir, repo, base, head_lcov, base_lcov) =
repo_with_moved_marker_region(Some("tolerate"));
let gated = repo.join("src/gated.rs");
let source = fs::read_to_string(&gated).unwrap();
let end = format!("// {} end", crate::markers::INTRODUCER);
fs::write(&gated, source.replace(&end, "// not the end")).unwrap();
let mut cmd = command(head_lcov, &base);
cmd.baseline_report = Some(base_lcov);
let message = match cmd.run(Some(&repo)) {
Err(e) => e.to_string(),
Ok(_) => panic!("an unterminated region must fail the run"),
};
assert!(message.contains("src/gated.rs:3"), "{message}");
assert!(message.contains("unterminated"), "{message}");
}
#[test]
fn run_with_baseline_enables_delta() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_head_lcov(&repo);
let baseline = repo.join("base.lcov");
fs::write(
&baseline,
format!("SF:{}/a.rs\nDA:1,1\nend_of_record\n", repo.display()),
)
.unwrap();
let mut cmd = command(report, &base);
cmd.baseline_report = Some(baseline);
let outcome = cmd.run(Some(&repo)).unwrap();
assert!(outcome.rendered.contains("vs `main`"));
}
#[test]
fn fail_under_patch_gate() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_head_lcov(&repo);
let mut cmd = command(report, &base);
cmd.fail_under_patch = Some(90.0);
assert!(
cmd.run(Some(&repo)).unwrap().below_gate,
"66.7% < 90% should fail"
);
let report = write_head_lcov(&repo);
let mut cmd = command(report, &base);
cmd.fail_under_patch = Some(50.0);
assert!(
!cmd.run(Some(&repo)).unwrap().below_gate,
"66.7% >= 50% should pass"
);
}
fn write_two_file_head_lcov(repo: &Path) -> PathBuf {
let lcov = format!(
"SF:{a}\nDA:1,0\nDA:2,0\nend_of_record\nSF:{b}\nDA:1,1\nDA:2,0\nDA:3,4\nend_of_record\n",
a = repo.join("a.rs").display(),
b = repo.join("b.rs").display(),
);
let report = repo.join("head.lcov");
fs::write(&report, lcov).unwrap();
report
}
#[test]
fn fail_under_lines_gate() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_two_file_head_lcov(&repo);
let mut cmd = command(report.clone(), &base);
cmd.fail_under_lines = Some(50.0);
let outcome = cmd.run(Some(&repo)).unwrap();
assert!(outcome.below_line_gate, "40% < 50% should fail");
assert_eq!(outcome.line_percent, Some(40.0));
assert!(!outcome.below_gate, "the patch gate is independent");
let mut cmd = command(report, &base);
cmd.fail_under_lines = Some(40.0);
assert!(
!cmd.run(Some(&repo)).unwrap().below_line_gate,
"a total exactly at the threshold passes"
);
}
#[test]
fn line_gate_is_off_unless_requested() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_two_file_head_lcov(&repo);
let outcome = command(report, &base).run(Some(&repo)).unwrap();
assert_eq!(outcome.line_percent, Some(40.0));
assert!(!outcome.below_line_gate);
}
#[test]
fn fail_under_lines_sees_the_post_ignore_total() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_two_file_head_lcov(&repo);
let mut cmd = command(report, &base);
cmd.fail_under_lines = Some(50.0);
cmd.ignore_filename_regex = vec![r"a\.rs".to_string()];
let outcome = cmd.run(Some(&repo)).unwrap();
assert_eq!(outcome.line_percent, Some(2.0 / 3.0 * 100.0));
assert!(!outcome.below_line_gate);
}
#[test]
fn fail_under_lines_fails_when_nothing_is_measurable() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_head_lcov(&repo);
let mut cmd = command(report, &base);
cmd.fail_under_lines = Some(1.0);
cmd.ignore_filename_regex = vec![r"b\.rs".to_string()];
let outcome = cmd.run(Some(&repo)).unwrap();
assert_eq!(outcome.line_percent, None);
assert!(outcome.below_line_gate);
let failures = cmd.gate_failures(&outcome);
assert_eq!(failures.len(), 1);
assert!(failures[0].contains("no executable lines"), "{failures:?}");
}
#[test]
fn gate_failures_reports_every_failed_gate() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_two_file_head_lcov(&repo);
let mut cmd = command(report, &base);
cmd.fail_under_patch = Some(90.0);
cmd.fail_under_lines = Some(90.0);
let outcome = cmd.run(Some(&repo)).unwrap();
let failures = cmd.gate_failures(&outcome);
assert_eq!(failures.len(), 2, "{failures:?}");
assert!(
failures[0].contains("patch coverage 66.67%"),
"{failures:?}"
);
assert!(failures[1].contains("line coverage 40.00%"), "{failures:?}");
assert!(failures[1].contains("--fail-under-lines threshold of 90.00%"));
}
#[test]
fn execute_bails_on_the_line_gate_only() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_two_file_head_lcov(&repo);
let mut cmd = command(report, &base);
cmd.fail_under_lines = Some(90.0);
let message = cmd.execute(Some(&repo)).unwrap_err().to_string();
assert!(message.contains("--fail-under-lines"), "{message}");
assert!(!message.contains("--fail-under-patch"), "{message}");
}
#[test]
fn fail_under_lines_parses() {
use clap::Parser;
let cmd =
DiffCommand::try_parse_from(["diff", "--report", "r.lcov", "--fail-under-lines", "80"])
.unwrap();
assert_eq!(cmd.fail_under_lines, Some(80.0));
assert_eq!(cmd.fail_under_patch, None);
}
fn write_shard(repo: &Path, name: &str, b_lines: &[(u32, u64)]) -> PathBuf {
use std::fmt::Write as _;
let mut lcov = format!(
"SF:{}\nDA:1,1\nend_of_record\nSF:{}\n",
repo.join("a.rs").display(),
repo.join("b.rs").display()
);
for (line, hits) in b_lines {
let _ = writeln!(lcov, "DA:{line},{hits}");
}
lcov.push_str("end_of_record\n");
let path = repo.join(name);
fs::write(&path, lcov).unwrap();
path
}
fn sharded(reports: Vec<PathBuf>, base: &str) -> DiffCommand {
let mut cmd = command(PathBuf::new(), base);
cmd.report = reports;
cmd
}
#[test]
fn shards_are_merged_before_the_patch_is_computed() {
let (_dir, repo, base) = repo_with_added_file();
let one = write_shard(&repo, "one.lcov", &[(1, 1), (2, 0), (3, 0)]);
let two = write_shard(&repo, "two.lcov", &[(1, 0), (2, 1), (3, 0)]);
for alone in [&one, &two] {
let outcome = sharded(vec![alone.clone()], &base)
.run(Some(&repo))
.unwrap();
assert_eq!(outcome.patch_percent, Some(1.0 / 3.0 * 100.0));
}
let outcome = sharded(vec![one, two], &base).run(Some(&repo)).unwrap();
assert_eq!(outcome.patch_percent, Some(2.0 / 3.0 * 100.0));
assert_eq!(outcome.line_percent, Some(75.0));
assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings);
}
#[test]
fn shard_order_does_not_change_the_output() {
let (_dir, repo, base) = repo_with_added_file();
let one = write_shard(&repo, "one.lcov", &[(1, 1), (2, 0), (3, 0)]);
let two = write_shard(&repo, "two.lcov", &[(1, 0), (2, 1), (3, 0)]);
let forward = sharded(vec![one.clone(), two.clone()], &base)
.run(Some(&repo))
.unwrap();
let reverse = sharded(vec![two, one], &base).run(Some(&repo)).unwrap();
assert_eq!(forward.rendered, reverse.rendered);
assert_eq!(forward.line_percent, reverse.line_percent);
}
#[test]
fn shards_of_different_formats_are_merged() {
let (_dir, repo, base) = repo_with_added_file();
let lcov = write_shard(&repo, "one.lcov", &[(1, 1), (2, 1), (3, 0)]);
let json = repo.join("two.json");
fs::write(
&json,
serde_json::json!({
"data": [{ "files": [{
"filename": repo.join("b.rs").display().to_string(),
"segments": [
[2, 1, 0, true, true, false],
[4, 1, 0, false, false, false]
]
}]}],
"type": "llvm.coverage.json.export",
"version": "2.0.1"
})
.to_string(),
)
.unwrap();
let outcome = sharded(vec![lcov, json], &base).run(Some(&repo)).unwrap();
assert_eq!(outcome.line_percent, Some(60.0));
assert_eq!(outcome.patch_percent, Some(2.0 / 3.0 * 100.0));
}
#[test]
fn a_shard_with_no_executable_lines_fails_the_run_by_name() {
let (_dir, repo, base) = repo_with_added_file();
let one = write_shard(&repo, "one.lcov", &[(1, 1), (2, 1), (3, 1)]);
let two = repo.join("two.lcov");
fs::write(&two, "TN:\nSF:b.rs\nend_of_record\n").unwrap();
let message = sharded(vec![one, two], &base)
.run(Some(&repo))
.err()
.expect("an empty shard must fail the run")
.to_string();
assert!(message.contains("two.lcov"), "{message}");
assert!(message.contains("no executable lines"), "{message}");
}
#[test]
fn an_unparseable_shard_fails_the_run_by_name() {
let (_dir, repo, base) = repo_with_added_file();
let one = write_shard(&repo, "one.lcov", &[(1, 1), (2, 1), (3, 1)]);
let two = repo.join("two.lcov");
fs::write(&two, "").unwrap();
let message = format!(
"{:#}",
sharded(vec![one, two], &base)
.run(Some(&repo))
.err()
.expect("an empty file must fail the run")
);
assert!(message.contains("two.lcov"), "{message}");
}
#[test]
fn a_missing_shard_fails_the_run() {
let (_dir, repo, base) = repo_with_added_file();
let one = write_shard(&repo, "one.lcov", &[(1, 1), (2, 1), (3, 1)]);
let message = sharded(vec![one, repo.join("shard-2.lcov")], &base)
.run(Some(&repo))
.err()
.expect("a shard that never uploaded must fail the run")
.to_string();
assert!(message.contains("shard-2.lcov"), "{message}");
}
#[test]
fn a_lone_report_with_no_lines_is_still_accepted() {
let (_dir, repo, base) = repo_with_added_file();
let empty = repo.join("empty.lcov");
fs::write(&empty, "TN:\nSF:b.rs\nend_of_record\n").unwrap();
let outcome = sharded(vec![empty], &base).run(Some(&repo)).unwrap();
assert_eq!(outcome.line_percent, None);
}
#[test]
fn a_shard_whose_files_are_all_ignored_is_not_empty() {
let (_dir, repo, base) = repo_with_added_file();
let one = write_shard(&repo, "one.lcov", &[(1, 1), (2, 1), (3, 1)]);
let two = repo.join("two.lcov");
fs::write(
&two,
format!(
"SF:{}\nDA:1,1\nend_of_record\n",
repo.join("a.rs").display()
),
)
.unwrap();
let mut cmd = sharded(vec![one, two], &base);
cmd.ignore_filename_regex = vec![r"a\.rs".to_string()];
let outcome = cmd.run(Some(&repo)).unwrap();
assert_eq!(outcome.line_percent, Some(100.0));
}
#[test]
fn a_shard_under_another_root_warns_but_still_runs() {
let (_dir, repo, base) = repo_with_added_file();
let one = write_shard(&repo, "one.lcov", &[(1, 1), (2, 1), (3, 1)]);
let two = repo.join("two.lcov");
fs::write(
&two,
"SF:/some/other/runner/b.rs\nDA:1,1\nDA:2,1\nend_of_record\n",
)
.unwrap();
let outcome = sharded(vec![one, two], &base).run(Some(&repo)).unwrap();
assert_eq!(outcome.warnings.len(), 1, "{:?}", outcome.warnings);
assert!(outcome.warnings[0].contains("two.lcov"));
assert!(outcome.warnings[0].contains("--strip-prefix"));
}
#[test]
fn execute_reports_shard_warnings_without_failing() {
let (_dir, repo, base) = repo_with_added_file();
let one = write_shard(&repo, "one.lcov", &[(1, 1), (2, 1), (3, 1)]);
let two = repo.join("two.lcov");
fs::write(&two, "SF:/some/other/runner/b.rs\nDA:1,1\nend_of_record\n").unwrap();
sharded(vec![one, two], &base).execute(Some(&repo)).unwrap();
}
#[test]
fn a_lone_report_under_another_root_does_not_warn() {
let (_dir, repo, base) = repo_with_added_file();
let only = repo.join("only.lcov");
fs::write(&only, "SF:/some/other/runner/b.rs\nDA:1,1\nend_of_record\n").unwrap();
let outcome = sharded(vec![only], &base).run(Some(&repo)).unwrap();
assert!(outcome.warnings.is_empty());
}
#[test]
fn an_empty_report_list_is_an_error() {
let (_dir, repo, base) = repo_with_added_file();
let message = sharded(Vec::new(), &base)
.run(Some(&repo))
.err()
.expect("no report at all must fail")
.to_string();
assert!(
message.contains("at least one coverage report"),
"{message}"
);
}
#[test]
fn report_is_repeatable_and_still_required() {
use clap::Parser;
let cmd = DiffCommand::try_parse_from([
"diff",
"--report",
"one.lcov",
"--report",
"two.lcov",
"--report",
"three.json",
])
.unwrap();
assert_eq!(
cmd.report,
vec![
PathBuf::from("one.lcov"),
PathBuf::from("two.lcov"),
PathBuf::from("three.json")
]
);
assert!(DiffCommand::try_parse_from(["diff"]).is_err());
}
#[test]
fn all_files_scope_surfaces_untouched_file() {
let (_dir, repo, base) = repo_with_added_file();
let head = format!(
"SF:{a}\nDA:1,1\nDA:2,1\nDA:3,1\nDA:4,0\nend_of_record\n\
SF:{b}\nDA:1,1\nDA:2,0\nDA:3,4\nend_of_record\n",
a = repo.join("a.rs").display(),
b = repo.join("b.rs").display(),
);
let report = repo.join("head.lcov");
fs::write(&report, head).unwrap();
let baseline = repo.join("base.lcov");
fs::write(
&baseline,
format!(
"SF:{}\nDA:1,1\nDA:2,1\nDA:3,0\nDA:4,0\nend_of_record\n",
repo.join("a.rs").display()
),
)
.unwrap();
let mut scoped = command(report.clone(), &base);
scoped.baseline_report = Some(baseline.clone());
let scoped_md = scoped.run(Some(&repo)).unwrap().rendered;
assert!(
!scoped_md.contains("`a.rs`"),
"DiffOnly must hide untouched a.rs"
);
let mut all = command(report, &base);
all.baseline_report = Some(baseline);
all.all_files = true;
let all_md = all.run(Some(&repo)).unwrap().rendered;
assert!(
all_md.contains("`a.rs`"),
"All scope must surface untouched a.rs"
);
}
#[test]
fn ignore_filename_regex_excludes_file_from_patch() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_head_lcov(&repo);
let mut cmd = command(report, &base);
cmd.ignore_filename_regex = vec![r"b\.rs".to_string()];
let outcome = cmd.run(Some(&repo)).unwrap();
assert_eq!(outcome.patch_percent, None);
assert!(!outcome.rendered.contains("`b.rs:2`"));
}
#[test]
fn excluding_the_only_touched_file_is_visible_and_not_an_empty_diff() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_head_lcov(&repo);
let mut cmd = command(report, &base);
cmd.ignore_filename_regex = vec![r"b\.rs".to_string()];
let md = cmd.run(Some(&repo)).unwrap().rendered;
assert!(
md.contains(
"_Excluded by ignore-filename-regex: 1 file (1 of them touched by this diff, adding 3 executable lines)._"
),
"{md}"
);
assert!(md.contains("- `b.rs`"), "{md}");
assert!(
md.contains("3 new executable lines are in files excluded by ignore-filename-regex"),
"{md}"
);
assert!(!md.contains("_No new executable lines added by this diff._"));
cmd.output = OutputFormatArg::Json;
let json: serde_json::Value =
serde_json::from_str(&cmd.run(Some(&repo)).unwrap().rendered).unwrap();
let excluded = &json["excluded_files"];
assert_eq!(excluded["count"], 1);
assert_eq!(excluded["touched_count"], 1);
assert_eq!(excluded["new_executable_lines"], 3);
assert_eq!(excluded["paths"], serde_json::json!(["b.rs"]));
assert_eq!(excluded["touched"], serde_json::json!(["b.rs"]));
}
#[test]
fn a_filter_matching_no_file_leaves_the_output_unchanged() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_head_lcov(&repo);
let plain = command(report.clone(), &base)
.run(Some(&repo))
.unwrap()
.rendered;
let mut cmd = command(report, &base);
cmd.ignore_filename_regex = vec![r"nomatch\.rs".to_string()];
let filtered = cmd.run(Some(&repo)).unwrap().rendered;
assert_eq!(filtered, plain);
assert!(!filtered.contains("Excluded by"));
for format in [OutputFormatArg::Json, OutputFormatArg::Yaml] {
cmd.output = format;
assert!(!cmd
.run(Some(&repo))
.unwrap()
.rendered
.contains("excluded_files:"));
assert!(!cmd
.run(Some(&repo))
.unwrap()
.rendered
.contains("\"excluded_files\""));
}
}
#[test]
fn excluding_an_untouched_file_says_the_diff_did_not_touch_it() {
let (_dir, repo, base) = repo_with_added_file();
let head = format!(
"SF:{a}\nDA:1,1\nend_of_record\nSF:{b}\nDA:1,1\nDA:2,0\nDA:3,4\nend_of_record\n",
a = repo.join("a.rs").display(),
b = repo.join("b.rs").display(),
);
let report = repo.join("head.lcov");
fs::write(&report, head).unwrap();
let mut cmd = command(report, &base);
cmd.ignore_filename_regex = vec![r"a\.rs".to_string()];
let outcome = cmd.run(Some(&repo)).unwrap();
assert!(
outcome.rendered.contains(
"_Excluded by ignore-filename-regex: 1 file (none of them touched by this diff)._"
),
"{}",
outcome.rendered
);
assert!(!outcome.rendered.contains("Excluded files touched"));
assert!(outcome.patch_percent.is_some());
}
#[test]
fn a_diff_that_added_no_executable_code_reads_as_before_beside_the_note() {
let (_dir, repo, base) = repo_with_added_file();
let report = repo.join("head.lcov");
fs::write(
&report,
format!(
"SF:{}\nDA:1,1\nend_of_record\n",
repo.join("a.rs").display()
),
)
.unwrap();
let mut cmd = command(report, &base);
cmd.ignore_filename_regex = vec![r"a\.rs".to_string()];
let md = cmd.run(Some(&repo)).unwrap().rendered;
assert!(
md.contains("_No new executable lines added by this diff._"),
"{md}"
);
assert!(
md.contains("Excluded by ignore-filename-regex: 1 file"),
"{md}"
);
}
#[test]
fn a_file_only_the_baseline_held_counts_as_excluded() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_head_lcov(&repo);
let baseline = repo.join("base.lcov");
fs::write(
&baseline,
format!(
"SF:{}\nDA:1,1\nend_of_record\n",
repo.join("a.rs").display()
),
)
.unwrap();
let mut cmd = command(report, &base);
cmd.baseline_report = Some(baseline);
cmd.ignore_filename_regex = vec![r"a\.rs".to_string()];
let md = cmd.run(Some(&repo)).unwrap().rendered;
assert!(
md.contains(
"_Excluded by ignore-filename-regex: 1 file (none of them touched by this diff)._"
),
"{md}"
);
}
#[test]
fn ignore_filename_regex_drops_file_from_both_reports() {
let (_dir, repo, base) = repo_with_added_file();
let head = format!(
"SF:{a}\nDA:1,1\nDA:2,1\nDA:3,1\nDA:4,0\nend_of_record\n\
SF:{b}\nDA:1,1\nDA:2,0\nDA:3,4\nend_of_record\n",
a = repo.join("a.rs").display(),
b = repo.join("b.rs").display(),
);
let report = repo.join("head.lcov");
fs::write(&report, head).unwrap();
let baseline = repo.join("base.lcov");
fs::write(
&baseline,
format!(
"SF:{}\nDA:1,1\nDA:2,1\nDA:3,0\nDA:4,0\nend_of_record\n",
repo.join("a.rs").display()
),
)
.unwrap();
let mut cmd = command(report, &base);
cmd.baseline_report = Some(baseline);
cmd.all_files = true;
cmd.ignore_filename_regex = vec![r"a\.rs".to_string()];
let md = cmd.run(Some(&repo)).unwrap().rendered;
assert!(
!md.contains("`a.rs`"),
"ignored a.rs must not appear in the delta table"
);
assert!(md.contains("`b.rs"));
}
#[test]
fn ignore_filename_regex_parses_repeatable_and_comma() {
use clap::Parser;
let cmd = DiffCommand::try_parse_from([
"diff",
"--report",
"r.lcov",
"--ignore-filename-regex",
"foo,bar",
"--ignore-filename-regex",
"baz",
])
.unwrap();
assert_eq!(cmd.ignore_filename_regex, vec!["foo", "bar", "baz"]);
}
#[test]
fn ignore_filename_regex_treats_empty_pattern_as_noop() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_head_lcov(&repo);
let mut cmd = command(report, &base);
cmd.ignore_filename_regex = vec![String::new(), r"nomatch\.rs".to_string()];
let outcome = cmd.run(Some(&repo)).unwrap();
assert_eq!(outcome.patch_percent, Some(2.0 / 3.0 * 100.0));
assert!(outcome.rendered.contains("`b.rs:2`"));
}
#[test]
fn invalid_ignore_pattern_errors() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_head_lcov(&repo);
let mut cmd = command(report, &base);
cmd.ignore_filename_regex = vec!["(unclosed".to_string()];
assert!(cmd.run(Some(&repo)).is_err());
}
#[test]
fn path_mappings_apply_to_shards_baseline_filters_and_markers() {
let (_dir, repo, base) = repo_with_added_file();
let config_dir = repo.join(".patchcov");
fs::create_dir(&config_dir).unwrap();
fs::write(config_dir.join("config.yaml"),
"diff:\n path-mappings:\n - from: /ci/package\n to: ''\n - from: module\n to: ''\n").unwrap();
let report = repo.join("head.lcov");
fs::write(
&report,
"SF:/ci/package/b.rs\nDA:1,1\nDA:2,0\nDA:3,1\nend_of_record\nSF:/sdk/runtime.rs\nDA:1,1\nend_of_record\nSF:module/a.rs\nDA:1,0\nend_of_record\n",
)
.unwrap();
let shard = repo.join("shard.lcov");
fs::write(&shard, "SF:module/b.rs\nDA:2,1\nend_of_record\n").unwrap();
let baseline = repo.join("base.lcov");
fs::write(&baseline, "SF:module/a.rs\nDA:1,1\nend_of_record\n").unwrap();
let mut cmd = command(report, &base);
cmd.config_dir = Some(config_dir);
cmd.report.push(shard);
cmd.baseline_report = Some(baseline);
let outcome = cmd.run(Some(&repo)).unwrap();
assert_eq!(outcome.patch_percent, Some(100.0));
assert!(outcome.warnings.is_empty());
cmd.all_files = true;
cmd.output = OutputFormatArg::Json;
let outcome = cmd.run(Some(&repo)).unwrap();
let json: serde_json::Value = serde_json::from_str(&outcome.rendered).unwrap();
let deltas = json["project_delta"]["files"].as_array().unwrap();
assert!(deltas
.iter()
.any(|d| d["path"] == "a.rs" && d["before"] == 100.0));
cmd.output = OutputFormatArg::Markdown;
cmd.ignore_filename_regex = vec!["^b\\.rs$".into()];
let outcome = cmd.run(Some(&repo)).unwrap();
assert_eq!(outcome.patch_percent, None);
assert!(outcome
.rendered
.contains("Excluded by ignore-filename-regex"));
cmd.ignore_filename_regex.clear();
fs::write(
repo.join("b.rs"),
format!("// {} coverage ignore-line\none\n", "patchcov:"),
)
.unwrap();
let error = cmd.run(Some(&repo)).err().unwrap().to_string();
assert!(error.contains("b.rs"), "{error}");
}
#[test]
fn invalid_path_mapping_config_fails_before_report_loading() {
let (_dir, repo, base) = repo_with_added_file();
let dir = repo.join(".patchcov");
fs::create_dir(&dir).unwrap();
fs::write(
dir.join("config.yaml"),
"diff:\n path-mappings:\n - from: src\n to: ../outside\n",
)
.unwrap();
let mut cmd = command(repo.join("missing.lcov"), &base);
cmd.config_dir = Some(dir);
assert!(cmd
.run(Some(&repo))
.err()
.unwrap()
.to_string()
.contains("repo-relative"));
}
#[test]
fn coverage_config_parses_ignore_list() {
let yaml = "diff:\n ignore-filename-regex:\n - 'src/bits/popcount\\.rs'\n - 'src/dsv/simd/.*'\n";
let config: CoverageConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
config.diff.ignore_filename_regex,
vec![
r"src/bits/popcount\.rs".to_string(),
"src/dsv/simd/.*".to_string()
]
);
}
#[test]
fn coverage_config_ignores_unknown_keys() {
let yaml =
"future-top: 1\ndiff:\n future-diff-key: true\n ignore-filename-regex:\n - 'x'\n";
let config: CoverageConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.diff.ignore_filename_regex, vec!["x".to_string()]);
}
#[test]
fn coverage_config_defaults_to_empty() {
let config: CoverageConfig = serde_yaml::from_str("{}").unwrap();
assert!(config.diff.ignore_filename_regex.is_empty());
assert!(config.lint_markers.include.is_empty());
}
#[test]
fn coverage_config_parses_lint_markers_include() {
let yaml =
"lint-markers:\n future-key: 1\n include:\n - 'src/**/*.py'\n - '**/*.rs'\n";
let config: CoverageConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
config.lint_markers.include,
vec!["src/**/*.py".to_string(), "**/*.rs".to_string()]
);
assert!(config.diff.ignore_filename_regex.is_empty());
}
#[test]
fn config_ignore_drops_file_from_both_reports() {
let (_dir, repo, base) = repo_with_added_file();
let head = format!(
"SF:{a}\nDA:1,1\nDA:2,1\nDA:3,1\nDA:4,0\nend_of_record\n\
SF:{b}\nDA:1,1\nDA:2,0\nDA:3,4\nend_of_record\n",
a = repo.join("a.rs").display(),
b = repo.join("b.rs").display(),
);
let report = repo.join("head.lcov");
fs::write(&report, head).unwrap();
let baseline = repo.join("base.lcov");
fs::write(
&baseline,
format!(
"SF:{}\nDA:1,1\nDA:2,1\nDA:3,0\nDA:4,0\nend_of_record\n",
repo.join("a.rs").display()
),
)
.unwrap();
let config_dir = write_coverage_config(&repo, &[r"a\.rs"]);
let mut cmd = command(report, &base);
cmd.baseline_report = Some(baseline);
cmd.all_files = true;
cmd.config_dir = Some(config_dir);
let md = cmd.run(Some(&repo)).unwrap().rendered;
assert!(
!md.contains("`a.rs`"),
"config-ignored a.rs must not appear in the delta table"
);
assert!(md.contains("`b.rs"), "un-ignored added file still reported");
}
#[test]
fn config_and_cli_ignore_lists_union() {
let (_dir, repo, base) = repo_with_added_file();
let head = format!(
"SF:{a}\nDA:1,1\nDA:2,1\nDA:3,1\nDA:4,0\nend_of_record\n\
SF:{b}\nDA:1,1\nDA:2,0\nDA:3,4\nend_of_record\n",
a = repo.join("a.rs").display(),
b = repo.join("b.rs").display(),
);
let report = repo.join("head.lcov");
fs::write(&report, head).unwrap();
let baseline = repo.join("base.lcov");
fs::write(
&baseline,
format!(
"SF:{}\nDA:1,1\nDA:2,1\nDA:3,0\nDA:4,0\nend_of_record\n",
repo.join("a.rs").display()
),
)
.unwrap();
let config_dir = write_coverage_config(&repo, &[r"a\.rs"]);
let mut cmd = command(report, &base);
cmd.baseline_report = Some(baseline);
cmd.all_files = true;
cmd.config_dir = Some(config_dir);
cmd.ignore_filename_regex = vec![r"b\.rs".to_string()];
let outcome = cmd.run(Some(&repo)).unwrap();
assert_eq!(outcome.patch_percent, None);
assert!(!outcome.rendered.contains("`a.rs`"));
}
#[test]
fn empty_config_ignore_is_noop() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_head_lcov(&repo);
let config_dir = write_coverage_config(&repo, &[]);
let mut cmd = command(report, &base);
cmd.config_dir = Some(config_dir);
let outcome = cmd.run(Some(&repo)).unwrap();
assert_eq!(outcome.patch_percent, Some(2.0 / 3.0 * 100.0));
assert!(outcome.rendered.contains("`b.rs:2`"));
}
#[test]
fn missing_config_is_noop() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_head_lcov(&repo);
let empty_dir = repo.join(".patchcov-empty");
fs::create_dir_all(&empty_dir).unwrap();
let mut cmd = command(report, &base);
cmd.config_dir = Some(empty_dir);
let outcome = cmd.run(Some(&repo)).unwrap();
assert_eq!(outcome.patch_percent, Some(2.0 / 3.0 * 100.0));
assert!(outcome.rendered.contains("`b.rs:2`"));
}
#[test]
fn malformed_config_errors() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_head_lcov(&repo);
let config_dir = repo.join(".patchcov");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("config.yaml"),
"diff:\n ignore-filename-regex: 'not-a-list'\n",
)
.unwrap();
let mut cmd = command(report, &base);
cmd.config_dir = Some(config_dir);
assert!(cmd.run(Some(&repo)).is_err());
}
#[test]
fn invalid_config_pattern_errors() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_head_lcov(&repo);
let config_dir = write_coverage_config(&repo, &["(unclosed"]);
let mut cmd = command(report, &base);
cmd.config_dir = Some(config_dir);
assert!(cmd.run(Some(&repo)).is_err());
}
#[test]
fn missing_report_errors() {
let (_dir, repo, base) = repo_with_added_file();
let cmd = command(repo.join("nope.lcov"), &base);
assert!(cmd.run(Some(&repo)).is_err());
}
#[test]
fn render_options_use_flags() {
let (_dir, repo, base) = repo_with_added_file();
let mut cmd = command(repo.join("head.lcov"), &base);
cmd.artifact_url = Some("https://artifact".to_string());
cmd.collapse_ranges = true;
let opts = cmd.render_options();
assert_eq!(opts.artifact_url.as_deref(), Some("https://artifact"));
assert!(opts.collapse_ranges);
}
#[test]
fn execute_succeeds_and_gate_bails() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_head_lcov(&repo);
let mut cmd = command(report.clone(), &base);
cmd.fail_under_patch = Some(10.0);
assert!(cmd.execute(Some(&repo)).is_ok());
let mut cmd = command(report, &base);
cmd.fail_under_patch = Some(99.0);
assert!(cmd.execute(Some(&repo)).is_err());
}
#[test]
fn execute_folds_deprecated_format_flag() {
let (_dir, repo, base) = repo_with_added_file();
let report = write_head_lcov(&repo);
let mut cmd = command(report, &base);
cmd.format = Some(OutputFormatArg::Yaml);
assert!(cmd.execute(Some(&repo)).is_ok());
}
#[test]
fn run_anchors_repo_and_relative_report_to_injected_root() {
let (_dir, repo, base) = repo_with_added_file();
write_head_lcov(&repo); let outcome = command(PathBuf::from("head.lcov"), &base)
.run(Some(&repo))
.unwrap();
assert_eq!(outcome.patch_percent, Some(2.0 / 3.0 * 100.0));
assert!(outcome.rendered.contains("`b.rs:2`"));
}
}