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 {
89 let actual = report.totals.percent_covered;
90 let required = f64::from(thresholds.fail_under);
91 if actual + 1e-9 >= required {
93 Outcome::Pass
94 } else {
95 Outcome::Fail(format!(
96 "coverage {actual:.2}% is below the required {}%",
97 thresholds.fail_under
98 ))
99 }
100}
101
102pub fn measure(root: &Path, thresholds: Thresholds, omit: &[String]) -> Result<Outcome> {
106 let report = run_coverage(root, omit)?;
107 Ok(evaluate(&report, thresholds))
108}
109
110pub fn measure_report(root: &Path, omit: &[String]) -> Result<CoverageReport> {
113 run_coverage(root, omit)
114}
115
116struct DataFile(PathBuf);
119
120impl DataFile {
121 fn new() -> Self {
122 static COUNTER: AtomicU64 = AtomicU64::new(0);
123 let name = format!(
124 "testing-conventions-{}-{}.coverage",
125 std::process::id(),
126 COUNTER.fetch_add(1, Ordering::Relaxed),
127 );
128 DataFile(std::env::temp_dir().join(name))
129 }
130}
131
132impl Drop for DataFile {
133 fn drop(&mut self) {
134 let _ = std::fs::remove_file(&self.0);
135 }
136}
137
138fn run_coverage(root: &Path, omit: &[String]) -> Result<CoverageReport> {
142 let data = DataFile::new();
143 let omit = build_omit(omit);
144
145 let mut command = Command::new("coverage");
147 command
148 .current_dir(root)
149 .args(["run", "--branch", "--source=."])
150 .arg(format!("--omit={omit}"));
151 let run = command
152 .args([
153 "-m",
154 "pytest",
155 "-q",
156 "-p",
157 "no:cacheprovider",
158 "--ignore=tests",
159 ".",
160 ])
161 .env("COVERAGE_FILE", &data.0)
162 .env("PYTHONDONTWRITEBYTECODE", "1")
163 .output()
164 .context("running `coverage run -m pytest` (is coverage.py installed?)")?;
165 if !run.status.success() {
166 bail!(
167 "the unit suite did not run cleanly under coverage in `{}`:\n{}{}",
168 root.display(),
169 String::from_utf8_lossy(&run.stdout),
170 String::from_utf8_lossy(&run.stderr),
171 );
172 }
173
174 let json = Command::new("coverage")
175 .current_dir(root)
176 .args(["json", "-o", "-"])
177 .env("COVERAGE_FILE", &data.0)
178 .output()
179 .context("running `coverage json`")?;
180 if !json.status.success() {
181 bail!(
182 "`coverage json` failed:\n{}",
183 String::from_utf8_lossy(&json.stderr),
184 );
185 }
186
187 parse_report(&String::from_utf8_lossy(&json.stdout))
188}
189
190fn build_omit(omit: &[String]) -> String {
194 [TEST_OMIT.to_string(), SUPPORT_OMIT.to_string()]
195 .into_iter()
196 .chain(omit.iter().cloned())
197 .collect::<Vec<_>>()
198 .join(",")
199}
200
201const TS_INCLUDE: &str = "**/*.{ts,tsx,mts,cts}";
204
205fn vitest_default_excludes(root: &Path) -> Result<Vec<String>> {
209 let run = Command::new("node")
210 .current_dir(root)
211 .args([
212 "-e",
213 "process.stdout.write(JSON.stringify(require('vitest/config').coverageConfigDefaults.exclude))",
214 ])
215 .output()
216 .context("resolving vitest's default coverage excludes via node")?;
217 if !run.status.success() {
218 bail!(
219 "could not resolve vitest's default coverage excludes in `{}`. The check runs the \
220 project's own vitest via `npx --no-install` and never downloads it, so `vitest` \
221 must be installed in the project. node output:\n{}{}",
222 root.display(),
223 String::from_utf8_lossy(&run.stdout),
224 String::from_utf8_lossy(&run.stderr),
225 );
226 }
227 parse_default_excludes(&run.stdout)
228}
229
230fn parse_default_excludes(stdout: &[u8]) -> Result<Vec<String>> {
232 let excludes: Vec<String> = serde_json::from_slice(stdout).with_context(|| {
233 format!(
234 "vitest's default coverage excludes were not a JSON string array — got: {}",
235 String::from_utf8_lossy(stdout)
236 )
237 })?;
238 Ok(excludes.into_iter().filter(|p| !p.contains('\0')).collect())
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub struct TypeScriptThresholds {
246 pub lines: u8,
247 pub branches: u8,
248 pub functions: u8,
249 pub statements: u8,
250}
251
252#[derive(Debug, Clone, Copy, Deserialize)]
254pub struct VitestReport {
255 pub total: VitestTotals,
256}
257
258#[derive(Debug, Clone, Copy, Deserialize)]
260pub struct VitestTotals {
261 pub lines: VitestMetric,
262 pub branches: VitestMetric,
263 pub functions: VitestMetric,
264 pub statements: VitestMetric,
265}
266
267#[derive(Debug, Clone, Copy, Deserialize)]
269pub struct VitestMetric {
270 #[serde(deserialize_with = "deserialize_pct")]
273 pub pct: Option<f64>,
274 pub total: u64,
276}
277
278fn deserialize_pct<'de, D>(deserializer: D) -> std::result::Result<Option<f64>, D::Error>
281where
282 D: serde::Deserializer<'de>,
283{
284 struct PctVisitor;
285 impl serde::de::Visitor<'_> for PctVisitor {
286 type Value = Option<f64>;
287
288 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
289 f.write_str("a coverage percent number or the string \"Unknown\"")
290 }
291
292 fn visit_f64<E>(self, value: f64) -> std::result::Result<Self::Value, E> {
293 Ok(Some(value))
294 }
295
296 fn visit_u64<E>(self, value: u64) -> std::result::Result<Self::Value, E> {
298 Ok(Some(value as f64))
299 }
300
301 fn visit_str<E>(self, _value: &str) -> std::result::Result<Self::Value, E> {
303 Ok(None)
304 }
305 }
306 deserializer.deserialize_any(PctVisitor)
307}
308
309pub fn parse_vitest_report(json: &str) -> Result<VitestReport> {
311 serde_json::from_str(json).context("parsing vitest coverage-summary JSON report")
312}
313
314pub fn evaluate_typescript(report: &VitestReport, thresholds: TypeScriptThresholds) -> Outcome {
318 let total = &report.total;
319 if total.lines.total == 0 {
321 return Outcome::Fail(
322 "the unit suite measured no code — check the path and that the suite runs".to_string(),
323 );
324 }
325 let checks = [
326 ("lines", total.lines, thresholds.lines),
327 ("branches", total.branches, thresholds.branches),
328 ("functions", total.functions, thresholds.functions),
329 ("statements", total.statements, thresholds.statements),
330 ];
331 let mut shortfalls = Vec::new();
332 for (name, metric, required) in checks {
333 let actual = metric.pct.unwrap_or(100.0);
335 if actual + 1e-9 < f64::from(required) {
337 shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
338 }
339 }
340 if shortfalls.is_empty() {
341 Outcome::Pass
342 } else {
343 Outcome::Fail(format!(
344 "coverage below thresholds: {}",
345 shortfalls.join(", ")
346 ))
347 }
348}
349
350pub fn measure_typescript(
354 root: &Path,
355 thresholds: TypeScriptThresholds,
356 exclude: &[String],
357) -> Result<Outcome> {
358 let report = run_vitest(root, exclude)?;
359 Ok(evaluate_typescript(&report, thresholds))
360}
361
362struct ReportDir(PathBuf);
365
366impl ReportDir {
367 fn new() -> Self {
368 static COUNTER: AtomicU64 = AtomicU64::new(0);
369 let name = format!(
370 "testing-conventions-vitest-{}-{}",
371 std::process::id(),
372 COUNTER.fetch_add(1, Ordering::Relaxed),
373 );
374 ReportDir(std::env::temp_dir().join(name))
375 }
376}
377
378impl Drop for ReportDir {
379 fn drop(&mut self) {
380 let _ = std::fs::remove_dir_all(&self.0);
381 }
382}
383
384fn run_vitest(root: &Path, exclude: &[String]) -> Result<VitestReport> {
386 let json = run_vitest_coverage(root, exclude, "json-summary", "coverage-summary.json")?;
387 parse_vitest_report(&json)
388}
389
390fn run_vitest_coverage(
394 root: &Path,
395 exclude: &[String],
396 reporter: &str,
397 report_file: &str,
398) -> Result<String> {
399 let reports = ReportDir::new();
400
401 let mut command = Command::new("npx");
402 command
403 .current_dir(root)
404 .args(["--no-install", "vitest", "run", "--no-cache"])
407 .args(["--coverage.enabled", "--coverage.provider=v8"])
408 .arg(format!("--coverage.reporter={reporter}"))
409 .arg("--coverage.all=true")
410 .arg(format!(
411 "--coverage.reportsDirectory={}",
412 reports.0.display()
413 ))
414 .arg(format!("--coverage.include={TS_INCLUDE}"))
415 .args([
418 "--coverage.thresholds.lines=0",
419 "--coverage.thresholds.branches=0",
420 "--coverage.thresholds.functions=0",
421 "--coverage.thresholds.statements=0",
422 "--coverage.thresholds.autoUpdate=false",
423 ]);
424 for path in vitest_default_excludes(root)?.iter().chain(exclude) {
425 command.arg(format!("--coverage.exclude={path}"));
426 }
427 let run = command
429 .env("CI", "1")
430 .output()
431 .context("running `npx --no-install vitest run --coverage`")?;
432 if !run.status.success() {
433 bail!(
434 "the unit suite did not run cleanly under vitest in `{}`. The check runs the \
435 project's own vitest via `npx --no-install` and never downloads it, so `vitest` \
436 and `@vitest/coverage-v8` must be installed in the project. vitest output:\n{}{}",
437 root.display(),
438 String::from_utf8_lossy(&run.stdout),
439 String::from_utf8_lossy(&run.stderr),
440 );
441 }
442
443 read_vitest_report(&reports.0.join(report_file), reporter)
444}
445
446fn read_vitest_report(path: &Path, reporter: &str) -> Result<String> {
448 std::fs::read_to_string(path).with_context(|| {
449 format!(
450 "reading vitest coverage report `{}` (did the run produce a {reporter} report?)",
451 path.display()
452 )
453 })
454}
455
456#[derive(Debug, Clone, Deserialize)]
459struct IstanbulFile {
460 #[serde(rename = "statementMap", default)]
462 statement_map: BTreeMap<String, IstanbulSpan>,
463 #[serde(default)]
465 s: BTreeMap<String, u64>,
466 #[serde(rename = "branchMap", default)]
468 branch_map: BTreeMap<String, IstanbulBranch>,
469 #[serde(default)]
471 b: BTreeMap<String, Vec<u64>>,
472 #[serde(rename = "fnMap", default)]
474 fn_map: BTreeMap<String, IstanbulFn>,
475 #[serde(default)]
477 f: BTreeMap<String, u64>,
478}
479
480#[derive(Debug, Clone, Deserialize)]
482struct IstanbulSpan {
483 start: IstanbulPos,
484 end: IstanbulPos,
485}
486
487#[derive(Debug, Clone, Deserialize)]
489struct IstanbulPos {
490 line: u64,
491}
492
493#[derive(Debug, Clone, Deserialize)]
495struct IstanbulBranch {
496 loc: IstanbulSpan,
497}
498
499#[derive(Debug, Clone, Deserialize)]
502struct IstanbulFn {
503 decl: IstanbulSpan,
504}
505
506#[derive(Debug, Clone, Default)]
509pub struct TsPatchCoverage {
510 pub statements: Vec<(u64, u64, bool)>,
513 pub branch_arms: Vec<(u64, bool)>,
516 pub functions: Vec<(u64, bool)>,
519}
520
521pub fn measure_patch_typescript_detail(
525 root: &Path,
526 exclude: &[String],
527) -> Result<BTreeMap<String, TsPatchCoverage>> {
528 let json = run_vitest_coverage(root, exclude, "json", "coverage-final.json")?;
529 istanbul_patch_detail(&json)
530}
531
532fn istanbul_patch_detail(json: &str) -> Result<BTreeMap<String, TsPatchCoverage>> {
535 let files: BTreeMap<String, IstanbulFile> = serde_json::from_str(json)
536 .context("parsing vitest coverage-final (Istanbul) JSON report")?;
537 let mut out = BTreeMap::new();
538 for (path, file) in files {
539 let mut detail = TsPatchCoverage::default();
540 for (id, span) in &file.statement_map {
541 let covered = file.s.get(id).is_some_and(|&count| count > 0);
542 detail
543 .statements
544 .push((span.start.line, span.end.line, covered));
545 }
546 for (id, branch) in &file.branch_map {
549 let line = branch.loc.start.line;
550 if let Some(counts) = file.b.get(id) {
551 for &count in counts {
552 detail.branch_arms.push((line, count > 0));
553 }
554 }
555 }
556 for (id, function) in &file.fn_map {
557 let covered = file.f.get(id).is_some_and(|&count| count > 0);
558 detail.functions.push((function.decl.start.line, covered));
559 }
560 out.insert(path, detail);
561 }
562 Ok(out)
563}
564
565#[derive(Debug, Clone, Copy, PartialEq, Eq)]
569pub struct RustThresholds {
570 pub regions: Option<u8>,
571 pub lines: u8,
572 pub functions: Option<u8>,
573 pub branch: Option<u8>,
574}
575
576#[derive(Debug, Clone, Deserialize)]
579pub struct LlvmCovReport {
580 pub data: Vec<LlvmCovData>,
581}
582
583#[derive(Debug, Clone, Copy, Deserialize)]
585pub struct LlvmCovData {
586 pub totals: LlvmCovTotals,
587}
588
589#[derive(Debug, Clone, Copy, Default, Deserialize)]
592pub struct LlvmCovTotals {
593 pub regions: LlvmCovMetric,
594 pub lines: LlvmCovMetric,
595 pub functions: LlvmCovMetric,
596 #[serde(default)]
597 pub branches: Option<LlvmCovMetric>,
598}
599
600#[derive(Debug, Clone, Copy, Default, Deserialize)]
602pub struct LlvmCovMetric {
603 pub count: u64,
605 pub covered: u64,
606 pub percent: f64,
607}
608
609pub fn parse_llvm_cov_report(json: &str) -> Result<LlvmCovReport> {
611 serde_json::from_str(json).context("parsing cargo llvm-cov JSON report")
612}
613
614pub fn evaluate_rust(report: &LlvmCovReport, thresholds: RustThresholds) -> Outcome {
617 let Some(totals) = report.data.first().map(|entry| &entry.totals) else {
618 return Outcome::Fail("the cargo llvm-cov report contained no data".to_string());
619 };
620 if totals.regions.count == 0 {
622 return Outcome::Fail(
623 "the unit suite measured no code — check the path and that the suite runs".to_string(),
624 );
625 }
626 let mut checks: Vec<(&str, f64, u8)> = Vec::new();
628 if let Some(regions) = thresholds.regions {
629 checks.push(("regions", totals.regions.percent, regions));
630 }
631 checks.push(("lines", totals.lines.percent, thresholds.lines));
632 if let Some(functions) = thresholds.functions {
633 checks.push(("functions", totals.functions.percent, functions));
634 }
635 if let Some(branch) = thresholds.branch {
636 if let Some(branches) = totals.branches.filter(|metric| metric.count > 0) {
639 checks.push(("branches", branches.percent, branch));
640 }
641 }
642 let mut shortfalls = Vec::new();
643 for (name, actual, required) in checks {
644 if actual + 1e-9 < f64::from(required) {
646 shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
647 }
648 }
649 if shortfalls.is_empty() {
650 Outcome::Pass
651 } else {
652 Outcome::Fail(format!(
653 "coverage below thresholds: {}",
654 shortfalls.join(", ")
655 ))
656 }
657}
658
659pub fn measure_rust(
663 root: &Path,
664 thresholds: RustThresholds,
665 ignore: &[String],
666 features: &[String],
667) -> Result<Outcome> {
668 let report = run_llvm_cov(root, ignore, features, thresholds.branch.is_some())?;
669 Ok(evaluate_rust(&report, thresholds))
670}
671
672struct TargetDir(PathBuf);
675
676impl TargetDir {
677 fn new() -> Self {
678 static COUNTER: AtomicU64 = AtomicU64::new(0);
679 let name = format!(
680 "testing-conventions-llvm-cov-{}-{}",
681 std::process::id(),
682 COUNTER.fetch_add(1, Ordering::Relaxed),
683 );
684 TargetDir(std::env::temp_dir().join(name))
685 }
686}
687
688impl Drop for TargetDir {
689 fn drop(&mut self) {
690 let _ = std::fs::remove_dir_all(&self.0);
691 }
692}
693
694fn run_llvm_cov(
699 root: &Path,
700 ignore: &[String],
701 features: &[String],
702 branch: bool,
703) -> Result<LlvmCovReport> {
704 let json = run_cargo_llvm_cov(root, ignore, &["--json"], features, branch)?;
705 let hidden = hidden_lines_by_file(&json)?;
706 Ok(llvm_cov_totals_less_hidden(
707 &parse_llvm_cov_export(&json)?,
708 &hidden,
709 ))
710}
711
712fn hidden_lines_by_file(json: &str) -> Result<BTreeMap<String, BTreeSet<u32>>> {
720 let export = parse_llvm_cov_export(json)?;
721 let mut out = BTreeMap::new();
722 for data in &export.data {
723 for file in &data.files {
724 let Ok(source) = std::fs::read_to_string(&file.filename) else {
725 continue;
726 };
727 let lines = crate::isolation::lines_hidden_from_tests(&source);
728 if !lines.is_empty() {
729 out.insert(file.filename.clone(), lines);
730 }
731 }
732 }
733 Ok(out)
734}
735
736#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
738struct Tally {
739 count: u64,
740 covered: u64,
741}
742
743impl Tally {
744 fn merge(&mut self, other: Tally) {
747 self.count = self.count.max(other.count);
748 self.covered = self.covered.max(other.covered);
749 }
750
751 fn add(&mut self, other: Tally) {
752 self.count += other.count;
753 self.covered += other.covered;
754 }
755}
756
757#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
759struct HiddenTotals {
760 regions: Tally,
761 lines: Tally,
762 functions: Tally,
763 branches: Tally,
764}
765
766impl HiddenTotals {
767 fn merge(&mut self, other: HiddenTotals) {
768 self.regions.merge(other.regions);
769 self.lines.merge(other.lines);
770 self.functions.merge(other.functions);
771 self.branches.merge(other.branches);
772 }
773
774 fn add(&mut self, other: HiddenTotals) {
775 self.regions.add(other.regions);
776 self.lines.add(other.lines);
777 self.functions.add(other.functions);
778 self.branches.add(other.branches);
779 }
780}
781
782fn llvm_cov_totals_less_hidden(
784 export: &LlvmCovExport,
785 hidden: &BTreeMap<String, BTreeSet<u32>>,
786) -> LlvmCovReport {
787 LlvmCovReport {
788 data: export
789 .data
790 .iter()
791 .map(|data| LlvmCovData {
792 totals: totals_less_hidden(data, hidden),
793 })
794 .collect(),
795 }
796}
797
798fn totals_less_hidden(
800 data: &LlvmCovExportData,
801 hidden: &BTreeMap<String, BTreeSet<u32>>,
802) -> LlvmCovTotals {
803 let delta = hidden_totals(data, hidden);
804 LlvmCovTotals {
805 regions: metric_less(data.totals.regions, delta.regions),
806 lines: metric_less(data.totals.lines, delta.lines),
807 functions: metric_less(data.totals.functions, delta.functions),
808 branches: data
809 .totals
810 .branches
811 .map(|metric| metric_less(metric, delta.branches)),
812 }
813}
814
815fn metric_less(metric: LlvmCovMetric, delta: Tally) -> LlvmCovMetric {
818 let count = metric.count.saturating_sub(delta.count);
819 let covered = metric.covered.saturating_sub(delta.covered).min(count);
820 let percent = if count == 0 {
821 100.0
822 } else {
823 covered as f64 * 100.0 / count as f64
824 };
825 LlvmCovMetric {
826 count,
827 covered,
828 percent,
829 }
830}
831
832fn hidden_totals(
836 data: &LlvmCovExportData,
837 hidden: &BTreeMap<String, BTreeSet<u32>>,
838) -> HiddenTotals {
839 let measured: BTreeSet<&str> = data.files.iter().map(|f| f.filename.as_str()).collect();
840 let mut groups: BTreeMap<(String, i64, i64), HiddenTotals> = BTreeMap::new();
841 for function in &data.functions {
842 let Some((file, line, column)) = function_start(function) else {
843 continue;
844 };
845 if !measured.contains(file.as_str()) {
846 continue;
847 }
848 let Some(lines) = hidden.get(&file) else {
849 continue;
850 };
851 if !lines.contains(&(line.max(0) as u32)) {
852 continue;
853 }
854 groups
855 .entry((file, line, column))
856 .or_default()
857 .merge(function_totals(function, lines));
858 }
859 groups.values().fold(HiddenTotals::default(), |mut acc, g| {
860 acc.add(*g);
861 acc
862 })
863}
864
865fn function_start(function: &LlvmCovFunction) -> Option<(String, i64, i64)> {
868 let region = function.regions.first()?;
869 if region.len() < 8 {
870 return None;
871 }
872 let file = function.filenames.get(usize::try_from(region[5]).ok()?)?;
873 Some((file.clone(), region[0], region[1]))
874}
875
876fn function_totals(function: &LlvmCovFunction, hidden: &BTreeSet<u32>) -> HiddenTotals {
879 let code: Vec<&Vec<i64>> = function
880 .regions
881 .iter()
882 .filter(|region| region.len() >= 8 && region[7] == 0)
883 .collect();
884 let mut lines: BTreeMap<u32, bool> = BTreeMap::new();
885 for region in &code {
886 for line in region[0].max(0) as u32..=region[2].max(0) as u32 {
887 if hidden.contains(&line) {
888 *lines.entry(line).or_default() |= region[4] > 0;
889 }
890 }
891 }
892 HiddenTotals {
893 regions: Tally {
894 count: code.len() as u64,
895 covered: code.iter().filter(|region| region[4] > 0).count() as u64,
896 },
897 lines: Tally {
898 count: lines.len() as u64,
899 covered: lines.values().filter(|covered| **covered).count() as u64,
900 },
901 functions: Tally {
902 count: 1,
903 covered: u64::from(function.count > 0),
904 },
905 branches: branch_tally(&function.branches),
906 }
907}
908
909fn branch_tally(branches: &[Vec<i64>]) -> Tally {
911 let mut tally = Tally::default();
912 for branch in branches.iter().filter(|branch| branch.len() >= 9) {
913 tally.count += 2;
914 tally.covered += u64::from(branch[4] > 0) + u64::from(branch[5] > 0);
915 }
916 tally
917}
918
919fn run_cargo_llvm_cov(
923 root: &Path,
924 ignore: &[String],
925 format: &[&str],
926 features: &[String],
927 branch: bool,
928) -> Result<String> {
929 let target = TargetDir::new();
930
931 let mut command = Command::new("cargo");
932 command
933 .current_dir(root)
934 .arg("llvm-cov")
935 .arg("--lib")
939 .arg("--bins")
940 .args(format)
941 .env("CARGO_TARGET_DIR", &target.0);
942 if !features.is_empty() {
943 command.arg("--features").arg(features.join(","));
944 }
945 if branch {
946 command.arg("--branch");
948 }
949 if let Some(regex) = ignore_filename_regex(root, ignore) {
950 command.arg("--ignore-filename-regex").arg(regex);
951 }
952 for var in [
956 "RUSTFLAGS",
957 "CARGO_ENCODED_RUSTFLAGS",
958 "RUSTDOCFLAGS",
959 "CARGO_ENCODED_RUSTDOCFLAGS",
960 "LLVM_PROFILE_FILE",
961 "CARGO_LLVM_COV",
962 "CARGO_LLVM_COV_SHOW_ENV",
963 "CARGO_LLVM_COV_TARGET_DIR",
964 "CARGO_LLVM_COV_BUILD_DIR",
965 "RUSTC_WRAPPER",
966 "RUSTC_WORKSPACE_WRAPPER",
967 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
968 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
969 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
970 "RUSTUP_TOOLCHAIN",
974 "CARGO",
975 "RUSTC",
976 ] {
977 command.env_remove(var);
978 }
979 let output = command
980 .output()
981 .context("running `cargo llvm-cov` (is cargo-llvm-cov installed?)")?;
982 if !output.status.success() {
983 let hint = if branch {
984 "\n(the [rust].coverage `branch` floor runs with --branch, which requires a \
985 nightly toolchain — pin one in the crate's rust-toolchain.toml with \
986 llvm-tools-preview, or set a rustup directory override)"
987 } else {
988 ""
989 };
990 bail!(
991 "the unit suite did not run cleanly under cargo llvm-cov in `{}`:{hint}\n{}{}",
992 root.display(),
993 String::from_utf8_lossy(&output.stdout),
994 String::from_utf8_lossy(&output.stderr),
995 );
996 }
997 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
998}
999
1000#[derive(Debug, Clone, Default)]
1003pub struct RustPatchCoverage {
1004 pub regions: Vec<(u64, u64, bool)>,
1007}
1008
1009#[derive(Debug, Clone, Deserialize)]
1012struct LlvmCovExport {
1013 data: Vec<LlvmCovExportData>,
1014}
1015
1016#[derive(Debug, Clone, Deserialize)]
1020struct LlvmCovExportData {
1021 files: Vec<LlvmCovExportFile>,
1022 functions: Vec<LlvmCovFunction>,
1023 #[serde(default)]
1026 totals: LlvmCovTotals,
1027}
1028
1029#[derive(Debug, Clone, Deserialize)]
1032struct LlvmCovExportFile {
1033 filename: String,
1034}
1035
1036#[derive(Debug, Clone, Deserialize)]
1041struct LlvmCovFunction {
1042 filenames: Vec<String>,
1043 regions: Vec<Vec<i64>>,
1044 #[serde(default)]
1046 count: u64,
1047 #[serde(default)]
1048 branches: Vec<Vec<i64>>,
1049}
1050
1051pub fn measure_patch_rust_detail(
1055 root: &Path,
1056 ignore: &[String],
1057 features: &[String],
1058) -> Result<BTreeMap<String, RustPatchCoverage>> {
1059 let json = run_cargo_llvm_cov(root, ignore, &["--json"], features, false)?;
1061 let hidden = hidden_lines_by_file(&json)?;
1062 llvm_cov_patch_detail(&json, &hidden)
1063}
1064
1065fn parse_llvm_cov_export(json: &str) -> Result<LlvmCovExport> {
1067 serde_json::from_str(json).context("parsing cargo llvm-cov JSON export")
1068}
1069
1070fn llvm_cov_patch_detail(
1075 json: &str,
1076 hidden: &BTreeMap<String, BTreeSet<u32>>,
1077) -> Result<BTreeMap<String, RustPatchCoverage>> {
1078 let export = parse_llvm_cov_export(json)?;
1079 let mut out: BTreeMap<String, RustPatchCoverage> = BTreeMap::new();
1080 for data in &export.data {
1081 let measured: BTreeSet<&str> = data.files.iter().map(|f| f.filename.as_str()).collect();
1082 for function in &data.functions {
1083 for region in &function.regions {
1084 if region.len() < 8 {
1085 continue;
1086 }
1087 if region[7] != 0 {
1089 continue;
1090 }
1091 let file_id = region[5];
1092 let Ok(file_id) = usize::try_from(file_id) else {
1093 continue;
1094 };
1095 let Some(file) = function.filenames.get(file_id) else {
1096 continue;
1097 };
1098 if !measured.contains(file.as_str()) {
1100 continue;
1101 }
1102 let start = region[0].max(0) as u64;
1103 let end = region[2].max(0) as u64;
1104 if hidden
1107 .get(file)
1108 .is_some_and(|lines| lines.contains(&(start as u32)))
1109 {
1110 continue;
1111 }
1112 let covered = region[4] > 0;
1113 out.entry(file.clone())
1114 .or_default()
1115 .regions
1116 .push((start, end, covered));
1117 }
1118 }
1119 }
1120 Ok(out)
1121}
1122
1123fn ignore_filename_regex(root: &Path, ignore: &[String]) -> Option<String> {
1127 if ignore.is_empty() {
1128 return None;
1129 }
1130 Some(
1131 ignore
1132 .iter()
1133 .map(|rel| {
1134 let full = root.join(rel);
1137 let full = full.canonicalize().unwrap_or(full);
1138 format!("{}$", regex_escape(&full.to_string_lossy()))
1139 })
1140 .collect::<Vec<_>>()
1141 .join("|"),
1142 )
1143}
1144
1145fn regex_escape(s: &str) -> String {
1147 const META: &str = r"\.+*?()|[]{}^$";
1148 let mut out = String::with_capacity(s.len());
1149 for c in s.chars() {
1150 if META.contains(c) {
1151 out.push('\\');
1152 }
1153 out.push(c);
1154 }
1155 out
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160 use super::*;
1161
1162 fn report(percent_covered: f64, num_branches: u64) -> CoverageReport {
1163 CoverageReport {
1164 totals: Totals {
1165 percent_covered,
1166 num_branches,
1167 },
1168 files: BTreeMap::new(),
1169 }
1170 }
1171
1172 #[test]
1173 fn passes_when_total_meets_the_floor() {
1174 assert_eq!(
1175 evaluate(
1176 &report(100.0, 12),
1177 Thresholds {
1178 fail_under: 100,
1179 branch: true
1180 }
1181 ),
1182 Outcome::Pass
1183 );
1184 }
1185
1186 #[test]
1187 fn fails_when_total_is_below_the_floor() {
1188 assert!(matches!(
1189 evaluate(
1190 &report(80.0, 12),
1191 Thresholds {
1192 fail_under: 100,
1193 branch: true
1194 }
1195 ),
1196 Outcome::Fail(_)
1197 ));
1198 }
1199
1200 #[test]
1201 fn passes_when_branch_required_and_none_are_measured() {
1202 assert_eq!(
1203 evaluate(
1204 &report(100.0, 0),
1205 Thresholds {
1206 fail_under: 100,
1207 branch: true
1208 }
1209 ),
1210 Outcome::Pass
1211 );
1212 }
1213
1214 #[test]
1215 fn parses_a_coverage_py_report() {
1216 let json = r#"{"totals":{"percent_covered":91.5,"num_branches":8,"covered_lines":91}}"#;
1217 let report = parse_report(json).expect("valid coverage.py json");
1218 assert_eq!(report.totals.percent_covered, 91.5);
1219 assert_eq!(report.totals.num_branches, 8);
1220 }
1221
1222 #[test]
1223 fn parses_the_per_file_block_for_patch_coverage() {
1224 let json = r#"{
1225 "files": {
1226 "widget.py": {
1227 "executed_lines": [1, 2, 3, 4, 6],
1228 "summary": {"percent_covered": 85.0},
1229 "missing_lines": [5],
1230 "excluded_lines": [],
1231 "missing_branches": [[4, 5]]
1232 }
1233 },
1234 "totals": {"percent_covered": 85.0, "num_branches": 4}
1235 }"#;
1236 let report = parse_report(json).expect("valid coverage.py json with files");
1237 let widget = report.files.get("widget.py").expect("widget.py is present");
1238 assert_eq!(widget.missing_lines, vec![5]);
1239 assert_eq!(widget.missing_branches, vec![vec![4, 5]]);
1240 assert_eq!(report.totals.percent_covered, 85.0);
1241 }
1242
1243 #[test]
1244 fn a_report_without_a_files_block_parses_with_an_empty_map() {
1245 let report = parse_report(r#"{"totals":{"percent_covered":100.0,"num_branches":2}}"#)
1246 .expect("valid coverage.py json");
1247 assert!(report.files.is_empty());
1248 }
1249
1250 #[test]
1251 fn omit_is_the_test_and_support_globs_when_nothing_is_exempt() {
1252 assert_eq!(build_omit(&[]), "*_test.py,*conftest.py");
1253 }
1254
1255 #[test]
1256 fn omit_folds_in_the_exempt_paths_after_the_test_glob() {
1257 let exempt = vec!["pkg/gen.py".to_string(), "shim.py".to_string()];
1258 assert_eq!(
1259 build_omit(&exempt),
1260 "*_test.py,*conftest.py,pkg/gen.py,shim.py"
1261 );
1262 }
1263
1264 fn metric(pct: f64) -> VitestMetric {
1265 VitestMetric {
1266 pct: Some(pct),
1267 total: 10,
1268 }
1269 }
1270
1271 fn ts_report(lines: f64, branches: f64, functions: f64, statements: f64) -> VitestReport {
1272 VitestReport {
1273 total: VitestTotals {
1274 lines: metric(lines),
1275 branches: metric(branches),
1276 functions: metric(functions),
1277 statements: metric(statements),
1278 },
1279 }
1280 }
1281
1282 const TS_FULL: TypeScriptThresholds = TypeScriptThresholds {
1283 lines: 100,
1284 branches: 100,
1285 functions: 100,
1286 statements: 100,
1287 };
1288 const TS_MID: TypeScriptThresholds = TypeScriptThresholds {
1289 lines: 80,
1290 branches: 75,
1291 functions: 80,
1292 statements: 80,
1293 };
1294
1295 #[test]
1296 fn typescript_passes_when_every_metric_meets_its_floor() {
1297 assert_eq!(
1298 evaluate_typescript(&ts_report(100.0, 100.0, 100.0, 100.0), TS_FULL),
1299 Outcome::Pass
1300 );
1301 }
1302
1303 #[test]
1304 fn typescript_fails_on_the_one_metric_below_its_floor() {
1305 let outcome = evaluate_typescript(&ts_report(100.0, 66.66, 100.0, 100.0), TS_MID);
1306 assert!(
1307 matches!(&outcome, Outcome::Fail(message) if message.contains("branches") && !message.contains("lines")),
1308 "got: {outcome:?}"
1309 );
1310 }
1311
1312 #[test]
1313 fn typescript_fail_message_names_every_metric_below() {
1314 let outcome = evaluate_typescript(&ts_report(70.0, 70.0, 70.0, 70.0), TS_MID);
1315 assert!(
1316 matches!(&outcome, Outcome::Fail(message)
1317 if message.contains("lines")
1318 && message.contains("branches")
1319 && message.contains("functions")
1320 && message.contains("statements")),
1321 "got: {outcome:?}"
1322 );
1323 }
1324
1325 #[test]
1326 fn typescript_tolerates_float_noise_at_the_floor() {
1327 assert_eq!(
1328 evaluate_typescript(&ts_report(99.999_999_999, 100.0, 100.0, 100.0), TS_FULL),
1329 Outcome::Pass
1330 );
1331 }
1332
1333 #[test]
1334 fn typescript_empty_denominator_metric_is_vacuously_satisfied() {
1335 let report = VitestReport {
1336 total: VitestTotals {
1337 lines: metric(100.0),
1338 branches: VitestMetric {
1339 pct: None,
1340 total: 0,
1341 },
1342 functions: metric(100.0),
1343 statements: metric(100.0),
1344 },
1345 };
1346 assert_eq!(evaluate_typescript(&report, TS_FULL), Outcome::Pass);
1347 }
1348
1349 #[test]
1350 fn typescript_fails_a_vacuous_run_that_measured_no_code() {
1351 let nothing = VitestMetric {
1352 pct: None,
1353 total: 0,
1354 };
1355 let report = VitestReport {
1356 total: VitestTotals {
1357 lines: nothing,
1358 branches: nothing,
1359 functions: nothing,
1360 statements: nothing,
1361 },
1362 };
1363 let outcome = evaluate_typescript(&report, TS_MID);
1364 assert!(
1365 matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1366 "got: {outcome:?}"
1367 );
1368 }
1369
1370 #[test]
1371 fn parses_a_vitest_summary_report() {
1372 let json = r#"{
1373 "total": {
1374 "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1375 "statements": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1376 "functions": {"total": 2, "covered": 2, "skipped": 0, "pct": 100},
1377 "branches": {"total": 3, "covered": 2, "skipped": 0, "pct": 66.66},
1378 "branchesTrue": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1379 },
1380 "/abs/widget.ts": {
1381 "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80}
1382 }
1383 }"#;
1384 let report = parse_vitest_report(json).expect("valid vitest json-summary");
1385 assert_eq!(report.total.lines.pct, Some(80.0));
1387 assert_eq!(report.total.branches.pct, Some(66.66));
1388 assert_eq!(report.total.functions.total, 2);
1389 }
1390
1391 #[test]
1392 fn parses_an_unknown_pct_as_unmeasured() {
1393 let json = r#"{"total": {
1394 "lines": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1395 "statements": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1396 "functions": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1397 "branches": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1398 }}"#;
1399 let report = parse_vitest_report(json).expect("valid vitest json-summary");
1400 assert_eq!(report.total.lines.pct, None);
1401 assert_eq!(report.total.lines.total, 0);
1402 }
1403
1404 #[test]
1405 fn a_pct_that_is_neither_number_nor_string_is_a_parse_error() {
1406 let json = r#"{"total":{
1407 "lines": {"total": 1, "covered": 1, "skipped": 0, "pct": true},
1408 "statements": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1409 "functions": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1410 "branches": {"total": 1, "covered": 1, "skipped": 0, "pct": 100}
1411 }}"#;
1412 assert!(parse_vitest_report(json).is_err());
1413 }
1414
1415 fn rust_metric(percent: f64) -> LlvmCovMetric {
1416 LlvmCovMetric {
1417 count: 10,
1418 covered: 10,
1419 percent,
1420 }
1421 }
1422
1423 fn rust_report(regions: f64, lines: f64) -> LlvmCovReport {
1424 LlvmCovReport {
1425 data: vec![LlvmCovData {
1426 totals: LlvmCovTotals {
1427 regions: rust_metric(regions),
1428 lines: rust_metric(lines),
1429 functions: rust_metric(lines),
1430 branches: None,
1431 },
1432 }],
1433 }
1434 }
1435
1436 fn rust_report_full(
1439 regions: f64,
1440 lines: f64,
1441 functions: f64,
1442 branches: (u64, f64),
1443 ) -> LlvmCovReport {
1444 let (count, percent) = branches;
1445 LlvmCovReport {
1446 data: vec![LlvmCovData {
1447 totals: LlvmCovTotals {
1448 regions: rust_metric(regions),
1449 lines: rust_metric(lines),
1450 functions: rust_metric(functions),
1451 branches: Some(LlvmCovMetric {
1452 count,
1453 covered: count,
1454 percent,
1455 }),
1456 },
1457 }],
1458 }
1459 }
1460
1461 const RUST_FULL: RustThresholds = RustThresholds {
1462 regions: Some(100),
1463 lines: 100,
1464 functions: None,
1465 branch: None,
1466 };
1467 const RUST_MID: RustThresholds = RustThresholds {
1468 regions: Some(80),
1469 lines: 85,
1470 functions: None,
1471 branch: None,
1472 };
1473
1474 #[test]
1475 fn rust_functions_floor_fails_below_and_passes_at_its_bar() {
1476 let report = rust_report_full(100.0, 100.0, 66.67, (0, 0.0));
1477 let floor = |functions| RustThresholds {
1478 regions: None,
1479 lines: 50,
1480 functions: Some(functions),
1481 branch: None,
1482 };
1483 assert!(matches!(
1484 evaluate_rust(&report, floor(100)),
1485 Outcome::Fail(message) if message.contains("functions")
1486 ));
1487 assert_eq!(evaluate_rust(&report, floor(60)), Outcome::Pass);
1488 }
1489
1490 #[test]
1491 fn rust_branch_floor_fails_below_and_passes_at_its_bar() {
1492 let report = rust_report_full(100.0, 100.0, 100.0, (2, 50.0));
1493 let floor = |branch| RustThresholds {
1494 regions: None,
1495 lines: 50,
1496 functions: None,
1497 branch: Some(branch),
1498 };
1499 assert!(matches!(
1500 evaluate_rust(&report, floor(100)),
1501 Outcome::Fail(message) if message.contains("branches")
1502 ));
1503 assert_eq!(evaluate_rust(&report, floor(50)), Outcome::Pass);
1504 }
1505
1506 #[test]
1507 fn rust_a_branchless_crate_clears_any_branch_floor_vacuously() {
1508 let report = rust_report_full(100.0, 100.0, 100.0, (0, 0.0));
1509 let floor = RustThresholds {
1510 regions: None,
1511 lines: 50,
1512 functions: None,
1513 branch: Some(100),
1514 };
1515 assert_eq!(evaluate_rust(&report, floor), Outcome::Pass);
1516 }
1517
1518 #[test]
1519 fn rust_passes_when_both_metrics_meet_their_floor() {
1520 assert_eq!(
1521 evaluate_rust(&rust_report(100.0, 100.0), RUST_FULL),
1522 Outcome::Pass
1523 );
1524 }
1525
1526 #[test]
1527 fn rust_fails_on_the_one_metric_below_its_floor() {
1528 let outcome = evaluate_rust(&rust_report(70.0, 100.0), RUST_MID);
1529 assert!(
1530 matches!(&outcome, Outcome::Fail(message) if message.contains("regions") && !message.contains("lines")),
1531 "got: {outcome:?}"
1532 );
1533 }
1534
1535 #[test]
1536 fn rust_fail_message_names_every_metric_below() {
1537 let outcome = evaluate_rust(&rust_report(50.0, 50.0), RUST_MID);
1538 assert!(
1539 matches!(&outcome, Outcome::Fail(message)
1540 if message.contains("regions") && message.contains("lines")),
1541 "got: {outcome:?}"
1542 );
1543 }
1544
1545 #[test]
1546 fn rust_skips_the_region_check_when_regions_is_opt_out() {
1547 let thresholds = RustThresholds {
1548 regions: None,
1549 lines: 100,
1550 functions: None,
1551 branch: None,
1552 };
1553 assert_eq!(
1554 evaluate_rust(&rust_report(40.0, 100.0), thresholds),
1555 Outcome::Pass
1556 );
1557 }
1558
1559 #[test]
1560 fn rust_still_fails_lines_with_regions_opt_out() {
1561 let thresholds = RustThresholds {
1562 regions: None,
1563 lines: 100,
1564 functions: None,
1565 branch: None,
1566 };
1567 let outcome = evaluate_rust(&rust_report(100.0, 80.0), thresholds);
1568 assert!(
1569 matches!(&outcome, Outcome::Fail(message)
1570 if message.contains("lines") && !message.contains("regions")),
1571 "got: {outcome:?}"
1572 );
1573 }
1574
1575 #[test]
1576 fn rust_tolerates_float_noise_at_the_floor() {
1577 assert_eq!(
1578 evaluate_rust(&rust_report(99.999_999_999, 100.0), RUST_FULL),
1579 Outcome::Pass
1580 );
1581 }
1582
1583 #[test]
1584 fn rust_fails_a_vacuous_run_that_measured_no_code() {
1585 let nothing = LlvmCovMetric {
1586 count: 0,
1587 covered: 0,
1588 percent: 0.0,
1589 };
1590 let report = LlvmCovReport {
1591 data: vec![LlvmCovData {
1592 totals: LlvmCovTotals {
1593 regions: nothing,
1594 lines: nothing,
1595 functions: nothing,
1596 branches: None,
1597 },
1598 }],
1599 };
1600 let outcome = evaluate_rust(&report, RUST_MID);
1601 assert!(
1602 matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1603 "got: {outcome:?}"
1604 );
1605 }
1606
1607 #[test]
1608 fn rust_fails_an_export_with_no_data() {
1609 let report = LlvmCovReport { data: vec![] };
1610 assert!(matches!(evaluate_rust(&report, RUST_MID), Outcome::Fail(_)));
1611 }
1612
1613 #[test]
1614 fn parses_a_cargo_llvm_cov_report() {
1615 let json = r#"{
1616 "data": [{"totals": {
1617 "regions": {"count": 12, "covered": 9, "notcovered": 3, "percent": 75.0},
1618 "lines": {"count": 20, "covered": 18, "percent": 90.0},
1619 "functions": {"count": 3, "covered": 3, "percent": 100.0}
1620 }}],
1621 "type": "llvm.coverage.json.export",
1622 "version": "2.0.1"
1623 }"#;
1624 let report = parse_llvm_cov_report(json).expect("valid llvm-cov json");
1625 assert_eq!(report.data[0].totals.regions.percent, 75.0);
1626 assert_eq!(report.data[0].totals.lines.count, 20);
1627 }
1628
1629 fn patch_detail(json: &str) -> BTreeMap<String, RustPatchCoverage> {
1631 llvm_cov_patch_detail(json, &BTreeMap::new()).expect("valid llvm-cov export")
1632 }
1633
1634 #[test]
1635 fn llvm_cov_patch_detail_reads_code_regions_per_file() {
1636 let json = r#"{
1637 "data": [{
1638 "files": [{"filename": "/abs/grade.rs"}],
1639 "functions": [{
1640 "filenames": ["/abs/grade.rs"],
1641 "regions": [
1642 [6, 5, 6, 26, 1, 0, 0, 0],
1643 [10, 9, 10, 17, 0, 0, 0, 0]
1644 ]
1645 }]
1646 }],
1647 "type": "llvm.coverage.json.export",
1648 "version": "3.0.1"
1649 }"#;
1650 let out = patch_detail(json);
1651 assert_eq!(
1652 out["/abs/grade.rs"].regions,
1653 vec![(6, 6, true), (10, 10, false)]
1654 );
1655 }
1656
1657 #[test]
1658 fn llvm_cov_patch_detail_skips_non_code_regions() {
1659 let json = r#"{
1660 "data": [{
1661 "files": [{"filename": "/abs/a.rs"}],
1662 "functions": [{
1663 "filenames": ["/abs/a.rs"],
1664 "regions": [
1665 [1, 1, 1, 10, 2, 0, 0, 0],
1666 [2, 1, 2, 10, 0, 0, 0, 1],
1667 [3, 1, 3, 10, 0, 0, 0, 2]
1668 ]
1669 }]
1670 }]
1671 }"#;
1672 let out = patch_detail(json);
1673 assert_eq!(out["/abs/a.rs"].regions, vec![(1, 1, true)]);
1674 }
1675
1676 #[test]
1677 fn llvm_cov_patch_detail_groups_regions_by_filename_id() {
1678 let json = r#"{
1679 "data": [{
1680 "files": [{"filename": "/abs/a.rs"}, {"filename": "/abs/b.rs"}],
1681 "functions": [{
1682 "filenames": ["/abs/a.rs", "/abs/b.rs"],
1683 "regions": [
1684 [1, 1, 1, 5, 1, 0, 0, 0],
1685 [9, 1, 9, 5, 0, 1, 1, 0]
1686 ]
1687 }]
1688 }]
1689 }"#;
1690 let out = patch_detail(json);
1691 assert_eq!(out["/abs/a.rs"].regions, vec![(1, 1, true)]);
1692 assert_eq!(out["/abs/b.rs"].regions, vec![(9, 9, false)]);
1693 }
1694
1695 #[test]
1696 fn llvm_cov_patch_detail_skips_a_malformed_short_region() {
1697 let json = r#"{
1698 "data": [{
1699 "files": [{"filename": "/abs/a.rs"}],
1700 "functions": [{
1701 "filenames": ["/abs/a.rs"],
1702 "regions": [
1703 [4, 1, 4],
1704 [5, 1, 5, 9, 1, 0, 0, 0]
1705 ]
1706 }]
1707 }]
1708 }"#;
1709 let out = patch_detail(json);
1710 assert_eq!(out["/abs/a.rs"].regions, vec![(5, 5, true)]);
1711 }
1712
1713 #[test]
1714 fn llvm_cov_patch_detail_spans_a_multiline_region() {
1715 let json = r#"{
1716 "data": [{
1717 "files": [{"filename": "/abs/a.rs"}],
1718 "functions": [{
1719 "filenames": ["/abs/a.rs"],
1720 "regions": [[3, 5, 5, 6, 0, 0, 0, 0]]
1721 }]
1722 }]
1723 }"#;
1724 let out = patch_detail(json);
1725 assert_eq!(out["/abs/a.rs"].regions, vec![(3, 5, false)]);
1726 }
1727
1728 #[test]
1729 fn llvm_cov_patch_detail_drops_a_file_absent_from_the_files_allowlist() {
1730 let json = r#"{
1731 "data": [{
1732 "files": [{"filename": "/abs/kept.rs"}],
1733 "functions": [{
1734 "filenames": ["/abs/kept.rs", "/abs/ignored.rs"],
1735 "regions": [
1736 [1, 1, 1, 9, 1, 0, 0, 0],
1737 [2, 1, 2, 9, 0, 1, 0, 0]
1738 ]
1739 }]
1740 }]
1741 }"#;
1742 let out = patch_detail(json);
1743 assert_eq!(out["/abs/kept.rs"].regions, vec![(1, 1, true)]);
1744 assert!(!out.contains_key("/abs/ignored.rs"));
1745 }
1746
1747 #[test]
1748 fn llvm_cov_patch_detail_malformed_json_is_an_error() {
1749 assert!(llvm_cov_patch_detail("{ not json", &BTreeMap::new()).is_err());
1750 }
1751
1752 #[test]
1753 fn llvm_cov_patch_detail_skips_a_negative_file_id() {
1754 let json = r#"{
1755 "data": [{
1756 "files": [{"filename": "/abs/a.rs"}],
1757 "functions": [{
1758 "filenames": ["/abs/a.rs"],
1759 "regions": [[1, 1, 1, 5, 1, -1, 0, 0]]
1760 }]
1761 }]
1762 }"#;
1763 let out = patch_detail(json);
1764 assert!(out.is_empty(), "got: {out:?}");
1765 }
1766
1767 #[test]
1768 fn llvm_cov_patch_detail_skips_an_out_of_range_file_id() {
1769 let json = r#"{
1770 "data": [{
1771 "files": [{"filename": "/abs/a.rs"}],
1772 "functions": [{
1773 "filenames": ["/abs/a.rs"],
1774 "regions": [[1, 1, 1, 5, 1, 7, 0, 0]]
1775 }]
1776 }]
1777 }"#;
1778 let out = patch_detail(json);
1779 assert!(out.is_empty(), "got: {out:?}");
1780 }
1781
1782 #[test]
1783 fn istanbul_patch_detail_reads_statements_arms_and_functions() {
1784 let json = r#"{
1785 "/abs/a.ts": {
1786 "statementMap": {"0": {"start": {"line": 1}, "end": {"line": 2}}},
1787 "s": {"0": 1},
1788 "branchMap": {"0": {"loc": {"start": {"line": 3}, "end": {"line": 3}}}},
1789 "b": {"0": [1, 0]},
1790 "fnMap": {"0": {"decl": {"start": {"line": 7}, "end": {"line": 7}}}},
1791 "f": {"0": 0}
1792 }
1793 }"#;
1794 let out = istanbul_patch_detail(json).expect("valid Istanbul report");
1795 let detail = &out["/abs/a.ts"];
1796 assert_eq!(detail.statements, vec![(1, 2, true)]);
1797 assert_eq!(detail.branch_arms, vec![(3, true), (3, false)]);
1798 assert_eq!(detail.functions, vec![(7, false)]);
1799 }
1800
1801 #[test]
1802 fn istanbul_patch_detail_keeps_a_branch_without_counts() {
1803 let json = r#"{
1804 "/abs/a.ts": {
1805 "statementMap": {},
1806 "s": {},
1807 "branchMap": {"0": {"loc": {"start": {"line": 3}, "end": {"line": 3}}}},
1808 "b": {},
1809 "fnMap": {},
1810 "f": {}
1811 }
1812 }"#;
1813 let out = istanbul_patch_detail(json).expect("valid Istanbul report");
1814 assert!(out["/abs/a.ts"].branch_arms.is_empty(), "got: {out:?}");
1815 }
1816
1817 #[test]
1818 fn default_excludes_that_are_not_json_name_the_output() {
1819 let err = parse_default_excludes(b"vitest warmed up first").unwrap_err();
1820 let msg = format!("{err:#}");
1821 assert!(msg.contains("not a JSON string array"), "got: {msg}");
1822 assert!(msg.contains("vitest warmed up first"), "got: {msg}");
1823 }
1824
1825 #[test]
1826 fn default_excludes_drop_a_nul_bearing_pattern() {
1827 let parsed = parse_default_excludes(br#"["**/dist/**", "**/\u0000*"]"#).unwrap();
1828 assert_eq!(parsed, vec!["**/dist/**".to_string()]);
1829 }
1830
1831 #[test]
1832 fn a_missing_vitest_report_names_the_reporter() {
1833 let path = std::env::temp_dir().join("tc-no-such-report/coverage-final.json");
1834 let err = read_vitest_report(&path, "json").unwrap_err();
1835 assert!(format!("{err:#}").contains("json report"), "got: {err:#}");
1836 }
1837
1838 #[test]
1839 fn rust_ignore_regex_is_none_when_nothing_is_exempt() {
1840 assert_eq!(ignore_filename_regex(Path::new("/repo"), &[]), None);
1841 }
1842
1843 #[test]
1844 fn rust_ignore_regex_anchors_each_exempt_path_to_its_full_path() {
1845 let exempt = vec!["src/shim.rs".to_string(), "src/gen.rs".to_string()];
1847 assert_eq!(
1848 ignore_filename_regex(Path::new("/repo"), &exempt).as_deref(),
1849 Some(r"/repo/src/shim\.rs$|/repo/src/gen\.rs$")
1850 );
1851 }
1852
1853 fn llvm_would_ignore(regex: &str, filename: &str) -> bool {
1856 regex.split('|').any(|alt| {
1857 let (lit, anchored) = match alt.strip_suffix('$') {
1858 Some(head) => (head, true),
1859 None => (alt, false),
1860 };
1861 let lit = lit.replace('\\', "");
1862 if anchored {
1863 filename.ends_with(&lit)
1864 } else {
1865 filename.contains(&lit)
1866 }
1867 })
1868 }
1869
1870 #[test]
1871 fn llvm_would_ignore_matches_an_unanchored_literal_anywhere() {
1872 assert!(llvm_would_ignore("/repo/src", "/repo/src/a.rs"));
1873 assert!(!llvm_would_ignore("/elsewhere", "/repo/src/a.rs"));
1874 }
1875
1876 #[test]
1877 fn rust_ignore_regex_does_not_over_match_a_member_with_the_same_suffix() {
1878 let regex = ignore_filename_regex(Path::new("/repo"), &["src/a.rs".to_string()]).unwrap();
1879 assert!(
1880 llvm_would_ignore(®ex, "/repo/src/a.rs"),
1881 "the exempted file must still be ignored: {regex}"
1882 );
1883 assert!(
1884 !llvm_would_ignore(®ex, "/repo/member/src/a.rs"),
1885 "`src/a.rs` over-matched `member/src/a.rs`: {regex}"
1886 );
1887 assert!(
1888 !llvm_would_ignore(®ex, "/repo/src/xsrc/a.rs"),
1889 "`src/a.rs` over-matched `src/xsrc/a.rs`: {regex}"
1890 );
1891 }
1892
1893 const GATED_EXPORT: &str = r#"{
1896 "data": [{
1897 "files": [{"filename": "/abs/entrypoint.rs"}],
1898 "functions": [
1899 {"name": "report", "count": 1, "filenames": ["/abs/entrypoint.rs"], "branches": [],
1900 "regions": [[11, 1, 11, 37, 1, 0, 0, 0], [12, 5, 12, 19, 1, 0, 0, 0],
1901 [12, 20, 12, 30, 1, 0, 0, 0], [13, 1, 13, 2, 1, 0, 0, 0]]},
1902 {"name": "main", "count": 0, "filenames": ["/abs/entrypoint.rs"], "branches": [],
1903 "regions": [[6, 1, 6, 26, 0, 0, 0, 0], [7, 5, 7, 11, 0, 0, 0, 0],
1904 [7, 12, 7, 46, 0, 0, 0, 0], [8, 1, 8, 2, 0, 0, 0, 0]]},
1905 {"name": "report", "count": 0, "filenames": ["/abs/entrypoint.rs"], "branches": [],
1906 "regions": [[11, 1, 11, 37, 0, 0, 0, 0], [12, 5, 12, 19, 0, 0, 0, 0],
1907 [12, 20, 12, 30, 0, 0, 0, 0], [13, 1, 13, 2, 0, 0, 0, 0]]}
1908 ],
1909 "totals": {
1910 "regions": {"count": 13, "covered": 9, "percent": 69.23},
1911 "lines": {"count": 9, "covered": 6, "percent": 66.67},
1912 "functions": {"count": 3, "covered": 2, "percent": 66.67}
1913 }
1914 }]
1915 }"#;
1916
1917 fn gated_main() -> BTreeMap<String, BTreeSet<u32>> {
1919 BTreeMap::from([(
1920 "/abs/entrypoint.rs".to_string(),
1921 BTreeSet::from([5, 6, 7, 8]),
1922 )])
1923 }
1924
1925 fn totals_less(json: &str, hidden: &BTreeMap<String, BTreeSet<u32>>) -> LlvmCovTotals {
1927 let export = parse_llvm_cov_export(json).expect("valid llvm-cov export");
1928 llvm_cov_totals_less_hidden(&export, hidden).data[0].totals
1929 }
1930
1931 #[test]
1932 fn a_gated_entry_point_leaves_every_ratio_full() {
1933 let totals = totals_less(GATED_EXPORT, &gated_main());
1934 assert_eq!((totals.regions.count, totals.regions.covered), (9, 9));
1935 assert_eq!((totals.lines.count, totals.lines.covered), (6, 6));
1936 assert_eq!((totals.functions.count, totals.functions.covered), (2, 2));
1937 assert_eq!(totals.regions.percent, 100.0);
1938 }
1939
1940 #[test]
1941 fn an_export_with_nothing_gated_keeps_its_totals() {
1942 let totals = totals_less(GATED_EXPORT, &BTreeMap::new());
1943 assert_eq!((totals.regions.count, totals.regions.covered), (13, 9));
1944 assert_eq!((totals.lines.count, totals.lines.covered), (9, 6));
1945 assert_eq!((totals.functions.count, totals.functions.covered), (3, 2));
1946 }
1947
1948 #[test]
1949 fn two_copies_of_one_gated_item_subtract_once() {
1950 let json = GATED_EXPORT.replace(
1951 r#"{"name": "report", "count": 1"#,
1952 r#"{"name": "main", "count": 0, "filenames": ["/abs/entrypoint.rs"], "branches": [],
1953 "regions": [[6, 1, 6, 26, 0, 0, 0, 0], [7, 5, 7, 11, 0, 0, 0, 0],
1954 [7, 12, 7, 46, 0, 0, 0, 0], [8, 1, 8, 2, 0, 0, 0, 0]]},
1955 {"name": "report", "count": 1"#,
1956 );
1957 let totals = totals_less(&json, &gated_main());
1958 assert_eq!((totals.regions.count, totals.regions.covered), (9, 9));
1959 assert_eq!(totals.functions.count, 2);
1960 }
1961
1962 #[test]
1963 fn a_record_with_no_region_to_place_it_is_not_subtracted() {
1964 let json = r#"{
1965 "data": [{
1966 "files": [{"filename": "/abs/a.rs"}],
1967 "functions": [{"name": "empty", "count": 0, "filenames": ["/abs/a.rs"],
1968 "regions": [], "branches": []}],
1969 "totals": {
1970 "regions": {"count": 2, "covered": 2, "percent": 100.0},
1971 "lines": {"count": 2, "covered": 2, "percent": 100.0},
1972 "functions": {"count": 1, "covered": 1, "percent": 100.0}
1973 }
1974 }]
1975 }"#;
1976 let hidden = BTreeMap::from([("/abs/a.rs".to_string(), BTreeSet::from([1]))]);
1977 assert_eq!(totals_less(json, &hidden).regions.count, 2);
1978 }
1979
1980 #[test]
1981 fn a_record_outside_the_files_allowlist_is_not_subtracted() {
1982 let hidden = BTreeMap::from([("/abs/other.rs".to_string(), BTreeSet::from([6]))]);
1983 let json = GATED_EXPORT.replace("\"/abs/entrypoint.rs\"],", "\"/abs/other.rs\"],");
1984 assert_eq!(totals_less(&json, &hidden).regions.count, 13);
1985 }
1986
1987 #[test]
1988 fn a_gated_branch_drops_both_of_its_arms() {
1989 let json = r#"{
1990 "data": [{
1991 "files": [{"filename": "/abs/a.rs"}],
1992 "functions": [{"name": "main", "count": 0, "filenames": ["/abs/a.rs"],
1993 "regions": [[1, 1, 3, 2, 0, 0, 0, 0]],
1994 "branches": [[2, 9, 2, 14, 0, 0, 0, 0, 4], [2, 9, 2, 14, 0, 0, 0, 0]]}],
1995 "totals": {
1996 "regions": {"count": 5, "covered": 4, "percent": 80.0},
1997 "lines": {"count": 9, "covered": 6, "percent": 66.67},
1998 "functions": {"count": 3, "covered": 2, "percent": 66.67},
1999 "branches": {"count": 6, "covered": 4, "percent": 66.67}
2000 }
2001 }]
2002 }"#;
2003 let hidden = BTreeMap::from([("/abs/a.rs".to_string(), BTreeSet::from([1, 2, 3]))]);
2004 let branches = totals_less(json, &hidden).branches.expect("branch totals");
2005 assert_eq!((branches.count, branches.covered), (4, 4));
2007 }
2008
2009 #[test]
2010 fn a_metric_the_subtraction_empties_reads_full() {
2011 let metric = LlvmCovMetric {
2012 count: 4,
2013 covered: 0,
2014 percent: 0.0,
2015 };
2016 let emptied = metric_less(
2017 metric,
2018 Tally {
2019 count: 9,
2020 covered: 9,
2021 },
2022 );
2023 assert_eq!((emptied.count, emptied.covered), (0, 0));
2024 assert_eq!(emptied.percent, 100.0);
2025 }
2026
2027 #[test]
2028 fn a_function_record_without_a_usable_first_region_has_no_group() {
2029 let short = LlvmCovFunction {
2030 filenames: vec!["/abs/a.rs".to_string()],
2031 regions: vec![vec![1, 1, 1, 2]],
2032 count: 0,
2033 branches: Vec::new(),
2034 };
2035 let unnamed = LlvmCovFunction {
2036 filenames: Vec::new(),
2037 regions: vec![vec![1, 1, 1, 2, 0, 7, 0, 0]],
2038 count: 0,
2039 branches: Vec::new(),
2040 };
2041 let empty = LlvmCovFunction {
2042 filenames: vec!["/abs/a.rs".to_string()],
2043 regions: Vec::new(),
2044 count: 0,
2045 branches: Vec::new(),
2046 };
2047 assert_eq!(function_start(&short), None);
2048 assert_eq!(function_start(&unnamed), None);
2049 assert_eq!(function_start(&empty), None);
2050 }
2051
2052 #[test]
2053 fn a_gated_region_drops_out_of_the_changed_line_detail() {
2054 let hidden =
2055 BTreeMap::from([("/abs/entrypoint.rs".to_string(), BTreeSet::from([6, 7, 8]))]);
2056 let detail = llvm_cov_patch_detail(GATED_EXPORT, &hidden).expect("valid export");
2057 assert_eq!(
2058 detail["/abs/entrypoint.rs"]
2059 .regions
2060 .iter()
2061 .map(|(start, _, _)| *start)
2062 .collect::<BTreeSet<_>>(),
2063 BTreeSet::from([11, 12, 13])
2064 );
2065 }
2066
2067 #[test]
2068 fn hidden_lines_come_from_the_sources_the_export_measured() {
2069 let dir = std::env::temp_dir().join(format!("tc-cov-hidden-{}", std::process::id()));
2070 std::fs::create_dir_all(&dir).unwrap();
2071 let gated = dir.join("gated.rs");
2072 std::fs::write(&gated, "#[cfg(not(test))]\nfn main() {}\n").unwrap();
2073 let json = format!(
2074 r#"{{"data": [{{"files": [{{"filename": "{}"}}, {{"filename": "{}"}}],
2075 "functions": []}}]}}"#,
2076 gated.display(),
2077 dir.join("absent.rs").display(),
2078 );
2079 let hidden = hidden_lines_by_file(&json).expect("valid export");
2080 std::fs::remove_dir_all(&dir).ok();
2081 assert_eq!(
2082 hidden,
2083 BTreeMap::from([(gated.display().to_string(), BTreeSet::from([1, 2]))]),
2084 "an unreadable source hides nothing"
2085 );
2086 }
2087}