use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use git2::Repository;
use regex::RegexSet;
use crate::claude::context::{load_config_content, resolve_context_dir_at};
use crate::coverage::analysis::{analyze_with_markers, Markers};
use crate::coverage::markers::{self, FileMarkers};
use crate::coverage::merge::check_shard;
use crate::coverage::{
default_base_ref, parse, render, CoverageReport, DiffModel, DiffScope, Format, OutputFormat,
RenderOptions,
};
const COVERAGE_CONFIG_FILE: &str = "coverage.yaml";
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "kebab-case")]
pub enum ReportFormat {
Auto,
Lcov,
LlvmCovJson,
Cobertura,
}
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),
}
}
}
#[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 = "format", hide = true)]
pub format: Option<OutputFormatArg>,
#[arg(long, value_name = "PCT")]
pub fail_under_patch: Option<f64>,
#[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 context_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) -> Result<CoverageReport> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("could not read coverage report {}", path.display()))?;
parse(&content, format.into_format())
.with_context(|| format!("could not parse coverage report {}", path.display()))
}
fn normalise_report(
report: &mut CoverageReport,
strip_prefix: Option<&Path>,
ignore: Option<&RegexSet>,
) {
if let Some(prefix) = strip_prefix {
report.strip_prefix(prefix);
}
if let Some(ignore) = ignore {
report.retain_paths(|path| !ignore.is_match(path));
}
}
#[derive(Debug, Default, serde::Deserialize)]
struct CoverageConfig {
#[serde(default)]
diff: CoverageDiffConfig,
}
#[derive(Debug, Default, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
struct CoverageDiffConfig {
#[serde(default)]
ignore_filename_regex: Vec<String>,
}
pub struct DiffOutcome {
pub rendered: String,
pub patch_percent: Option<f64>,
pub below_gate: bool,
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"
),
});
}
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_ignore = self.load_config_ignore(&repo_path)?;
let ignore = self.compile_ignore(&config_ignore)?;
let mut warnings = Vec::new();
let head = self.load_head(
strip_prefix.as_deref(),
ignore.as_ref(),
&repo_path,
&mut warnings,
)?;
let baseline = match &self.baseline_report {
Some(path) => Some(self.load_report(
path,
self.baseline_report_format,
strip_prefix.as_deref(),
ignore.as_ref(),
&repo_path,
)?),
None => None,
};
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 diff = DiffModel::between(&repo, &base_ref, self.head_ref.as_deref())?;
let scope = if self.all_files {
DiffScope::All
} else {
DiffScope::DiffOnly
};
let result = analyze_with_markers(&head, &diff, baseline.as_ref(), scope, &markers);
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,
line_percent,
below_line_gate,
warnings,
})
}
fn load_head(
&self,
strip_prefix: Option<&Path>,
ignore: Option<&RegexSet>,
repo_root: &Path,
warnings: &mut Vec<String>,
) -> 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)?;
if sharded {
check_shard(&path.display().to_string(), &report, strip_prefix, warnings)?;
}
normalise_report(&mut report, strip_prefix, ignore);
merged.merge(report);
}
Ok(merged)
}
fn load_report(
&self,
path: &Path,
format: ReportFormat,
strip_prefix: Option<&Path>,
ignore: Option<&RegexSet>,
repo_root: &Path,
) -> Result<CoverageReport> {
let mut report = read_report(&anchor(path, repo_root), format)?;
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 load_config_ignore(&self, repo_root: &Path) -> Result<Vec<String>> {
let context_dir = resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
let Some(content) = load_config_content(&context_dir, COVERAGE_CONFIG_FILE)? else {
return Ok(Vec::new());
};
let config: CoverageConfig = serde_yaml::from_str(&content).with_context(|| {
format!(
"could not parse coverage config {}/{COVERAGE_CONFIG_FILE}",
context_dir.display()
)
})?;
Ok(config.diff.ignore_filename_regex)
}
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 coverage.yaml)",
)?;
Ok(Some(set))
}
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,
}
}
}
#[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,
format: None,
fail_under_patch: None,
fail_under_lines: None,
strip_prefix: None,
ignore_filename_regex: Vec::new(),
context_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(".omni-dev");
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("coverage.yaml"), body).unwrap();
config_dir
}
#[test]
fn report_format_into_format() {
assert_eq!(ReportFormat::Auto.into_format(), None);
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)
);
}
#[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"));
}
}
fn marked(kind: &str, reason: &str) -> String {
format!(
"// {} {kind} reason=\"{reason}\"",
crate::coverage::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::coverage::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::coverage::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 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 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());
}
#[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.context_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.context_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.context_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(".omni-dev-empty");
fs::create_dir_all(&empty_dir).unwrap();
let mut cmd = command(report, &base);
cmd.context_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(".omni-dev");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("coverage.yaml"),
"diff:\n ignore-filename-regex: 'not-a-list'\n",
)
.unwrap();
let mut cmd = command(report, &base);
cmd.context_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.context_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`"));
}
}