1use std::collections::{BTreeMap, BTreeSet};
6use std::path::{Path, PathBuf};
7use std::process::Command;
8use std::sync::atomic::{AtomicU64, Ordering};
9
10use anyhow::{bail, Context, Result};
11use serde::Deserialize;
12
13const TEST_OMIT: &str = "*_test.py";
15
16const SUPPORT_OMIT: &str = "*conftest.py";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct Thresholds {
22 pub fail_under: u8,
24 pub branch: bool,
26}
27
28#[derive(Debug, Clone, Deserialize)]
31pub struct CoverageReport {
32 pub totals: Totals,
33 #[serde(default)]
36 pub files: BTreeMap<String, FileCoverage>,
37}
38
39#[derive(Debug, Clone, Default, Deserialize)]
42pub struct FileCoverage {
43 #[serde(default)]
45 pub executed_lines: Vec<u64>,
46 #[serde(default)]
48 pub missing_lines: Vec<u64>,
49 #[serde(default)]
51 pub excluded_lines: Vec<u64>,
52 #[serde(default)]
55 pub missing_branches: Vec<Vec<i64>>,
56 #[serde(default)]
59 pub executed_branches: Vec<Vec<i64>>,
60}
61
62#[derive(Debug, Clone, Deserialize)]
64pub struct Totals {
65 pub percent_covered: f64,
67 #[serde(default)]
69 pub num_branches: u64,
70}
71
72#[derive(Debug, Clone, PartialEq)]
74pub enum Outcome {
75 Pass,
76 Fail(String),
78}
79
80pub fn parse_report(json: &str) -> Result<CoverageReport> {
82 serde_json::from_str(json).context("parsing coverage.py JSON report")
83}
84
85pub fn evaluate(report: &CoverageReport, thresholds: Thresholds) -> Outcome {
88 if thresholds.branch && report.totals.num_branches == 0 {
89 return Outcome::Fail(
90 "branch coverage is required but the report measured no branches".to_string(),
91 );
92 }
93 let actual = report.totals.percent_covered;
94 let required = f64::from(thresholds.fail_under);
95 if actual + 1e-9 >= required {
97 Outcome::Pass
98 } else {
99 Outcome::Fail(format!(
100 "coverage {actual:.2}% is below the required {}%",
101 thresholds.fail_under
102 ))
103 }
104}
105
106pub fn measure(root: &Path, thresholds: Thresholds, omit: &[String]) -> Result<Outcome> {
110 let report = run_coverage(root, omit, false)?;
111 Ok(evaluate(&report, thresholds))
112}
113
114pub fn measure_patch_report(root: &Path, omit: &[String]) -> Result<CoverageReport> {
118 run_coverage(root, omit, true)
119}
120
121pub fn measure_report(root: &Path, omit: &[String]) -> Result<CoverageReport> {
125 run_coverage(root, omit, false)
126}
127
128struct DataFile(PathBuf);
131
132impl DataFile {
133 fn new() -> Self {
134 static COUNTER: AtomicU64 = AtomicU64::new(0);
135 let name = format!(
136 "testing-conventions-{}-{}.coverage",
137 std::process::id(),
138 COUNTER.fetch_add(1, Ordering::Relaxed),
139 );
140 DataFile(std::env::temp_dir().join(name))
141 }
142}
143
144impl Drop for DataFile {
145 fn drop(&mut self) {
146 let _ = std::fs::remove_file(&self.0);
147 }
148}
149
150fn run_coverage(root: &Path, omit: &[String], include_all_sources: bool) -> Result<CoverageReport> {
154 let data = DataFile::new();
155 let omit = build_omit(omit);
156
157 let mut command = Command::new("coverage");
159 command
160 .current_dir(root)
161 .args(["run", "--branch"])
162 .arg(format!("--omit={omit}"));
163 if include_all_sources {
164 command.arg("--source=.");
165 }
166 let run = command
167 .args(["-m", "pytest", "-q", "-p", "no:cacheprovider", "."])
168 .env("COVERAGE_FILE", &data.0)
169 .env("PYTHONDONTWRITEBYTECODE", "1")
170 .output()
171 .context("running `coverage run -m pytest` (is coverage.py installed?)")?;
172 if !run.status.success() {
173 bail!(
174 "the unit suite did not run cleanly under coverage in `{}`:\n{}{}",
175 root.display(),
176 String::from_utf8_lossy(&run.stdout),
177 String::from_utf8_lossy(&run.stderr),
178 );
179 }
180
181 let json = Command::new("coverage")
182 .current_dir(root)
183 .args(["json", "-o", "-"])
184 .env("COVERAGE_FILE", &data.0)
185 .output()
186 .context("running `coverage json`")?;
187 if !json.status.success() {
188 bail!(
189 "`coverage json` failed:\n{}",
190 String::from_utf8_lossy(&json.stderr),
191 );
192 }
193
194 parse_report(&String::from_utf8_lossy(&json.stdout))
195}
196
197fn build_omit(omit: &[String]) -> String {
201 [TEST_OMIT.to_string(), SUPPORT_OMIT.to_string()]
202 .into_iter()
203 .chain(omit.iter().cloned())
204 .collect::<Vec<_>>()
205 .join(",")
206}
207
208const TS_INCLUDE: &str = "**/*.{ts,tsx,mts,cts}";
211
212fn vitest_default_excludes(root: &Path) -> Result<Vec<String>> {
216 let run = Command::new("node")
217 .current_dir(root)
218 .args([
219 "-e",
220 "process.stdout.write(JSON.stringify(require('vitest/config').coverageConfigDefaults.exclude))",
221 ])
222 .output()
223 .context("resolving vitest's default coverage excludes via node")?;
224 if !run.status.success() {
225 bail!(
226 "could not resolve vitest's default coverage excludes in `{}`. The rule runs the \
227 project's own vitest via `npx --no-install` and never downloads it, so `vitest` \
228 must be installed in the project. node output:\n{}{}",
229 root.display(),
230 String::from_utf8_lossy(&run.stdout),
231 String::from_utf8_lossy(&run.stderr),
232 );
233 }
234 let excludes: Vec<String> = serde_json::from_slice(&run.stdout).with_context(|| {
235 format!(
236 "vitest's default coverage excludes were not a JSON string array — got: {}",
237 String::from_utf8_lossy(&run.stdout)
238 )
239 })?;
240 Ok(excludes.into_iter().filter(|p| !p.contains('\0')).collect())
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub struct TypeScriptThresholds {
248 pub lines: u8,
249 pub branches: u8,
250 pub functions: u8,
251 pub statements: u8,
252}
253
254#[derive(Debug, Clone, Copy, Deserialize)]
256pub struct VitestReport {
257 pub total: VitestTotals,
258}
259
260#[derive(Debug, Clone, Copy, Deserialize)]
262pub struct VitestTotals {
263 pub lines: VitestMetric,
264 pub branches: VitestMetric,
265 pub functions: VitestMetric,
266 pub statements: VitestMetric,
267}
268
269#[derive(Debug, Clone, Copy, Deserialize)]
271pub struct VitestMetric {
272 #[serde(deserialize_with = "deserialize_pct")]
275 pub pct: Option<f64>,
276 pub total: u64,
278}
279
280fn deserialize_pct<'de, D>(deserializer: D) -> std::result::Result<Option<f64>, D::Error>
283where
284 D: serde::Deserializer<'de>,
285{
286 struct PctVisitor;
287 impl serde::de::Visitor<'_> for PctVisitor {
288 type Value = Option<f64>;
289
290 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
291 f.write_str("a coverage percent number or the string \"Unknown\"")
292 }
293
294 fn visit_f64<E>(self, value: f64) -> std::result::Result<Self::Value, E> {
295 Ok(Some(value))
296 }
297
298 fn visit_u64<E>(self, value: u64) -> std::result::Result<Self::Value, E> {
300 Ok(Some(value as f64))
301 }
302
303 fn visit_str<E>(self, _value: &str) -> std::result::Result<Self::Value, E> {
305 Ok(None)
306 }
307 }
308 deserializer.deserialize_any(PctVisitor)
309}
310
311pub fn parse_vitest_report(json: &str) -> Result<VitestReport> {
313 serde_json::from_str(json).context("parsing vitest coverage-summary JSON report")
314}
315
316pub fn evaluate_typescript(report: &VitestReport, thresholds: TypeScriptThresholds) -> Outcome {
320 let total = &report.total;
321 if total.lines.total == 0 {
323 return Outcome::Fail(
324 "the unit suite measured no code — check the path and that the suite runs".to_string(),
325 );
326 }
327 let checks = [
328 ("lines", total.lines, thresholds.lines),
329 ("branches", total.branches, thresholds.branches),
330 ("functions", total.functions, thresholds.functions),
331 ("statements", total.statements, thresholds.statements),
332 ];
333 let mut shortfalls = Vec::new();
334 for (name, metric, required) in checks {
335 let actual = metric.pct.unwrap_or(100.0);
337 if actual + 1e-9 < f64::from(required) {
339 shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
340 }
341 }
342 if shortfalls.is_empty() {
343 Outcome::Pass
344 } else {
345 Outcome::Fail(format!(
346 "coverage below thresholds: {}",
347 shortfalls.join(", ")
348 ))
349 }
350}
351
352pub fn measure_typescript(
356 root: &Path,
357 thresholds: TypeScriptThresholds,
358 exclude: &[String],
359) -> Result<Outcome> {
360 let report = run_vitest(root, exclude)?;
361 Ok(evaluate_typescript(&report, thresholds))
362}
363
364struct ReportDir(PathBuf);
367
368impl ReportDir {
369 fn new() -> Self {
370 static COUNTER: AtomicU64 = AtomicU64::new(0);
371 let name = format!(
372 "testing-conventions-vitest-{}-{}",
373 std::process::id(),
374 COUNTER.fetch_add(1, Ordering::Relaxed),
375 );
376 ReportDir(std::env::temp_dir().join(name))
377 }
378}
379
380impl Drop for ReportDir {
381 fn drop(&mut self) {
382 let _ = std::fs::remove_dir_all(&self.0);
383 }
384}
385
386fn run_vitest(root: &Path, exclude: &[String]) -> Result<VitestReport> {
388 let json = run_vitest_coverage(root, exclude, "json-summary", "coverage-summary.json")?;
389 parse_vitest_report(&json)
390}
391
392fn run_vitest_coverage(
396 root: &Path,
397 exclude: &[String],
398 reporter: &str,
399 report_file: &str,
400) -> Result<String> {
401 let reports = ReportDir::new();
402
403 let mut command = Command::new("npx");
404 command
405 .current_dir(root)
406 .args(["--no-install", "vitest", "run", "--no-cache"])
409 .args(["--coverage.enabled", "--coverage.provider=v8"])
410 .arg(format!("--coverage.reporter={reporter}"))
411 .arg("--coverage.all=true")
412 .arg(format!(
413 "--coverage.reportsDirectory={}",
414 reports.0.display()
415 ))
416 .arg(format!("--coverage.include={TS_INCLUDE}"))
417 .args([
420 "--coverage.thresholds.lines=0",
421 "--coverage.thresholds.branches=0",
422 "--coverage.thresholds.functions=0",
423 "--coverage.thresholds.statements=0",
424 "--coverage.thresholds.autoUpdate=false",
425 ]);
426 for path in vitest_default_excludes(root)?.iter().chain(exclude) {
427 command.arg(format!("--coverage.exclude={path}"));
428 }
429 let run = command
431 .env("CI", "1")
432 .output()
433 .context("running `npx --no-install vitest run --coverage`")?;
434 if !run.status.success() {
435 bail!(
436 "the unit suite did not run cleanly under vitest in `{}`. The rule runs the \
437 project's own vitest via `npx --no-install` and never downloads it, so `vitest` \
438 and `@vitest/coverage-v8` must be installed in the project. vitest output:\n{}{}",
439 root.display(),
440 String::from_utf8_lossy(&run.stdout),
441 String::from_utf8_lossy(&run.stderr),
442 );
443 }
444
445 let path = reports.0.join(report_file);
446 std::fs::read_to_string(&path).with_context(|| {
447 format!(
448 "reading vitest coverage report `{}` (did the run produce a {reporter} report?)",
449 path.display()
450 )
451 })
452}
453
454#[derive(Debug, Clone, Deserialize)]
457struct IstanbulFile {
458 #[serde(rename = "statementMap", default)]
460 statement_map: BTreeMap<String, IstanbulSpan>,
461 #[serde(default)]
463 s: BTreeMap<String, u64>,
464 #[serde(rename = "branchMap", default)]
466 branch_map: BTreeMap<String, IstanbulBranch>,
467 #[serde(default)]
469 b: BTreeMap<String, Vec<u64>>,
470 #[serde(rename = "fnMap", default)]
472 fn_map: BTreeMap<String, IstanbulFn>,
473 #[serde(default)]
475 f: BTreeMap<String, u64>,
476}
477
478#[derive(Debug, Clone, Deserialize)]
480struct IstanbulSpan {
481 start: IstanbulPos,
482 end: IstanbulPos,
483}
484
485#[derive(Debug, Clone, Deserialize)]
487struct IstanbulPos {
488 line: u64,
489}
490
491#[derive(Debug, Clone, Deserialize)]
493struct IstanbulBranch {
494 loc: IstanbulSpan,
495}
496
497#[derive(Debug, Clone, Deserialize)]
500struct IstanbulFn {
501 decl: IstanbulSpan,
502}
503
504#[derive(Debug, Clone, Default)]
507pub struct TsPatchCoverage {
508 pub statements: Vec<(u64, u64, bool)>,
511 pub branch_arms: Vec<(u64, bool)>,
514 pub functions: Vec<(u64, bool)>,
517}
518
519pub fn measure_patch_typescript_detail(
523 root: &Path,
524 exclude: &[String],
525) -> Result<BTreeMap<String, TsPatchCoverage>> {
526 let json = run_vitest_coverage(root, exclude, "json", "coverage-final.json")?;
527 istanbul_patch_detail(&json)
528}
529
530fn istanbul_patch_detail(json: &str) -> Result<BTreeMap<String, TsPatchCoverage>> {
533 let files: BTreeMap<String, IstanbulFile> = serde_json::from_str(json)
534 .context("parsing vitest coverage-final (Istanbul) JSON report")?;
535 let mut out = BTreeMap::new();
536 for (path, file) in files {
537 let mut detail = TsPatchCoverage::default();
538 for (id, span) in &file.statement_map {
539 let covered = file.s.get(id).is_some_and(|&count| count > 0);
540 detail
541 .statements
542 .push((span.start.line, span.end.line, covered));
543 }
544 for (id, branch) in &file.branch_map {
547 let line = branch.loc.start.line;
548 if let Some(counts) = file.b.get(id) {
549 for &count in counts {
550 detail.branch_arms.push((line, count > 0));
551 }
552 }
553 }
554 for (id, function) in &file.fn_map {
555 let covered = file.f.get(id).is_some_and(|&count| count > 0);
556 detail.functions.push((function.decl.start.line, covered));
557 }
558 out.insert(path, detail);
559 }
560 Ok(out)
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq)]
567pub struct RustThresholds {
568 pub regions: Option<u8>,
569 pub lines: u8,
570 pub functions: Option<u8>,
571 pub branch: Option<u8>,
572}
573
574#[derive(Debug, Clone, Deserialize)]
577pub struct LlvmCovReport {
578 pub data: Vec<LlvmCovData>,
579}
580
581#[derive(Debug, Clone, Copy, Deserialize)]
583pub struct LlvmCovData {
584 pub totals: LlvmCovTotals,
585}
586
587#[derive(Debug, Clone, Copy, Deserialize)]
590pub struct LlvmCovTotals {
591 pub regions: LlvmCovMetric,
592 pub lines: LlvmCovMetric,
593 pub functions: LlvmCovMetric,
594 #[serde(default)]
595 pub branches: Option<LlvmCovMetric>,
596}
597
598#[derive(Debug, Clone, Copy, Deserialize)]
600pub struct LlvmCovMetric {
601 pub count: u64,
603 pub covered: u64,
604 pub percent: f64,
605}
606
607pub fn parse_llvm_cov_report(json: &str) -> Result<LlvmCovReport> {
609 serde_json::from_str(json).context("parsing cargo llvm-cov JSON report")
610}
611
612pub fn evaluate_rust(report: &LlvmCovReport, thresholds: RustThresholds) -> Outcome {
615 let Some(totals) = report.data.first().map(|entry| &entry.totals) else {
616 return Outcome::Fail("the cargo llvm-cov report contained no data".to_string());
617 };
618 if totals.regions.count == 0 {
620 return Outcome::Fail(
621 "the unit suite measured no code — check the path and that the suite runs".to_string(),
622 );
623 }
624 let mut checks: Vec<(&str, f64, u8)> = Vec::new();
626 if let Some(regions) = thresholds.regions {
627 checks.push(("regions", totals.regions.percent, regions));
628 }
629 checks.push(("lines", totals.lines.percent, thresholds.lines));
630 if let Some(functions) = thresholds.functions {
631 checks.push(("functions", totals.functions.percent, functions));
632 }
633 if let Some(branch) = thresholds.branch {
634 if let Some(branches) = totals.branches.filter(|metric| metric.count > 0) {
637 checks.push(("branches", branches.percent, branch));
638 }
639 }
640 let mut shortfalls = Vec::new();
641 for (name, actual, required) in checks {
642 if actual + 1e-9 < f64::from(required) {
644 shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
645 }
646 }
647 if shortfalls.is_empty() {
648 Outcome::Pass
649 } else {
650 Outcome::Fail(format!(
651 "coverage below thresholds: {}",
652 shortfalls.join(", ")
653 ))
654 }
655}
656
657pub fn measure_rust(
661 root: &Path,
662 thresholds: RustThresholds,
663 ignore: &[String],
664 features: &[String],
665) -> Result<Outcome> {
666 let report = run_llvm_cov(root, ignore, features, thresholds.branch.is_some())?;
667 Ok(evaluate_rust(&report, thresholds))
668}
669
670struct TargetDir(PathBuf);
673
674impl TargetDir {
675 fn new() -> Self {
676 static COUNTER: AtomicU64 = AtomicU64::new(0);
677 let name = format!(
678 "testing-conventions-llvm-cov-{}-{}",
679 std::process::id(),
680 COUNTER.fetch_add(1, Ordering::Relaxed),
681 );
682 TargetDir(std::env::temp_dir().join(name))
683 }
684}
685
686impl Drop for TargetDir {
687 fn drop(&mut self) {
688 let _ = std::fs::remove_dir_all(&self.0);
689 }
690}
691
692fn run_llvm_cov(
695 root: &Path,
696 ignore: &[String],
697 features: &[String],
698 branch: bool,
699) -> Result<LlvmCovReport> {
700 parse_llvm_cov_report(&run_cargo_llvm_cov(
701 root,
702 ignore,
703 &["--json", "--summary-only"],
704 features,
705 branch,
706 )?)
707}
708
709fn run_cargo_llvm_cov(
713 root: &Path,
714 ignore: &[String],
715 format: &[&str],
716 features: &[String],
717 branch: bool,
718) -> Result<String> {
719 let target = TargetDir::new();
720
721 let mut command = Command::new("cargo");
722 command
723 .current_dir(root)
724 .arg("llvm-cov")
725 .arg("--lib")
728 .args(format)
729 .env("CARGO_TARGET_DIR", &target.0);
730 if !features.is_empty() {
731 command.arg("--features").arg(features.join(","));
732 }
733 if branch {
734 command.arg("--branch");
736 }
737 if let Some(regex) = ignore_filename_regex(root, ignore) {
738 command.arg("--ignore-filename-regex").arg(regex);
739 }
740 for var in [
744 "RUSTFLAGS",
745 "CARGO_ENCODED_RUSTFLAGS",
746 "RUSTDOCFLAGS",
747 "CARGO_ENCODED_RUSTDOCFLAGS",
748 "LLVM_PROFILE_FILE",
749 "CARGO_LLVM_COV",
750 "CARGO_LLVM_COV_SHOW_ENV",
751 "CARGO_LLVM_COV_TARGET_DIR",
752 "CARGO_LLVM_COV_BUILD_DIR",
753 "RUSTC_WRAPPER",
754 "RUSTC_WORKSPACE_WRAPPER",
755 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
756 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
757 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
758 "RUSTUP_TOOLCHAIN",
762 "CARGO",
763 "RUSTC",
764 ] {
765 command.env_remove(var);
766 }
767 let output = command
768 .output()
769 .context("running `cargo llvm-cov` (is cargo-llvm-cov installed?)")?;
770 if !output.status.success() {
771 let hint = if branch {
772 "\n(the [rust].coverage `branch` floor runs with --branch, which requires a \
773 nightly toolchain — pin one in the crate's rust-toolchain.toml with \
774 llvm-tools-preview, or set a rustup directory override)"
775 } else {
776 ""
777 };
778 bail!(
779 "the unit suite did not run cleanly under cargo llvm-cov in `{}`:{hint}\n{}{}",
780 root.display(),
781 String::from_utf8_lossy(&output.stdout),
782 String::from_utf8_lossy(&output.stderr),
783 );
784 }
785 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
786}
787
788#[derive(Debug, Clone, Default)]
791pub struct RustPatchCoverage {
792 pub regions: Vec<(u64, u64, bool)>,
795}
796
797#[derive(Debug, Clone, Deserialize)]
800struct LlvmCovExport {
801 data: Vec<LlvmCovExportData>,
802}
803
804#[derive(Debug, Clone, Deserialize)]
808struct LlvmCovExportData {
809 files: Vec<LlvmCovExportFile>,
810 functions: Vec<LlvmCovFunction>,
811}
812
813#[derive(Debug, Clone, Deserialize)]
816struct LlvmCovExportFile {
817 filename: String,
818}
819
820#[derive(Debug, Clone, Deserialize)]
824struct LlvmCovFunction {
825 filenames: Vec<String>,
826 regions: Vec<Vec<i64>>,
827}
828
829pub fn measure_patch_rust_detail(
833 root: &Path,
834 ignore: &[String],
835 features: &[String],
836) -> Result<BTreeMap<String, RustPatchCoverage>> {
837 llvm_cov_patch_detail(&run_cargo_llvm_cov(
839 root,
840 ignore,
841 &["--json"],
842 features,
843 false,
844 )?)
845}
846
847fn llvm_cov_patch_detail(json: &str) -> Result<BTreeMap<String, RustPatchCoverage>> {
851 let export: LlvmCovExport =
852 serde_json::from_str(json).context("parsing cargo llvm-cov JSON export")?;
853 let mut out: BTreeMap<String, RustPatchCoverage> = BTreeMap::new();
854 for data in &export.data {
855 let measured: BTreeSet<&str> = data.files.iter().map(|f| f.filename.as_str()).collect();
856 for function in &data.functions {
857 for region in &function.regions {
858 if region.len() < 8 {
859 continue;
860 }
861 if region[7] != 0 {
863 continue;
864 }
865 let file_id = region[5];
866 let Ok(file_id) = usize::try_from(file_id) else {
867 continue;
868 };
869 let Some(file) = function.filenames.get(file_id) else {
870 continue;
871 };
872 if !measured.contains(file.as_str()) {
874 continue;
875 }
876 let start = region[0].max(0) as u64;
877 let end = region[2].max(0) as u64;
878 let covered = region[4] > 0;
879 out.entry(file.clone())
880 .or_default()
881 .regions
882 .push((start, end, covered));
883 }
884 }
885 }
886 Ok(out)
887}
888
889fn ignore_filename_regex(root: &Path, ignore: &[String]) -> Option<String> {
893 if ignore.is_empty() {
894 return None;
895 }
896 Some(
897 ignore
898 .iter()
899 .map(|rel| {
900 let full = root.join(rel);
903 let full = full.canonicalize().unwrap_or(full);
904 format!("{}$", regex_escape(&full.to_string_lossy()))
905 })
906 .collect::<Vec<_>>()
907 .join("|"),
908 )
909}
910
911fn regex_escape(s: &str) -> String {
913 const META: &str = r"\.+*?()|[]{}^$";
914 let mut out = String::with_capacity(s.len());
915 for c in s.chars() {
916 if META.contains(c) {
917 out.push('\\');
918 }
919 out.push(c);
920 }
921 out
922}
923
924#[cfg(test)]
925mod tests {
926 use super::*;
927
928 fn report(percent_covered: f64, num_branches: u64) -> CoverageReport {
929 CoverageReport {
930 totals: Totals {
931 percent_covered,
932 num_branches,
933 },
934 files: BTreeMap::new(),
935 }
936 }
937
938 #[test]
939 fn passes_when_total_meets_the_floor() {
940 assert_eq!(
941 evaluate(
942 &report(100.0, 12),
943 Thresholds {
944 fail_under: 100,
945 branch: true
946 }
947 ),
948 Outcome::Pass
949 );
950 }
951
952 #[test]
953 fn fails_when_total_is_below_the_floor() {
954 assert!(matches!(
955 evaluate(
956 &report(80.0, 12),
957 Thresholds {
958 fail_under: 100,
959 branch: true
960 }
961 ),
962 Outcome::Fail(_)
963 ));
964 }
965
966 #[test]
967 fn fails_when_branch_required_but_unmeasured() {
968 assert!(matches!(
969 evaluate(
970 &report(100.0, 0),
971 Thresholds {
972 fail_under: 90,
973 branch: true
974 }
975 ),
976 Outcome::Fail(_)
977 ));
978 }
979
980 #[test]
981 fn parses_a_coverage_py_report() {
982 let json = r#"{"totals":{"percent_covered":91.5,"num_branches":8,"covered_lines":91}}"#;
983 let report = parse_report(json).expect("valid coverage.py json");
984 assert_eq!(report.totals.percent_covered, 91.5);
985 assert_eq!(report.totals.num_branches, 8);
986 }
987
988 #[test]
989 fn parses_the_per_file_block_for_patch_coverage() {
990 let json = r#"{
991 "files": {
992 "widget.py": {
993 "executed_lines": [1, 2, 3, 4, 6],
994 "summary": {"percent_covered": 85.0},
995 "missing_lines": [5],
996 "excluded_lines": [],
997 "missing_branches": [[4, 5]]
998 }
999 },
1000 "totals": {"percent_covered": 85.0, "num_branches": 4}
1001 }"#;
1002 let report = parse_report(json).expect("valid coverage.py json with files");
1003 let widget = report.files.get("widget.py").expect("widget.py is present");
1004 assert_eq!(widget.missing_lines, vec![5]);
1005 assert_eq!(widget.missing_branches, vec![vec![4, 5]]);
1006 assert_eq!(report.totals.percent_covered, 85.0);
1007 }
1008
1009 #[test]
1010 fn a_report_without_a_files_block_parses_with_an_empty_map() {
1011 let report = parse_report(r#"{"totals":{"percent_covered":100.0,"num_branches":2}}"#)
1012 .expect("valid coverage.py json");
1013 assert!(report.files.is_empty());
1014 }
1015
1016 #[test]
1017 fn omit_is_the_test_and_support_globs_when_nothing_is_exempt() {
1018 assert_eq!(build_omit(&[]), "*_test.py,*conftest.py");
1019 }
1020
1021 #[test]
1022 fn omit_folds_in_the_exempt_paths_after_the_test_glob() {
1023 let exempt = vec!["pkg/gen.py".to_string(), "shim.py".to_string()];
1024 assert_eq!(
1025 build_omit(&exempt),
1026 "*_test.py,*conftest.py,pkg/gen.py,shim.py"
1027 );
1028 }
1029
1030 fn metric(pct: f64) -> VitestMetric {
1031 VitestMetric {
1032 pct: Some(pct),
1033 total: 10,
1034 }
1035 }
1036
1037 fn ts_report(lines: f64, branches: f64, functions: f64, statements: f64) -> VitestReport {
1038 VitestReport {
1039 total: VitestTotals {
1040 lines: metric(lines),
1041 branches: metric(branches),
1042 functions: metric(functions),
1043 statements: metric(statements),
1044 },
1045 }
1046 }
1047
1048 const TS_FULL: TypeScriptThresholds = TypeScriptThresholds {
1049 lines: 100,
1050 branches: 100,
1051 functions: 100,
1052 statements: 100,
1053 };
1054 const TS_MID: TypeScriptThresholds = TypeScriptThresholds {
1055 lines: 80,
1056 branches: 75,
1057 functions: 80,
1058 statements: 80,
1059 };
1060
1061 #[test]
1062 fn typescript_passes_when_every_metric_meets_its_floor() {
1063 assert_eq!(
1064 evaluate_typescript(&ts_report(100.0, 100.0, 100.0, 100.0), TS_FULL),
1065 Outcome::Pass
1066 );
1067 }
1068
1069 #[test]
1070 fn typescript_fails_on_the_one_metric_below_its_floor() {
1071 let outcome = evaluate_typescript(&ts_report(100.0, 66.66, 100.0, 100.0), TS_MID);
1072 assert!(
1073 matches!(&outcome, Outcome::Fail(message) if message.contains("branches") && !message.contains("lines")),
1074 "got: {outcome:?}"
1075 );
1076 }
1077
1078 #[test]
1079 fn typescript_fail_message_names_every_metric_below() {
1080 let outcome = evaluate_typescript(&ts_report(70.0, 70.0, 70.0, 70.0), TS_MID);
1081 assert!(
1082 matches!(&outcome, Outcome::Fail(message)
1083 if message.contains("lines")
1084 && message.contains("branches")
1085 && message.contains("functions")
1086 && message.contains("statements")),
1087 "got: {outcome:?}"
1088 );
1089 }
1090
1091 #[test]
1092 fn typescript_tolerates_float_noise_at_the_floor() {
1093 assert_eq!(
1094 evaluate_typescript(&ts_report(99.999_999_999, 100.0, 100.0, 100.0), TS_FULL),
1095 Outcome::Pass
1096 );
1097 }
1098
1099 #[test]
1100 fn typescript_empty_denominator_metric_is_vacuously_satisfied() {
1101 let report = VitestReport {
1102 total: VitestTotals {
1103 lines: metric(100.0),
1104 branches: VitestMetric {
1105 pct: None,
1106 total: 0,
1107 },
1108 functions: metric(100.0),
1109 statements: metric(100.0),
1110 },
1111 };
1112 assert_eq!(evaluate_typescript(&report, TS_FULL), Outcome::Pass);
1113 }
1114
1115 #[test]
1116 fn typescript_fails_a_vacuous_run_that_measured_no_code() {
1117 let nothing = VitestMetric {
1118 pct: None,
1119 total: 0,
1120 };
1121 let report = VitestReport {
1122 total: VitestTotals {
1123 lines: nothing,
1124 branches: nothing,
1125 functions: nothing,
1126 statements: nothing,
1127 },
1128 };
1129 let outcome = evaluate_typescript(&report, TS_MID);
1130 assert!(
1131 matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1132 "got: {outcome:?}"
1133 );
1134 }
1135
1136 #[test]
1137 fn parses_a_vitest_summary_report() {
1138 let json = r#"{
1139 "total": {
1140 "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1141 "statements": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1142 "functions": {"total": 2, "covered": 2, "skipped": 0, "pct": 100},
1143 "branches": {"total": 3, "covered": 2, "skipped": 0, "pct": 66.66},
1144 "branchesTrue": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1145 },
1146 "/abs/widget.ts": {
1147 "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80}
1148 }
1149 }"#;
1150 let report = parse_vitest_report(json).expect("valid vitest json-summary");
1151 assert_eq!(report.total.lines.pct, Some(80.0));
1153 assert_eq!(report.total.branches.pct, Some(66.66));
1154 assert_eq!(report.total.functions.total, 2);
1155 }
1156
1157 #[test]
1158 fn parses_an_unknown_pct_as_unmeasured() {
1159 let json = r#"{"total": {
1160 "lines": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1161 "statements": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1162 "functions": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1163 "branches": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1164 }}"#;
1165 let report = parse_vitest_report(json).expect("valid vitest json-summary");
1166 assert_eq!(report.total.lines.pct, None);
1167 assert_eq!(report.total.lines.total, 0);
1168 }
1169
1170 #[test]
1171 fn a_pct_that_is_neither_number_nor_string_is_a_parse_error() {
1172 let json = r#"{"total":{
1173 "lines": {"total": 1, "covered": 1, "skipped": 0, "pct": true},
1174 "statements": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1175 "functions": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1176 "branches": {"total": 1, "covered": 1, "skipped": 0, "pct": 100}
1177 }}"#;
1178 assert!(parse_vitest_report(json).is_err());
1179 }
1180
1181 fn rust_metric(percent: f64) -> LlvmCovMetric {
1182 LlvmCovMetric {
1183 count: 10,
1184 covered: 10,
1185 percent,
1186 }
1187 }
1188
1189 fn rust_report(regions: f64, lines: f64) -> LlvmCovReport {
1190 LlvmCovReport {
1191 data: vec![LlvmCovData {
1192 totals: LlvmCovTotals {
1193 regions: rust_metric(regions),
1194 lines: rust_metric(lines),
1195 functions: rust_metric(lines),
1196 branches: None,
1197 },
1198 }],
1199 }
1200 }
1201
1202 fn rust_report_full(
1205 regions: f64,
1206 lines: f64,
1207 functions: f64,
1208 branches: (u64, f64),
1209 ) -> LlvmCovReport {
1210 let (count, percent) = branches;
1211 LlvmCovReport {
1212 data: vec![LlvmCovData {
1213 totals: LlvmCovTotals {
1214 regions: rust_metric(regions),
1215 lines: rust_metric(lines),
1216 functions: rust_metric(functions),
1217 branches: Some(LlvmCovMetric {
1218 count,
1219 covered: count,
1220 percent,
1221 }),
1222 },
1223 }],
1224 }
1225 }
1226
1227 const RUST_FULL: RustThresholds = RustThresholds {
1228 regions: Some(100),
1229 lines: 100,
1230 functions: None,
1231 branch: None,
1232 };
1233 const RUST_MID: RustThresholds = RustThresholds {
1234 regions: Some(80),
1235 lines: 85,
1236 functions: None,
1237 branch: None,
1238 };
1239
1240 #[test]
1241 fn rust_functions_floor_fails_below_and_passes_at_its_bar() {
1242 let report = rust_report_full(100.0, 100.0, 66.67, (0, 0.0));
1243 let floor = |functions| RustThresholds {
1244 regions: None,
1245 lines: 50,
1246 functions: Some(functions),
1247 branch: None,
1248 };
1249 assert!(matches!(
1250 evaluate_rust(&report, floor(100)),
1251 Outcome::Fail(message) if message.contains("functions")
1252 ));
1253 assert_eq!(evaluate_rust(&report, floor(60)), Outcome::Pass);
1254 }
1255
1256 #[test]
1257 fn rust_branch_floor_fails_below_and_passes_at_its_bar() {
1258 let report = rust_report_full(100.0, 100.0, 100.0, (2, 50.0));
1259 let floor = |branch| RustThresholds {
1260 regions: None,
1261 lines: 50,
1262 functions: None,
1263 branch: Some(branch),
1264 };
1265 assert!(matches!(
1266 evaluate_rust(&report, floor(100)),
1267 Outcome::Fail(message) if message.contains("branches")
1268 ));
1269 assert_eq!(evaluate_rust(&report, floor(50)), Outcome::Pass);
1270 }
1271
1272 #[test]
1273 fn rust_a_branchless_crate_clears_any_branch_floor_vacuously() {
1274 let report = rust_report_full(100.0, 100.0, 100.0, (0, 0.0));
1275 let floor = RustThresholds {
1276 regions: None,
1277 lines: 50,
1278 functions: None,
1279 branch: Some(100),
1280 };
1281 assert_eq!(evaluate_rust(&report, floor), Outcome::Pass);
1282 }
1283
1284 #[test]
1285 fn rust_passes_when_both_metrics_meet_their_floor() {
1286 assert_eq!(
1287 evaluate_rust(&rust_report(100.0, 100.0), RUST_FULL),
1288 Outcome::Pass
1289 );
1290 }
1291
1292 #[test]
1293 fn rust_fails_on_the_one_metric_below_its_floor() {
1294 let outcome = evaluate_rust(&rust_report(70.0, 100.0), RUST_MID);
1295 assert!(
1296 matches!(&outcome, Outcome::Fail(message) if message.contains("regions") && !message.contains("lines")),
1297 "got: {outcome:?}"
1298 );
1299 }
1300
1301 #[test]
1302 fn rust_fail_message_names_every_metric_below() {
1303 let outcome = evaluate_rust(&rust_report(50.0, 50.0), RUST_MID);
1304 assert!(
1305 matches!(&outcome, Outcome::Fail(message)
1306 if message.contains("regions") && message.contains("lines")),
1307 "got: {outcome:?}"
1308 );
1309 }
1310
1311 #[test]
1312 fn rust_skips_the_region_check_when_regions_is_opt_out() {
1313 let thresholds = RustThresholds {
1314 regions: None,
1315 lines: 100,
1316 functions: None,
1317 branch: None,
1318 };
1319 assert_eq!(
1320 evaluate_rust(&rust_report(40.0, 100.0), thresholds),
1321 Outcome::Pass
1322 );
1323 }
1324
1325 #[test]
1326 fn rust_still_fails_lines_with_regions_opt_out() {
1327 let thresholds = RustThresholds {
1328 regions: None,
1329 lines: 100,
1330 functions: None,
1331 branch: None,
1332 };
1333 let outcome = evaluate_rust(&rust_report(100.0, 80.0), thresholds);
1334 assert!(
1335 matches!(&outcome, Outcome::Fail(message)
1336 if message.contains("lines") && !message.contains("regions")),
1337 "got: {outcome:?}"
1338 );
1339 }
1340
1341 #[test]
1342 fn rust_tolerates_float_noise_at_the_floor() {
1343 assert_eq!(
1344 evaluate_rust(&rust_report(99.999_999_999, 100.0), RUST_FULL),
1345 Outcome::Pass
1346 );
1347 }
1348
1349 #[test]
1350 fn rust_fails_a_vacuous_run_that_measured_no_code() {
1351 let nothing = LlvmCovMetric {
1352 count: 0,
1353 covered: 0,
1354 percent: 0.0,
1355 };
1356 let report = LlvmCovReport {
1357 data: vec![LlvmCovData {
1358 totals: LlvmCovTotals {
1359 regions: nothing,
1360 lines: nothing,
1361 functions: nothing,
1362 branches: None,
1363 },
1364 }],
1365 };
1366 let outcome = evaluate_rust(&report, RUST_MID);
1367 assert!(
1368 matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1369 "got: {outcome:?}"
1370 );
1371 }
1372
1373 #[test]
1374 fn rust_fails_an_export_with_no_data() {
1375 let report = LlvmCovReport { data: vec![] };
1376 assert!(matches!(evaluate_rust(&report, RUST_MID), Outcome::Fail(_)));
1377 }
1378
1379 #[test]
1380 fn parses_a_cargo_llvm_cov_report() {
1381 let json = r#"{
1382 "data": [{"totals": {
1383 "regions": {"count": 12, "covered": 9, "notcovered": 3, "percent": 75.0},
1384 "lines": {"count": 20, "covered": 18, "percent": 90.0},
1385 "functions": {"count": 3, "covered": 3, "percent": 100.0}
1386 }}],
1387 "type": "llvm.coverage.json.export",
1388 "version": "2.0.1"
1389 }"#;
1390 let report = parse_llvm_cov_report(json).expect("valid llvm-cov json");
1391 assert_eq!(report.data[0].totals.regions.percent, 75.0);
1392 assert_eq!(report.data[0].totals.lines.count, 20);
1393 }
1394
1395 #[test]
1396 fn llvm_cov_patch_detail_reads_code_regions_per_file() {
1397 let json = r#"{
1398 "data": [{
1399 "files": [{"filename": "/abs/grade.rs"}],
1400 "functions": [{
1401 "filenames": ["/abs/grade.rs"],
1402 "regions": [
1403 [6, 5, 6, 26, 1, 0, 0, 0],
1404 [10, 9, 10, 17, 0, 0, 0, 0]
1405 ]
1406 }],
1407 "totals": {}
1408 }],
1409 "type": "llvm.coverage.json.export",
1410 "version": "3.0.1"
1411 }"#;
1412 let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1413 assert_eq!(
1414 out["/abs/grade.rs"].regions,
1415 vec![(6, 6, true), (10, 10, false)]
1416 );
1417 }
1418
1419 #[test]
1420 fn llvm_cov_patch_detail_skips_non_code_regions() {
1421 let json = r#"{
1422 "data": [{
1423 "files": [{"filename": "/abs/a.rs"}],
1424 "functions": [{
1425 "filenames": ["/abs/a.rs"],
1426 "regions": [
1427 [1, 1, 1, 10, 2, 0, 0, 0],
1428 [2, 1, 2, 10, 0, 0, 0, 1],
1429 [3, 1, 3, 10, 0, 0, 0, 2]
1430 ]
1431 }]
1432 }]
1433 }"#;
1434 let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1435 assert_eq!(out["/abs/a.rs"].regions, vec![(1, 1, true)]);
1436 }
1437
1438 #[test]
1439 fn llvm_cov_patch_detail_groups_regions_by_filename_id() {
1440 let json = r#"{
1441 "data": [{
1442 "files": [{"filename": "/abs/a.rs"}, {"filename": "/abs/b.rs"}],
1443 "functions": [{
1444 "filenames": ["/abs/a.rs", "/abs/b.rs"],
1445 "regions": [
1446 [1, 1, 1, 5, 1, 0, 0, 0],
1447 [9, 1, 9, 5, 0, 1, 1, 0]
1448 ]
1449 }]
1450 }]
1451 }"#;
1452 let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1453 assert_eq!(out["/abs/a.rs"].regions, vec![(1, 1, true)]);
1454 assert_eq!(out["/abs/b.rs"].regions, vec![(9, 9, false)]);
1455 }
1456
1457 #[test]
1458 fn llvm_cov_patch_detail_skips_a_malformed_short_region() {
1459 let json = r#"{
1460 "data": [{
1461 "files": [{"filename": "/abs/a.rs"}],
1462 "functions": [{
1463 "filenames": ["/abs/a.rs"],
1464 "regions": [
1465 [4, 1, 4],
1466 [5, 1, 5, 9, 1, 0, 0, 0]
1467 ]
1468 }]
1469 }]
1470 }"#;
1471 let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1472 assert_eq!(out["/abs/a.rs"].regions, vec![(5, 5, true)]);
1473 }
1474
1475 #[test]
1476 fn llvm_cov_patch_detail_spans_a_multiline_region() {
1477 let json = r#"{
1478 "data": [{
1479 "files": [{"filename": "/abs/a.rs"}],
1480 "functions": [{
1481 "filenames": ["/abs/a.rs"],
1482 "regions": [[3, 5, 5, 6, 0, 0, 0, 0]]
1483 }]
1484 }]
1485 }"#;
1486 let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1487 assert_eq!(out["/abs/a.rs"].regions, vec![(3, 5, false)]);
1488 }
1489
1490 #[test]
1491 fn llvm_cov_patch_detail_drops_a_file_absent_from_the_files_allowlist() {
1492 let json = r#"{
1493 "data": [{
1494 "files": [{"filename": "/abs/kept.rs"}],
1495 "functions": [{
1496 "filenames": ["/abs/kept.rs", "/abs/ignored.rs"],
1497 "regions": [
1498 [1, 1, 1, 9, 1, 0, 0, 0],
1499 [2, 1, 2, 9, 0, 1, 0, 0]
1500 ]
1501 }]
1502 }]
1503 }"#;
1504 let out = llvm_cov_patch_detail(json).expect("valid llvm-cov export");
1505 assert_eq!(out["/abs/kept.rs"].regions, vec![(1, 1, true)]);
1506 assert!(!out.contains_key("/abs/ignored.rs"));
1507 }
1508
1509 #[test]
1510 fn llvm_cov_patch_detail_malformed_json_is_an_error() {
1511 assert!(llvm_cov_patch_detail("{ not json").is_err());
1512 }
1513
1514 #[test]
1515 fn rust_ignore_regex_is_none_when_nothing_is_exempt() {
1516 assert_eq!(ignore_filename_regex(Path::new("/repo"), &[]), None);
1517 }
1518
1519 #[test]
1520 fn rust_ignore_regex_anchors_each_exempt_path_to_its_full_path() {
1521 let exempt = vec!["src/shim.rs".to_string(), "src/gen.rs".to_string()];
1523 assert_eq!(
1524 ignore_filename_regex(Path::new("/repo"), &exempt).as_deref(),
1525 Some(r"/repo/src/shim\.rs$|/repo/src/gen\.rs$")
1526 );
1527 }
1528
1529 fn llvm_would_ignore(regex: &str, filename: &str) -> bool {
1532 regex.split('|').any(|alt| {
1533 let (lit, anchored) = match alt.strip_suffix('$') {
1534 Some(head) => (head, true),
1535 None => (alt, false),
1536 };
1537 let lit = lit.replace('\\', "");
1538 if anchored {
1539 filename.ends_with(&lit)
1540 } else {
1541 filename.contains(&lit)
1542 }
1543 })
1544 }
1545
1546 #[test]
1547 fn rust_ignore_regex_does_not_over_match_a_member_with_the_same_suffix() {
1548 let regex = ignore_filename_regex(Path::new("/repo"), &["src/a.rs".to_string()]).unwrap();
1549 assert!(
1550 llvm_would_ignore(®ex, "/repo/src/a.rs"),
1551 "the exempted file must still be ignored: {regex}"
1552 );
1553 assert!(
1554 !llvm_would_ignore(®ex, "/repo/member/src/a.rs"),
1555 "`src/a.rs` over-matched `member/src/a.rs`: {regex}"
1556 );
1557 assert!(
1558 !llvm_would_ignore(®ex, "/repo/src/xsrc/a.rs"),
1559 "`src/a.rs` over-matched `src/xsrc/a.rs`: {regex}"
1560 );
1561 }
1562}