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
80const NOTHING_MEASURED: &str =
82 "the unit suite measured no code — check the path and that the suite runs";
83
84pub(crate) fn shortfall(name: &str, actual: f64, required: u8) -> Option<String> {
87 if actual + 1e-9 < f64::from(required) {
88 Some(format!("{name} {actual:.2}% < {required}%"))
89 } else {
90 None
91 }
92}
93
94pub(crate) fn verdict(shortfalls: Vec<String>) -> Outcome {
96 if shortfalls.is_empty() {
97 Outcome::Pass
98 } else {
99 Outcome::Fail(format!(
100 "coverage below thresholds: {}",
101 shortfalls.join(", ")
102 ))
103 }
104}
105
106pub fn parse_report(json: &str) -> Result<CoverageReport> {
108 serde_json::from_str(json).context("parsing coverage.py JSON report")
109}
110
111pub fn evaluate(report: &CoverageReport, thresholds: Thresholds) -> Outcome {
115 let actual = report.totals.percent_covered;
116 let required = f64::from(thresholds.fail_under);
117 if actual + 1e-9 >= required {
119 Outcome::Pass
120 } else {
121 Outcome::Fail(format!(
122 "coverage {actual:.2}% is below the required {}%",
123 thresholds.fail_under
124 ))
125 }
126}
127
128pub fn measure(root: &Path, thresholds: Thresholds, omit: &[String]) -> Result<Outcome> {
132 let report = run_coverage(root, omit)?;
133 Ok(evaluate(&report, thresholds))
134}
135
136pub fn measure_report(root: &Path, omit: &[String]) -> Result<CoverageReport> {
139 run_coverage(root, omit)
140}
141
142struct DataFile(PathBuf);
145
146impl DataFile {
147 fn new() -> Self {
148 static COUNTER: AtomicU64 = AtomicU64::new(0);
149 let name = format!(
150 "testing-conventions-{}-{}.coverage",
151 std::process::id(),
152 COUNTER.fetch_add(1, Ordering::Relaxed),
153 );
154 DataFile(std::env::temp_dir().join(name))
155 }
156}
157
158impl Drop for DataFile {
159 fn drop(&mut self) {
160 let _ = std::fs::remove_file(&self.0);
161 }
162}
163
164fn run_coverage(root: &Path, omit: &[String]) -> Result<CoverageReport> {
168 let data = DataFile::new();
169 let omit = build_omit(omit);
170
171 let mut command = Command::new("coverage");
173 command
174 .current_dir(root)
175 .args(["run", "--branch", "--source=."])
176 .arg(format!("--omit={omit}"));
177 let run = command
178 .args([
179 "-m",
180 "pytest",
181 "-q",
182 "-p",
183 "no:cacheprovider",
184 "--ignore=tests",
185 ".",
186 ])
187 .env("COVERAGE_FILE", &data.0)
188 .env("PYTHONDONTWRITEBYTECODE", "1")
189 .output()
190 .context("running `coverage run -m pytest` (is coverage.py installed?)")?;
191 if !run.status.success() {
192 bail!(
193 "the unit suite did not run cleanly under coverage in `{}`:\n{}{}",
194 root.display(),
195 String::from_utf8_lossy(&run.stdout),
196 String::from_utf8_lossy(&run.stderr),
197 );
198 }
199
200 let json = Command::new("coverage")
201 .current_dir(root)
202 .args(["json", "-o", "-"])
203 .env("COVERAGE_FILE", &data.0)
204 .output()
205 .context("running `coverage json`")?;
206 if !json.status.success() {
207 bail!(
208 "`coverage json` failed:\n{}",
209 String::from_utf8_lossy(&json.stderr),
210 );
211 }
212
213 parse_report(&String::from_utf8_lossy(&json.stdout))
214}
215
216fn build_omit(omit: &[String]) -> String {
220 [TEST_OMIT.to_string(), SUPPORT_OMIT.to_string()]
221 .into_iter()
222 .chain(omit.iter().cloned())
223 .collect::<Vec<_>>()
224 .join(",")
225}
226
227const TS_INCLUDE: &str = "**/*.{ts,tsx,mts,cts}";
230
231fn vitest_default_excludes(root: &Path) -> Result<Vec<String>> {
235 let run = Command::new("node")
236 .current_dir(root)
237 .args([
238 "-e",
239 "process.stdout.write(JSON.stringify(require('vitest/config').coverageConfigDefaults.exclude))",
240 ])
241 .output()
242 .context("resolving vitest's default coverage excludes via node")?;
243 if !run.status.success() {
244 bail!(
245 "could not resolve vitest's default coverage excludes in `{}`. The check runs the \
246 project's own vitest via `npx --no-install` and never downloads it, so `vitest` \
247 must be installed in the project. node output:\n{}{}",
248 root.display(),
249 String::from_utf8_lossy(&run.stdout),
250 String::from_utf8_lossy(&run.stderr),
251 );
252 }
253 parse_default_excludes(&run.stdout)
254}
255
256fn parse_default_excludes(stdout: &[u8]) -> Result<Vec<String>> {
258 let excludes: Vec<String> = serde_json::from_slice(stdout).with_context(|| {
259 format!(
260 "vitest's default coverage excludes were not a JSON string array — got: {}",
261 String::from_utf8_lossy(stdout)
262 )
263 })?;
264 Ok(excludes.into_iter().filter(|p| !p.contains('\0')).collect())
267}
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub struct TypeScriptThresholds {
272 pub lines: u8,
273 pub branches: u8,
274 pub functions: u8,
275 pub statements: u8,
276}
277
278#[derive(Debug, Clone, Copy, Deserialize)]
280pub struct VitestReport {
281 pub total: VitestTotals,
282}
283
284#[derive(Debug, Clone, Copy, Deserialize)]
286pub struct VitestTotals {
287 pub lines: VitestMetric,
288 pub branches: VitestMetric,
289 pub functions: VitestMetric,
290 pub statements: VitestMetric,
291}
292
293#[derive(Debug, Clone, Copy, Deserialize)]
295pub struct VitestMetric {
296 #[serde(deserialize_with = "deserialize_pct")]
299 pub pct: Option<f64>,
300 pub total: u64,
302}
303
304fn deserialize_pct<'de, D>(deserializer: D) -> std::result::Result<Option<f64>, D::Error>
307where
308 D: serde::Deserializer<'de>,
309{
310 struct PctVisitor;
311 impl serde::de::Visitor<'_> for PctVisitor {
312 type Value = Option<f64>;
313
314 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
315 f.write_str("a coverage percent number or the string \"Unknown\"")
316 }
317
318 fn visit_f64<E>(self, value: f64) -> std::result::Result<Self::Value, E> {
319 Ok(Some(value))
320 }
321
322 fn visit_u64<E>(self, value: u64) -> std::result::Result<Self::Value, E> {
324 Ok(Some(value as f64))
325 }
326
327 fn visit_str<E>(self, _value: &str) -> std::result::Result<Self::Value, E> {
329 Ok(None)
330 }
331 }
332 deserializer.deserialize_any(PctVisitor)
333}
334
335pub fn parse_vitest_report(json: &str) -> Result<VitestReport> {
337 serde_json::from_str(json).context("parsing vitest coverage-summary JSON report")
338}
339
340pub fn evaluate_typescript(report: &VitestReport, thresholds: TypeScriptThresholds) -> Outcome {
344 let total = &report.total;
345 if total.lines.total == 0 {
347 return Outcome::Fail(NOTHING_MEASURED.to_string());
348 }
349 let checks = [
350 ("lines", total.lines, thresholds.lines),
351 ("branches", total.branches, thresholds.branches),
352 ("functions", total.functions, thresholds.functions),
353 ("statements", total.statements, thresholds.statements),
354 ];
355 verdict(
356 checks
357 .into_iter()
358 .filter_map(|(name, metric, required)| {
359 shortfall(name, metric.pct.unwrap_or(100.0), required)
362 })
363 .collect(),
364 )
365}
366
367pub fn measure_typescript(
371 root: &Path,
372 thresholds: TypeScriptThresholds,
373 exclude: &[String],
374) -> Result<Outcome> {
375 let report = run_vitest(root, exclude)?;
376 Ok(evaluate_typescript(&report, thresholds))
377}
378
379struct ReportDir(PathBuf);
382
383impl ReportDir {
384 fn new() -> Self {
385 static COUNTER: AtomicU64 = AtomicU64::new(0);
386 let name = format!(
387 "testing-conventions-vitest-{}-{}",
388 std::process::id(),
389 COUNTER.fetch_add(1, Ordering::Relaxed),
390 );
391 ReportDir(std::env::temp_dir().join(name))
392 }
393}
394
395impl Drop for ReportDir {
396 fn drop(&mut self) {
397 let _ = std::fs::remove_dir_all(&self.0);
398 }
399}
400
401fn run_vitest(root: &Path, exclude: &[String]) -> Result<VitestReport> {
403 let json = run_vitest_coverage(root, exclude, "json-summary", "coverage-summary.json")?;
404 parse_vitest_report(&json)
405}
406
407fn run_vitest_coverage(
410 root: &Path,
411 exclude: &[String],
412 reporter: &str,
413 report_file: &str,
414) -> Result<String> {
415 let reports = ReportDir::new();
416 let excludes = vitest_default_excludes(root)?;
417
418 let run = Command::new("npx")
419 .current_dir(root)
420 .args(vitest_coverage_argv(
421 reporter,
422 &reports.0,
423 excludes.iter().chain(exclude),
424 ))
425 .env("CI", "1")
427 .output()
428 .context("running `npx --no-install vitest run --coverage`")?;
429 vitest_exit(run.status.success(), root, &run.stdout, &run.stderr)?;
430 read_vitest_report(&reports.0.join(report_file), reporter)
431}
432
433fn vitest_exit(success: bool, root: &Path, stdout: &[u8], stderr: &[u8]) -> Result<()> {
438 if !success {
439 bail!(
440 "the unit suite did not run cleanly under vitest in `{}`. The check runs the \
441 project's own vitest via `npx --no-install` and never downloads it, so `vitest` \
442 and `@vitest/coverage-v8` must be installed in the project. vitest output:\n{}{}",
443 root.display(),
444 String::from_utf8_lossy(stdout),
445 String::from_utf8_lossy(stderr),
446 );
447 }
448 Ok(())
449}
450
451const VITEST_BASE_ARGV: [&str; 12] = [
459 "--no-install",
460 "vitest",
461 "run",
462 "--no-cache",
463 "--coverage.enabled",
464 "--coverage.provider=v8",
465 "--coverage.all=true",
466 "--coverage.thresholds.lines=0",
467 "--coverage.thresholds.branches=0",
468 "--coverage.thresholds.functions=0",
469 "--coverage.thresholds.statements=0",
470 "--coverage.thresholds.autoUpdate=false",
471];
472
473fn vitest_coverage_argv<'a>(
476 reporter: &str,
477 reports_dir: &Path,
478 exclude: impl Iterator<Item = &'a String>,
479) -> Vec<String> {
480 let mut argv: Vec<String> = VITEST_BASE_ARGV
481 .iter()
482 .map(|arg| (*arg).to_string())
483 .collect();
484 argv.push(format!("--coverage.reporter={reporter}"));
485 argv.push(format!(
486 "--coverage.reportsDirectory={}",
487 reports_dir.display()
488 ));
489 argv.push(format!("--coverage.include={TS_INCLUDE}"));
490 argv.extend(exclude.map(|path| format!("--coverage.exclude={path}")));
491 argv
492}
493
494fn read_vitest_report(path: &Path, reporter: &str) -> Result<String> {
496 std::fs::read_to_string(path).with_context(|| {
497 format!(
498 "reading vitest coverage report `{}` (did the run produce a {reporter} report?)",
499 path.display()
500 )
501 })
502}
503
504#[derive(Debug, Clone, Deserialize)]
507struct IstanbulFile {
508 #[serde(rename = "statementMap", default)]
510 statement_map: BTreeMap<String, IstanbulSpan>,
511 #[serde(default)]
513 s: BTreeMap<String, u64>,
514 #[serde(rename = "branchMap", default)]
516 branch_map: BTreeMap<String, IstanbulBranch>,
517 #[serde(default)]
519 b: BTreeMap<String, Vec<u64>>,
520 #[serde(rename = "fnMap", default)]
522 fn_map: BTreeMap<String, IstanbulFn>,
523 #[serde(default)]
525 f: BTreeMap<String, u64>,
526}
527
528#[derive(Debug, Clone, Deserialize)]
530struct IstanbulSpan {
531 start: IstanbulPos,
532 end: IstanbulPos,
533}
534
535#[derive(Debug, Clone, Deserialize)]
537struct IstanbulPos {
538 line: u64,
539}
540
541#[derive(Debug, Clone, Deserialize)]
543struct IstanbulBranch {
544 loc: IstanbulSpan,
545}
546
547#[derive(Debug, Clone, Deserialize)]
550struct IstanbulFn {
551 decl: IstanbulSpan,
552}
553
554#[derive(Debug, Clone, Default)]
557pub struct TsPatchCoverage {
558 pub statements: Vec<(u64, u64, bool)>,
561 pub branch_arms: Vec<(u64, bool)>,
564 pub functions: Vec<(u64, bool)>,
567}
568
569pub fn measure_patch_typescript_detail(
573 root: &Path,
574 exclude: &[String],
575) -> Result<BTreeMap<String, TsPatchCoverage>> {
576 let json = run_vitest_coverage(root, exclude, "json", "coverage-final.json")?;
577 istanbul_patch_detail(&json)
578}
579
580fn istanbul_patch_detail(json: &str) -> Result<BTreeMap<String, TsPatchCoverage>> {
583 let files: BTreeMap<String, IstanbulFile> = serde_json::from_str(json)
584 .context("parsing vitest coverage-final (Istanbul) JSON report")?;
585 Ok(files
586 .into_iter()
587 .map(|(path, file)| (path, istanbul_file_detail(&file)))
588 .collect())
589}
590
591fn istanbul_branch_arms(file: &IstanbulFile) -> Vec<(u64, bool)> {
594 let mut out = Vec::new();
595 for (id, branch) in &file.branch_map {
596 let line = branch.loc.start.line;
597 for &count in file.b.get(id).into_iter().flatten() {
598 out.push((line, count > 0));
599 }
600 }
601 out
602}
603
604fn istanbul_file_detail(file: &IstanbulFile) -> TsPatchCoverage {
607 let mut detail = TsPatchCoverage::default();
608 for (id, span) in &file.statement_map {
609 let covered = file.s.get(id).is_some_and(|&count| count > 0);
610 detail
611 .statements
612 .push((span.start.line, span.end.line, covered));
613 }
614 detail.branch_arms = istanbul_branch_arms(file);
615 for (id, function) in &file.fn_map {
616 let covered = file.f.get(id).is_some_and(|&count| count > 0);
617 detail.functions.push((function.decl.start.line, covered));
618 }
619 detail
620}
621
622#[derive(Debug, Clone, Copy, PartialEq, Eq)]
626pub struct RustThresholds {
627 pub regions: Option<u8>,
628 pub lines: u8,
629 pub functions: Option<u8>,
630 pub branch: Option<u8>,
631}
632
633#[derive(Debug, Clone, Deserialize)]
636pub struct LlvmCovReport {
637 pub data: Vec<LlvmCovData>,
638}
639
640#[derive(Debug, Clone, Copy, Deserialize)]
642pub struct LlvmCovData {
643 pub totals: LlvmCovTotals,
644}
645
646#[derive(Debug, Clone, Copy, Default, Deserialize)]
649pub struct LlvmCovTotals {
650 pub regions: LlvmCovMetric,
651 pub lines: LlvmCovMetric,
652 pub functions: LlvmCovMetric,
653 #[serde(default)]
654 pub branches: Option<LlvmCovMetric>,
655}
656
657#[derive(Debug, Clone, Copy, Default, Deserialize)]
659pub struct LlvmCovMetric {
660 pub count: u64,
662 pub covered: u64,
663 pub percent: f64,
664}
665
666pub fn parse_llvm_cov_report(json: &str) -> Result<LlvmCovReport> {
668 serde_json::from_str(json).context("parsing cargo llvm-cov JSON report")
669}
670
671pub fn evaluate_rust(report: &LlvmCovReport, thresholds: RustThresholds) -> Outcome {
674 let Some(totals) = report.data.first().map(|entry| &entry.totals) else {
675 return Outcome::Fail("the cargo llvm-cov report contained no data".to_string());
676 };
677 if totals.regions.count == 0 {
679 return Outcome::Fail(NOTHING_MEASURED.to_string());
680 }
681 verdict(
682 rust_checks(totals, thresholds)
683 .into_iter()
684 .filter_map(|(name, actual, required)| shortfall(name, actual, required))
685 .collect(),
686 )
687}
688
689fn rust_checks(totals: &LlvmCovTotals, thresholds: RustThresholds) -> Vec<(&'static str, f64, u8)> {
692 let mut checks = Vec::new();
693 if let Some(regions) = thresholds.regions {
694 checks.push(("regions", totals.regions.percent, regions));
695 }
696 checks.push(("lines", totals.lines.percent, thresholds.lines));
697 if let Some(functions) = thresholds.functions {
698 checks.push(("functions", totals.functions.percent, functions));
699 }
700 if let Some(branch) = thresholds.branch {
701 if let Some(branches) = totals.branches.filter(|metric| metric.count > 0) {
704 checks.push(("branches", branches.percent, branch));
705 }
706 }
707 checks
708}
709
710pub fn measure_rust(
714 root: &Path,
715 thresholds: RustThresholds,
716 ignore: &[String],
717 features: &[String],
718) -> Result<Outcome> {
719 let report = run_llvm_cov(root, ignore, features, thresholds.branch.is_some())?;
720 Ok(evaluate_rust(&report, thresholds))
721}
722
723struct TargetDir(PathBuf);
726
727impl TargetDir {
728 fn new() -> Self {
729 static COUNTER: AtomicU64 = AtomicU64::new(0);
730 let name = format!(
731 "testing-conventions-llvm-cov-{}-{}",
732 std::process::id(),
733 COUNTER.fetch_add(1, Ordering::Relaxed),
734 );
735 TargetDir(std::env::temp_dir().join(name))
736 }
737}
738
739impl Drop for TargetDir {
740 fn drop(&mut self) {
741 let _ = std::fs::remove_dir_all(&self.0);
742 }
743}
744
745fn run_llvm_cov(
750 root: &Path,
751 ignore: &[String],
752 features: &[String],
753 branch: bool,
754) -> Result<LlvmCovReport> {
755 let (json, lcov) = run_cargo_llvm_cov_json_and_lcov(root, ignore, features, branch)?;
756 let hidden = hidden_lines_by_file(&json)?;
757 let mut report = llvm_cov_totals_less_hidden(&parse_llvm_cov_export(&json)?, &hidden);
758 let lines = lcov_lines_metric(&lcov, &hidden);
759 for data in &mut report.data {
760 data.totals.lines = lines;
761 }
762 Ok(report)
763}
764
765fn lcov_lines(lcov: &str) -> BTreeMap<String, BTreeMap<u32, bool>> {
767 let mut out: BTreeMap<String, BTreeMap<u32, bool>> = BTreeMap::new();
768 let mut file: Option<&str> = None;
769 for record in lcov.lines() {
770 if let Some(name) = record.strip_prefix("SF:") {
771 file = Some(name);
772 continue;
773 }
774 let (Some(file), Some((number, covered))) = (file, lcov_da(record)) else {
775 continue;
776 };
777 *out.entry(file.to_string())
778 .or_default()
779 .entry(number)
780 .or_default() |= covered;
781 }
782 out
783}
784
785fn lcov_da(record: &str) -> Option<(u32, bool)> {
788 let (number, count) = record.strip_prefix("DA:")?.split_once(',')?;
789 Some((number.parse().ok()?, count.trim().parse::<u64>().ok()? > 0))
790}
791
792fn lcov_lines_metric(lcov: &str, hidden: &BTreeMap<String, BTreeSet<u32>>) -> LlvmCovMetric {
801 let mut tally = Tally::default();
802 for (file, lines) in lcov_lines(lcov) {
803 let gated = hidden.get(&file);
804 for (number, covered) in lines {
805 if gated.is_some_and(|gated| gated.contains(&number)) {
806 continue;
807 }
808 tally.count += 1;
809 tally.covered += u64::from(covered);
810 }
811 }
812 as_metric(tally.count, tally.covered)
813}
814
815fn hidden_lines_by_file(json: &str) -> Result<BTreeMap<String, BTreeSet<u32>>> {
823 let export = parse_llvm_cov_export(json)?;
824 let mut out = BTreeMap::new();
825 for data in &export.data {
826 for file in &data.files {
827 let Ok(source) = std::fs::read_to_string(&file.filename) else {
828 continue;
829 };
830 let lines = crate::isolation::lines_hidden_from_tests(&source);
831 if !lines.is_empty() {
832 out.insert(file.filename.clone(), lines);
833 }
834 }
835 }
836 Ok(out)
837}
838
839#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
841struct Tally {
842 count: u64,
843 covered: u64,
844}
845
846impl Tally {
847 fn merge(&mut self, other: Tally) {
850 self.count = self.count.max(other.count);
851 self.covered = self.covered.max(other.covered);
852 }
853
854 fn add(&mut self, other: Tally) {
855 self.count += other.count;
856 self.covered += other.covered;
857 }
858}
859
860#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
862struct HiddenTotals {
863 regions: Tally,
864 lines: Tally,
865 functions: Tally,
866 branches: Tally,
867}
868
869impl HiddenTotals {
870 fn merge(&mut self, other: HiddenTotals) {
871 self.regions.merge(other.regions);
872 self.lines.merge(other.lines);
873 self.functions.merge(other.functions);
874 self.branches.merge(other.branches);
875 }
876
877 fn add(&mut self, other: HiddenTotals) {
878 self.regions.add(other.regions);
879 self.lines.add(other.lines);
880 self.functions.add(other.functions);
881 self.branches.add(other.branches);
882 }
883}
884
885fn llvm_cov_totals_less_hidden(
887 export: &LlvmCovExport,
888 hidden: &BTreeMap<String, BTreeSet<u32>>,
889) -> LlvmCovReport {
890 LlvmCovReport {
891 data: export
892 .data
893 .iter()
894 .map(|data| LlvmCovData {
895 totals: totals_less_hidden(data, hidden),
896 })
897 .collect(),
898 }
899}
900
901fn totals_less_hidden(
903 data: &LlvmCovExportData,
904 hidden: &BTreeMap<String, BTreeSet<u32>>,
905) -> LlvmCovTotals {
906 let delta = hidden_totals(data, hidden);
907 LlvmCovTotals {
908 regions: metric_less(data.totals.regions, delta.regions),
909 lines: metric_less(data.totals.lines, delta.lines),
910 functions: metric_less(data.totals.functions, delta.functions),
911 branches: data
912 .totals
913 .branches
914 .map(|metric| metric_less(metric, delta.branches)),
915 }
916}
917
918fn metric_less(metric: LlvmCovMetric, delta: Tally) -> LlvmCovMetric {
920 let count = metric.count.saturating_sub(delta.count);
921 as_metric(
922 count,
923 metric.covered.saturating_sub(delta.covered).min(count),
924 )
925}
926
927fn as_metric(count: u64, covered: u64) -> LlvmCovMetric {
930 let percent = if count == 0 {
931 100.0
932 } else {
933 covered as f64 * 100.0 / count as f64
934 };
935 LlvmCovMetric {
936 count,
937 covered,
938 percent,
939 }
940}
941
942fn hidden_totals(
946 data: &LlvmCovExportData,
947 hidden: &BTreeMap<String, BTreeSet<u32>>,
948) -> HiddenTotals {
949 let measured: BTreeSet<&str> = data.files.iter().map(|f| f.filename.as_str()).collect();
950 let mut groups: BTreeMap<FunctionGroup, HiddenTotals> = BTreeMap::new();
951 for function in &data.functions {
952 let Some((group, lines)) = hidden_group(function, &measured, hidden) else {
953 continue;
954 };
955 groups
956 .entry(group)
957 .or_default()
958 .merge(function_totals(function, lines));
959 }
960 groups.values().fold(HiddenTotals::default(), |mut acc, g| {
961 acc.add(*g);
962 acc
963 })
964}
965
966fn hidden_group<'h>(
969 function: &LlvmCovFunction,
970 measured: &BTreeSet<&str>,
971 hidden: &'h BTreeMap<String, BTreeSet<u32>>,
972) -> Option<(FunctionGroup, &'h BTreeSet<u32>)> {
973 let (file, line, column) = function_start(function)?;
974 if !measured.contains(file.as_str()) {
975 return None;
976 }
977 let lines = hidden.get(&file)?;
978 if !lines.contains(&(line.max(0) as u32)) {
979 return None;
980 }
981 Some(((file, line, column), lines))
982}
983
984type FunctionGroup = (String, i64, i64);
986
987fn function_start(function: &LlvmCovFunction) -> Option<FunctionGroup> {
990 let region = function.regions.first()?;
991 if region.len() < 8 {
992 return None;
993 }
994 let file = function.filenames.get(usize::try_from(region[5]).ok()?)?;
995 Some((file.clone(), region[0], region[1]))
996}
997
998fn function_totals(function: &LlvmCovFunction, hidden: &BTreeSet<u32>) -> HiddenTotals {
1001 let code = code_regions(function);
1002 let lines = hidden_line_coverage(&code, hidden);
1003 HiddenTotals {
1004 regions: Tally {
1005 count: code.len() as u64,
1006 covered: code.iter().filter(|region| region[4] > 0).count() as u64,
1007 },
1008 lines: Tally {
1009 count: lines.len() as u64,
1010 covered: lines.values().filter(|covered| **covered).count() as u64,
1011 },
1012 functions: Tally {
1013 count: 1,
1014 covered: u64::from(function.count > 0),
1015 },
1016 branches: branch_tally(&function.branches),
1017 }
1018}
1019
1020fn code_regions(function: &LlvmCovFunction) -> Vec<&Vec<i64>> {
1023 function
1024 .regions
1025 .iter()
1026 .filter(|region| region.len() >= 8 && region[7] == 0)
1027 .collect()
1028}
1029
1030fn hidden_line_coverage(code: &[&Vec<i64>], hidden: &BTreeSet<u32>) -> BTreeMap<u32, bool> {
1033 let mut lines: BTreeMap<u32, bool> = BTreeMap::new();
1034 for region in code {
1035 for line in region[0].max(0) as u32..=region[2].max(0) as u32 {
1036 if hidden.contains(&line) {
1037 *lines.entry(line).or_default() |= region[4] > 0;
1038 }
1039 }
1040 }
1041 lines
1042}
1043
1044fn branch_tally(branches: &[Vec<i64>]) -> Tally {
1046 let mut tally = Tally::default();
1047 for branch in branches.iter().filter(|branch| branch.len() >= 9) {
1048 tally.count += 2;
1049 tally.covered += u64::from(branch[4] > 0) + u64::from(branch[5] > 0);
1050 }
1051 tally
1052}
1053
1054fn run_cargo_llvm_cov(
1058 root: &Path,
1059 ignore: &[String],
1060 format: &[&str],
1061 features: &[String],
1062 branch: bool,
1063) -> Result<String> {
1064 run_in(&TargetDir::new(), root, ignore, format, features, branch)
1065}
1066
1067fn run_cargo_llvm_cov_json_and_lcov(
1071 root: &Path,
1072 ignore: &[String],
1073 features: &[String],
1074 branch: bool,
1075) -> Result<(String, String)> {
1076 let target = TargetDir::new();
1077 let json = run_in(&target, root, ignore, &["--json"], features, branch)?;
1078 let lcov = report_in(&target, root, ignore, &["--lcov"])?;
1079 Ok((json, lcov))
1080}
1081
1082fn report_in(
1085 target: &TargetDir,
1086 root: &Path,
1087 ignore: &[String],
1088 format: &[&str],
1089) -> Result<String> {
1090 let mut command = Command::new("cargo");
1091 command
1092 .current_dir(root)
1093 .args(report_argv(format, ignore_filename_regex(root, ignore)))
1094 .env("CARGO_TARGET_DIR", &target.0);
1095 scrub_outer_llvm_cov(&mut command);
1096 let output = command
1097 .output()
1098 .context("running `cargo llvm-cov report` (is cargo-llvm-cov installed?)")?;
1099 llvm_cov_stdout(
1100 output.status.success(),
1101 &output.stdout,
1102 &output.stderr,
1103 &format!(
1104 "cargo llvm-cov could not re-export the profile it just wrote in `{}`:",
1105 root.display()
1106 ),
1107 )
1108}
1109
1110fn report_argv(format: &[&str], ignore_regex: Option<String>) -> Vec<String> {
1111 let mut argv = vec!["llvm-cov".to_string(), "report".to_string()];
1112 argv.extend(format.iter().map(|arg| (*arg).to_string()));
1113 if let Some(regex) = ignore_regex {
1114 argv.extend(["--ignore-filename-regex".to_string(), regex]);
1115 }
1116 argv
1117}
1118
1119fn llvm_cov_stdout(success: bool, stdout: &[u8], stderr: &[u8], failure: &str) -> Result<String> {
1121 if !success {
1122 bail!(
1123 "{failure}\n{}{}",
1124 String::from_utf8_lossy(stdout),
1125 String::from_utf8_lossy(stderr)
1126 );
1127 }
1128 Ok(String::from_utf8_lossy(stdout).into_owned())
1129}
1130
1131fn run_in(
1132 target: &TargetDir,
1133 root: &Path,
1134 ignore: &[String],
1135 format: &[&str],
1136 features: &[String],
1137 branch: bool,
1138) -> Result<String> {
1139 let mut command = Command::new("cargo");
1140 let argv = run_argv(
1141 format,
1142 features,
1143 branch,
1144 ignore_filename_regex(root, ignore),
1145 );
1146 aim_llvm_cov(&mut command, target, root, argv);
1147 let output = command
1148 .output()
1149 .context("running `cargo llvm-cov` (is cargo-llvm-cov installed?)")?;
1150 llvm_cov_stdout(
1151 output.status.success(),
1152 &output.stdout,
1153 &output.stderr,
1154 &llvm_cov_failure(root, branch),
1155 )
1156}
1157
1158fn aim_llvm_cov(command: &mut Command, target: &TargetDir, root: &Path, argv: Vec<String>) {
1161 command
1162 .current_dir(root)
1163 .args(argv)
1164 .env("CARGO_TARGET_DIR", &target.0);
1165 scrub_outer_llvm_cov(command);
1166}
1167
1168fn llvm_cov_failure(root: &Path, branch: bool) -> String {
1171 format!(
1172 "the unit suite did not run cleanly under cargo llvm-cov in `{}`:{}",
1173 root.display(),
1174 branch_hint(branch)
1175 )
1176}
1177
1178fn run_argv(
1179 format: &[&str],
1180 features: &[String],
1181 branch: bool,
1182 ignore_regex: Option<String>,
1183) -> Vec<String> {
1184 let mut argv = vec![
1185 "llvm-cov".to_string(),
1186 "--lib".to_string(),
1187 "--bins".to_string(),
1188 ];
1189 argv.extend(format.iter().map(|arg| (*arg).to_string()));
1190 if !features.is_empty() {
1191 argv.extend(["--features".to_string(), features.join(",")]);
1192 }
1193 if branch {
1194 argv.push("--branch".to_string());
1195 }
1196 if let Some(regex) = ignore_regex {
1197 argv.extend(["--ignore-filename-regex".to_string(), regex]);
1198 }
1199 argv
1200}
1201
1202fn branch_hint(branch: bool) -> &'static str {
1203 if branch {
1204 "\n(the [rust].coverage `branch` floor runs with --branch, which requires a \
1205 nightly toolchain — pin one in the crate's rust-toolchain.toml with \
1206 llvm-tools-preview, or set a rustup directory override)"
1207 } else {
1208 ""
1209 }
1210}
1211
1212const OUTER_LLVM_COV_VARS: &[&str] = &[
1216 "RUSTFLAGS",
1217 "CARGO_ENCODED_RUSTFLAGS",
1218 "RUSTDOCFLAGS",
1219 "CARGO_ENCODED_RUSTDOCFLAGS",
1220 "LLVM_PROFILE_FILE",
1221 "CARGO_LLVM_COV",
1222 "CARGO_LLVM_COV_SHOW_ENV",
1223 "CARGO_LLVM_COV_TARGET_DIR",
1224 "CARGO_LLVM_COV_BUILD_DIR",
1225 "RUSTC_WRAPPER",
1226 "RUSTC_WORKSPACE_WRAPPER",
1227 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
1228 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
1229 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
1230 "RUSTUP_TOOLCHAIN",
1231 "CARGO",
1232 "RUSTC",
1233];
1234
1235fn scrub_outer_llvm_cov(command: &mut Command) {
1237 for var in OUTER_LLVM_COV_VARS {
1238 command.env_remove(var);
1239 }
1240}
1241
1242#[derive(Debug, Clone, Default)]
1245pub struct RustPatchCoverage {
1246 pub regions: Vec<(u64, u64, bool)>,
1249}
1250
1251#[derive(Debug, Clone, Deserialize)]
1254struct LlvmCovExport {
1255 data: Vec<LlvmCovExportData>,
1256}
1257
1258#[derive(Debug, Clone, Deserialize)]
1262struct LlvmCovExportData {
1263 files: Vec<LlvmCovExportFile>,
1264 functions: Vec<LlvmCovFunction>,
1265 #[serde(default)]
1268 totals: LlvmCovTotals,
1269}
1270
1271#[derive(Debug, Clone, Deserialize)]
1274struct LlvmCovExportFile {
1275 filename: String,
1276}
1277
1278#[derive(Debug, Clone, Deserialize)]
1283struct LlvmCovFunction {
1284 filenames: Vec<String>,
1285 regions: Vec<Vec<i64>>,
1286 #[serde(default)]
1288 count: u64,
1289 #[serde(default)]
1290 branches: Vec<Vec<i64>>,
1291}
1292
1293pub fn measure_patch_rust_detail(
1297 root: &Path,
1298 ignore: &[String],
1299 features: &[String],
1300) -> Result<BTreeMap<String, RustPatchCoverage>> {
1301 let json = run_cargo_llvm_cov(root, ignore, &["--json"], features, false)?;
1303 let hidden = hidden_lines_by_file(&json)?;
1304 llvm_cov_patch_detail(&json, &hidden)
1305}
1306
1307fn parse_llvm_cov_export(json: &str) -> Result<LlvmCovExport> {
1309 serde_json::from_str(json).context("parsing cargo llvm-cov JSON export")
1310}
1311
1312fn llvm_cov_patch_detail(
1315 json: &str,
1316 hidden: &BTreeMap<String, BTreeSet<u32>>,
1317) -> Result<BTreeMap<String, RustPatchCoverage>> {
1318 let export = parse_llvm_cov_export(json)?;
1319 let mut out: BTreeMap<String, RustPatchCoverage> = BTreeMap::new();
1320 for data in &export.data {
1321 let measured: BTreeSet<&str> = data.files.iter().map(|f| f.filename.as_str()).collect();
1322 for function in &data.functions {
1323 for region in &function.regions {
1324 if let Some((file, start, end, covered)) =
1325 patch_region(region, &function.filenames, &measured, hidden)
1326 {
1327 out.entry(file)
1328 .or_default()
1329 .regions
1330 .push((start, end, covered));
1331 }
1332 }
1333 }
1334 }
1335 Ok(out)
1336}
1337
1338fn patch_region(
1343 region: &[i64],
1344 filenames: &[String],
1345 measured: &BTreeSet<&str>,
1346 hidden: &BTreeMap<String, BTreeSet<u32>>,
1347) -> Option<(String, u64, u64, bool)> {
1348 if region.len() < 8 {
1349 return None;
1350 }
1351 if region[7] != 0 {
1353 return None;
1354 }
1355 let file = filenames.get(usize::try_from(region[5]).ok()?)?;
1356 if !measured.contains(file.as_str()) {
1358 return None;
1359 }
1360 let start = region[0].max(0) as u64;
1361 if hidden
1364 .get(file)
1365 .is_some_and(|lines| lines.contains(&(start as u32)))
1366 {
1367 return None;
1368 }
1369 Some((file.clone(), start, region[2].max(0) as u64, region[4] > 0))
1370}
1371
1372fn ignore_filename_regex(root: &Path, ignore: &[String]) -> Option<String> {
1376 if ignore.is_empty() {
1377 return None;
1378 }
1379 Some(
1380 ignore
1381 .iter()
1382 .map(|rel| {
1383 let full = root.join(rel);
1386 let full = full.canonicalize().unwrap_or(full);
1387 format!("{}$", regex_escape(&full.to_string_lossy()))
1388 })
1389 .collect::<Vec<_>>()
1390 .join("|"),
1391 )
1392}
1393
1394fn regex_escape(s: &str) -> String {
1396 const META: &str = r"\.+*?()|[]{}^$";
1397 let mut out = String::with_capacity(s.len());
1398 for c in s.chars() {
1399 if META.contains(c) {
1400 out.push('\\');
1401 }
1402 out.push(c);
1403 }
1404 out
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409 use super::*;
1410
1411 #[test]
1412 fn istanbul_detail_reads_statements_branches_and_functions() {
1413 let json = r#"{
1414 "/pkg/src/widget.ts": {
1415 "path": "/pkg/src/widget.ts",
1416 "statementMap": {
1417 "0": {"start": {"line": 3}, "end": {"line": 5}},
1418 "1": {"start": {"line": 9}, "end": {"line": 9}}
1419 },
1420 "s": {"0": 2, "1": 0},
1421 "branchMap": {"0": {"loc": {"start": {"line": 4}, "end": {"line": 7}}}},
1422 "b": {"0": [1, 0]},
1423 "fnMap": {
1424 "0": {"decl": {"start": {"line": 2}, "end": {"line": 2}}},
1425 "1": {"decl": {"start": {"line": 8}, "end": {"line": 8}}}
1426 },
1427 "f": {"0": 0, "1": 3}
1428 }
1429 }"#;
1430 let detail = istanbul_patch_detail(json).unwrap();
1431 let file = &detail["/pkg/src/widget.ts"];
1432 assert!(file.statements.contains(&(3, 5, true)));
1434 assert!(file.statements.contains(&(9, 9, false)));
1435 assert_eq!(file.branch_arms, vec![(4, true), (4, false)]);
1437 assert_eq!(file.functions, vec![(2, false), (8, true)]);
1440 }
1441
1442 #[test]
1443 fn a_zero_hit_count_is_uncovered_rather_than_covered() {
1444 let json = r#"{
1445 "/pkg/src/widget.ts": {
1446 "path": "/pkg/src/widget.ts",
1447 "statementMap": {"0": {"start": {"line": 1}, "end": {"line": 1}}},
1448 "s": {"0": 0},
1449 "branchMap": {},
1450 "b": {},
1451 "fnMap": {},
1452 "f": {}
1453 }
1454 }"#;
1455 let detail = istanbul_patch_detail(json).unwrap();
1456 assert_eq!(detail["/pkg/src/widget.ts"].statements, vec![(1, 1, false)]);
1457 }
1458
1459 #[test]
1460 fn only_kind_zero_regions_count_as_code() {
1461 let function = LlvmCovFunction {
1462 filenames: vec!["a.rs".into()],
1463 regions: vec![vec![1, 0, 3, 0, 1, 0, 0, 0], vec![4, 0, 6, 0, 1, 0, 0, 2]],
1465 count: 1,
1466 branches: Vec::new(),
1467 };
1468 let code = code_regions(&function);
1469 assert_eq!(code.len(), 1);
1470 assert_eq!(code[0][0], 1);
1471 }
1472
1473 #[test]
1474 fn a_region_too_short_to_carry_a_kind_is_not_code() {
1475 let function = LlvmCovFunction {
1476 filenames: vec!["a.rs".into()],
1477 regions: vec![vec![1, 0, 3, 0, 1]],
1478 count: 1,
1479 branches: Vec::new(),
1480 };
1481 assert!(code_regions(&function).is_empty());
1482 }
1483
1484 #[test]
1485 fn several_regions_over_one_hidden_line_or_together() {
1486 let uncovered = vec![1i64, 0, 1, 0, 0, 0, 0, 0];
1487 let covered = vec![1i64, 0, 1, 0, 4, 0, 0, 0];
1488 let hidden: BTreeSet<u32> = [1].into_iter().collect();
1489 let code: Vec<&Vec<i64>> = vec![&uncovered, &covered];
1490 assert_eq!(hidden_line_coverage(&code, &hidden).get(&1), Some(&true));
1491
1492 let only_uncovered: Vec<&Vec<i64>> = vec![&uncovered];
1493 assert_eq!(
1494 hidden_line_coverage(&only_uncovered, &hidden).get(&1),
1495 Some(&false)
1496 );
1497 }
1498
1499 #[test]
1500 fn a_line_outside_the_gated_set_is_not_tallied() {
1501 let region = vec![1i64, 0, 9, 0, 1, 0, 0, 0];
1502 let code: Vec<&Vec<i64>> = vec![®ion];
1503 let hidden: BTreeSet<u32> = [4].into_iter().collect();
1504 let lines = hidden_line_coverage(&code, &hidden);
1505 assert_eq!(lines.keys().copied().collect::<Vec<u32>>(), vec![4]);
1506 }
1507
1508 fn report(percent_covered: f64, num_branches: u64) -> CoverageReport {
1509 CoverageReport {
1510 totals: Totals {
1511 percent_covered,
1512 num_branches,
1513 },
1514 files: BTreeMap::new(),
1515 }
1516 }
1517
1518 #[test]
1519 fn passes_when_total_meets_the_floor() {
1520 assert_eq!(
1521 evaluate(
1522 &report(100.0, 12),
1523 Thresholds {
1524 fail_under: 100,
1525 branch: true
1526 }
1527 ),
1528 Outcome::Pass
1529 );
1530 }
1531
1532 #[test]
1533 fn fails_when_total_is_below_the_floor() {
1534 assert!(matches!(
1535 evaluate(
1536 &report(80.0, 12),
1537 Thresholds {
1538 fail_under: 100,
1539 branch: true
1540 }
1541 ),
1542 Outcome::Fail(_)
1543 ));
1544 }
1545
1546 #[test]
1547 fn passes_when_branch_required_and_none_are_measured() {
1548 assert_eq!(
1549 evaluate(
1550 &report(100.0, 0),
1551 Thresholds {
1552 fail_under: 100,
1553 branch: true
1554 }
1555 ),
1556 Outcome::Pass
1557 );
1558 }
1559
1560 #[test]
1561 fn parses_a_coverage_py_report() {
1562 let json = r#"{"totals":{"percent_covered":91.5,"num_branches":8,"covered_lines":91}}"#;
1563 let report = parse_report(json).expect("valid coverage.py json");
1564 assert_eq!(report.totals.percent_covered, 91.5);
1565 assert_eq!(report.totals.num_branches, 8);
1566 }
1567
1568 #[test]
1569 fn parses_the_per_file_block_for_patch_coverage() {
1570 let json = r#"{
1571 "files": {
1572 "widget.py": {
1573 "executed_lines": [1, 2, 3, 4, 6],
1574 "summary": {"percent_covered": 85.0},
1575 "missing_lines": [5],
1576 "excluded_lines": [],
1577 "missing_branches": [[4, 5]]
1578 }
1579 },
1580 "totals": {"percent_covered": 85.0, "num_branches": 4}
1581 }"#;
1582 let report = parse_report(json).expect("valid coverage.py json with files");
1583 let widget = report.files.get("widget.py").expect("widget.py is present");
1584 assert_eq!(widget.missing_lines, vec![5]);
1585 assert_eq!(widget.missing_branches, vec![vec![4, 5]]);
1586 assert_eq!(report.totals.percent_covered, 85.0);
1587 }
1588
1589 #[test]
1590 fn a_report_without_a_files_block_parses_with_an_empty_map() {
1591 let report = parse_report(r#"{"totals":{"percent_covered":100.0,"num_branches":2}}"#)
1592 .expect("valid coverage.py json");
1593 assert!(report.files.is_empty());
1594 }
1595
1596 #[test]
1597 fn omit_is_the_test_and_support_globs_when_nothing_is_exempt() {
1598 assert_eq!(build_omit(&[]), "*_test.py,*conftest.py");
1599 }
1600
1601 #[test]
1602 fn omit_folds_in_the_exempt_paths_after_the_test_glob() {
1603 let exempt = vec!["pkg/gen.py".to_string(), "shim.py".to_string()];
1604 assert_eq!(
1605 build_omit(&exempt),
1606 "*_test.py,*conftest.py,pkg/gen.py,shim.py"
1607 );
1608 }
1609
1610 fn metric(pct: f64) -> VitestMetric {
1611 VitestMetric {
1612 pct: Some(pct),
1613 total: 10,
1614 }
1615 }
1616
1617 fn ts_report(lines: f64, branches: f64, functions: f64, statements: f64) -> VitestReport {
1618 VitestReport {
1619 total: VitestTotals {
1620 lines: metric(lines),
1621 branches: metric(branches),
1622 functions: metric(functions),
1623 statements: metric(statements),
1624 },
1625 }
1626 }
1627
1628 const TS_FULL: TypeScriptThresholds = TypeScriptThresholds {
1629 lines: 100,
1630 branches: 100,
1631 functions: 100,
1632 statements: 100,
1633 };
1634 const TS_MID: TypeScriptThresholds = TypeScriptThresholds {
1635 lines: 80,
1636 branches: 75,
1637 functions: 80,
1638 statements: 80,
1639 };
1640
1641 #[test]
1642 fn typescript_passes_when_every_metric_meets_its_floor() {
1643 assert_eq!(
1644 evaluate_typescript(&ts_report(100.0, 100.0, 100.0, 100.0), TS_FULL),
1645 Outcome::Pass
1646 );
1647 }
1648
1649 #[test]
1650 fn typescript_fails_on_the_one_metric_below_its_floor() {
1651 let outcome = evaluate_typescript(&ts_report(100.0, 66.66, 100.0, 100.0), TS_MID);
1652 assert!(
1653 matches!(&outcome, Outcome::Fail(message) if message.contains("branches") && !message.contains("lines")),
1654 "got: {outcome:?}"
1655 );
1656 }
1657
1658 #[test]
1659 fn typescript_fail_message_names_every_metric_below() {
1660 let outcome = evaluate_typescript(&ts_report(70.0, 70.0, 70.0, 70.0), TS_MID);
1661 assert!(
1662 matches!(&outcome, Outcome::Fail(message)
1663 if message.contains("lines")
1664 && message.contains("branches")
1665 && message.contains("functions")
1666 && message.contains("statements")),
1667 "got: {outcome:?}"
1668 );
1669 }
1670
1671 #[test]
1672 fn typescript_tolerates_float_noise_at_the_floor() {
1673 assert_eq!(
1674 evaluate_typescript(&ts_report(99.999_999_999, 100.0, 100.0, 100.0), TS_FULL),
1675 Outcome::Pass
1676 );
1677 }
1678
1679 #[test]
1680 fn typescript_empty_denominator_metric_is_vacuously_satisfied() {
1681 let report = VitestReport {
1682 total: VitestTotals {
1683 lines: metric(100.0),
1684 branches: VitestMetric {
1685 pct: None,
1686 total: 0,
1687 },
1688 functions: metric(100.0),
1689 statements: metric(100.0),
1690 },
1691 };
1692 assert_eq!(evaluate_typescript(&report, TS_FULL), Outcome::Pass);
1693 }
1694
1695 #[test]
1696 fn typescript_fails_a_vacuous_run_that_measured_no_code() {
1697 let nothing = VitestMetric {
1698 pct: None,
1699 total: 0,
1700 };
1701 let report = VitestReport {
1702 total: VitestTotals {
1703 lines: nothing,
1704 branches: nothing,
1705 functions: nothing,
1706 statements: nothing,
1707 },
1708 };
1709 let outcome = evaluate_typescript(&report, TS_MID);
1710 assert!(
1711 matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1712 "got: {outcome:?}"
1713 );
1714 }
1715
1716 #[test]
1717 fn parses_a_vitest_summary_report() {
1718 let json = r#"{
1719 "total": {
1720 "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1721 "statements": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1722 "functions": {"total": 2, "covered": 2, "skipped": 0, "pct": 100},
1723 "branches": {"total": 3, "covered": 2, "skipped": 0, "pct": 66.66},
1724 "branchesTrue": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1725 },
1726 "/abs/widget.ts": {
1727 "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80}
1728 }
1729 }"#;
1730 let report = parse_vitest_report(json).expect("valid vitest json-summary");
1731 assert_eq!(report.total.lines.pct, Some(80.0));
1733 assert_eq!(report.total.branches.pct, Some(66.66));
1734 assert_eq!(report.total.functions.total, 2);
1735 }
1736
1737 #[test]
1738 fn parses_an_unknown_pct_as_unmeasured() {
1739 let json = r#"{"total": {
1740 "lines": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1741 "statements": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1742 "functions": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1743 "branches": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1744 }}"#;
1745 let report = parse_vitest_report(json).expect("valid vitest json-summary");
1746 assert_eq!(report.total.lines.pct, None);
1747 assert_eq!(report.total.lines.total, 0);
1748 }
1749
1750 #[test]
1751 fn a_pct_that_is_neither_number_nor_string_is_a_parse_error() {
1752 let json = r#"{"total":{
1753 "lines": {"total": 1, "covered": 1, "skipped": 0, "pct": true},
1754 "statements": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1755 "functions": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1756 "branches": {"total": 1, "covered": 1, "skipped": 0, "pct": 100}
1757 }}"#;
1758 assert!(parse_vitest_report(json).is_err());
1759 }
1760
1761 fn rust_metric(percent: f64) -> LlvmCovMetric {
1762 LlvmCovMetric {
1763 count: 10,
1764 covered: 10,
1765 percent,
1766 }
1767 }
1768
1769 fn rust_report(regions: f64, lines: f64) -> LlvmCovReport {
1770 LlvmCovReport {
1771 data: vec![LlvmCovData {
1772 totals: LlvmCovTotals {
1773 regions: rust_metric(regions),
1774 lines: rust_metric(lines),
1775 functions: rust_metric(lines),
1776 branches: None,
1777 },
1778 }],
1779 }
1780 }
1781
1782 fn rust_report_full(
1785 regions: f64,
1786 lines: f64,
1787 functions: f64,
1788 branches: (u64, f64),
1789 ) -> LlvmCovReport {
1790 let (count, percent) = branches;
1791 LlvmCovReport {
1792 data: vec![LlvmCovData {
1793 totals: LlvmCovTotals {
1794 regions: rust_metric(regions),
1795 lines: rust_metric(lines),
1796 functions: rust_metric(functions),
1797 branches: Some(LlvmCovMetric {
1798 count,
1799 covered: count,
1800 percent,
1801 }),
1802 },
1803 }],
1804 }
1805 }
1806
1807 const RUST_FULL: RustThresholds = RustThresholds {
1808 regions: Some(100),
1809 lines: 100,
1810 functions: None,
1811 branch: None,
1812 };
1813 const RUST_MID: RustThresholds = RustThresholds {
1814 regions: Some(80),
1815 lines: 85,
1816 functions: None,
1817 branch: None,
1818 };
1819
1820 #[test]
1821 fn rust_functions_floor_fails_below_and_passes_at_its_bar() {
1822 let report = rust_report_full(100.0, 100.0, 66.67, (0, 0.0));
1823 let floor = |functions| RustThresholds {
1824 regions: None,
1825 lines: 50,
1826 functions: Some(functions),
1827 branch: None,
1828 };
1829 assert!(matches!(
1830 evaluate_rust(&report, floor(100)),
1831 Outcome::Fail(message) if message.contains("functions")
1832 ));
1833 assert_eq!(evaluate_rust(&report, floor(60)), Outcome::Pass);
1834 }
1835
1836 #[test]
1837 fn rust_branch_floor_fails_below_and_passes_at_its_bar() {
1838 let report = rust_report_full(100.0, 100.0, 100.0, (2, 50.0));
1839 let floor = |branch| RustThresholds {
1840 regions: None,
1841 lines: 50,
1842 functions: None,
1843 branch: Some(branch),
1844 };
1845 assert!(matches!(
1846 evaluate_rust(&report, floor(100)),
1847 Outcome::Fail(message) if message.contains("branches")
1848 ));
1849 assert_eq!(evaluate_rust(&report, floor(50)), Outcome::Pass);
1850 }
1851
1852 #[test]
1853 fn rust_a_branchless_crate_clears_any_branch_floor_vacuously() {
1854 let report = rust_report_full(100.0, 100.0, 100.0, (0, 0.0));
1855 let floor = RustThresholds {
1856 regions: None,
1857 lines: 50,
1858 functions: None,
1859 branch: Some(100),
1860 };
1861 assert_eq!(evaluate_rust(&report, floor), Outcome::Pass);
1862 }
1863
1864 #[test]
1865 fn rust_passes_when_both_metrics_meet_their_floor() {
1866 assert_eq!(
1867 evaluate_rust(&rust_report(100.0, 100.0), RUST_FULL),
1868 Outcome::Pass
1869 );
1870 }
1871
1872 #[test]
1873 fn rust_fails_on_the_one_metric_below_its_floor() {
1874 let outcome = evaluate_rust(&rust_report(70.0, 100.0), RUST_MID);
1875 assert!(
1876 matches!(&outcome, Outcome::Fail(message) if message.contains("regions") && !message.contains("lines")),
1877 "got: {outcome:?}"
1878 );
1879 }
1880
1881 #[test]
1882 fn rust_fail_message_names_every_metric_below() {
1883 let outcome = evaluate_rust(&rust_report(50.0, 50.0), RUST_MID);
1884 assert!(
1885 matches!(&outcome, Outcome::Fail(message)
1886 if message.contains("regions") && message.contains("lines")),
1887 "got: {outcome:?}"
1888 );
1889 }
1890
1891 #[test]
1892 fn rust_skips_the_region_check_when_regions_is_opt_out() {
1893 let thresholds = RustThresholds {
1894 regions: None,
1895 lines: 100,
1896 functions: None,
1897 branch: None,
1898 };
1899 assert_eq!(
1900 evaluate_rust(&rust_report(40.0, 100.0), thresholds),
1901 Outcome::Pass
1902 );
1903 }
1904
1905 #[test]
1906 fn rust_still_fails_lines_with_regions_opt_out() {
1907 let thresholds = RustThresholds {
1908 regions: None,
1909 lines: 100,
1910 functions: None,
1911 branch: None,
1912 };
1913 let outcome = evaluate_rust(&rust_report(100.0, 80.0), thresholds);
1914 assert!(
1915 matches!(&outcome, Outcome::Fail(message)
1916 if message.contains("lines") && !message.contains("regions")),
1917 "got: {outcome:?}"
1918 );
1919 }
1920
1921 #[test]
1922 fn rust_tolerates_float_noise_at_the_floor() {
1923 assert_eq!(
1924 evaluate_rust(&rust_report(99.999_999_999, 100.0), RUST_FULL),
1925 Outcome::Pass
1926 );
1927 }
1928
1929 #[test]
1930 fn rust_fails_a_vacuous_run_that_measured_no_code() {
1931 let nothing = LlvmCovMetric {
1932 count: 0,
1933 covered: 0,
1934 percent: 0.0,
1935 };
1936 let report = LlvmCovReport {
1937 data: vec![LlvmCovData {
1938 totals: LlvmCovTotals {
1939 regions: nothing,
1940 lines: nothing,
1941 functions: nothing,
1942 branches: None,
1943 },
1944 }],
1945 };
1946 let outcome = evaluate_rust(&report, RUST_MID);
1947 assert!(
1948 matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1949 "got: {outcome:?}"
1950 );
1951 }
1952
1953 #[test]
1954 fn rust_fails_an_export_with_no_data() {
1955 let report = LlvmCovReport { data: vec![] };
1956 assert!(matches!(evaluate_rust(&report, RUST_MID), Outcome::Fail(_)));
1957 }
1958
1959 #[test]
1960 fn parses_a_cargo_llvm_cov_report() {
1961 let json = r#"{
1962 "data": [{"totals": {
1963 "regions": {"count": 12, "covered": 9, "notcovered": 3, "percent": 75.0},
1964 "lines": {"count": 20, "covered": 18, "percent": 90.0},
1965 "functions": {"count": 3, "covered": 3, "percent": 100.0}
1966 }}],
1967 "type": "llvm.coverage.json.export",
1968 "version": "2.0.1"
1969 }"#;
1970 let report = parse_llvm_cov_report(json).expect("valid llvm-cov json");
1971 assert_eq!(report.data[0].totals.regions.percent, 75.0);
1972 assert_eq!(report.data[0].totals.lines.count, 20);
1973 }
1974
1975 fn patch_detail(json: &str) -> BTreeMap<String, RustPatchCoverage> {
1977 llvm_cov_patch_detail(json, &BTreeMap::new()).expect("valid llvm-cov export")
1978 }
1979
1980 #[test]
1981 fn llvm_cov_patch_detail_reads_code_regions_per_file() {
1982 let json = r#"{
1983 "data": [{
1984 "files": [{"filename": "/abs/grade.rs"}],
1985 "functions": [{
1986 "filenames": ["/abs/grade.rs"],
1987 "regions": [
1988 [6, 5, 6, 26, 1, 0, 0, 0],
1989 [10, 9, 10, 17, 0, 0, 0, 0]
1990 ]
1991 }]
1992 }],
1993 "type": "llvm.coverage.json.export",
1994 "version": "3.0.1"
1995 }"#;
1996 let out = patch_detail(json);
1997 assert_eq!(
1998 out["/abs/grade.rs"].regions,
1999 vec![(6, 6, true), (10, 10, false)]
2000 );
2001 }
2002
2003 #[test]
2004 fn llvm_cov_patch_detail_skips_non_code_regions() {
2005 let json = r#"{
2006 "data": [{
2007 "files": [{"filename": "/abs/a.rs"}],
2008 "functions": [{
2009 "filenames": ["/abs/a.rs"],
2010 "regions": [
2011 [1, 1, 1, 10, 2, 0, 0, 0],
2012 [2, 1, 2, 10, 0, 0, 0, 1],
2013 [3, 1, 3, 10, 0, 0, 0, 2]
2014 ]
2015 }]
2016 }]
2017 }"#;
2018 let out = patch_detail(json);
2019 assert_eq!(out["/abs/a.rs"].regions, vec![(1, 1, true)]);
2020 }
2021
2022 #[test]
2023 fn llvm_cov_patch_detail_groups_regions_by_filename_id() {
2024 let json = r#"{
2025 "data": [{
2026 "files": [{"filename": "/abs/a.rs"}, {"filename": "/abs/b.rs"}],
2027 "functions": [{
2028 "filenames": ["/abs/a.rs", "/abs/b.rs"],
2029 "regions": [
2030 [1, 1, 1, 5, 1, 0, 0, 0],
2031 [9, 1, 9, 5, 0, 1, 1, 0]
2032 ]
2033 }]
2034 }]
2035 }"#;
2036 let out = patch_detail(json);
2037 assert_eq!(out["/abs/a.rs"].regions, vec![(1, 1, true)]);
2038 assert_eq!(out["/abs/b.rs"].regions, vec![(9, 9, false)]);
2039 }
2040
2041 #[test]
2042 fn llvm_cov_patch_detail_skips_a_malformed_short_region() {
2043 let json = r#"{
2044 "data": [{
2045 "files": [{"filename": "/abs/a.rs"}],
2046 "functions": [{
2047 "filenames": ["/abs/a.rs"],
2048 "regions": [
2049 [4, 1, 4],
2050 [5, 1, 5, 9, 1, 0, 0, 0]
2051 ]
2052 }]
2053 }]
2054 }"#;
2055 let out = patch_detail(json);
2056 assert_eq!(out["/abs/a.rs"].regions, vec![(5, 5, true)]);
2057 }
2058
2059 #[test]
2060 fn llvm_cov_patch_detail_spans_a_multiline_region() {
2061 let json = r#"{
2062 "data": [{
2063 "files": [{"filename": "/abs/a.rs"}],
2064 "functions": [{
2065 "filenames": ["/abs/a.rs"],
2066 "regions": [[3, 5, 5, 6, 0, 0, 0, 0]]
2067 }]
2068 }]
2069 }"#;
2070 let out = patch_detail(json);
2071 assert_eq!(out["/abs/a.rs"].regions, vec![(3, 5, false)]);
2072 }
2073
2074 #[test]
2075 fn llvm_cov_patch_detail_drops_a_file_absent_from_the_files_allowlist() {
2076 let json = r#"{
2077 "data": [{
2078 "files": [{"filename": "/abs/kept.rs"}],
2079 "functions": [{
2080 "filenames": ["/abs/kept.rs", "/abs/ignored.rs"],
2081 "regions": [
2082 [1, 1, 1, 9, 1, 0, 0, 0],
2083 [2, 1, 2, 9, 0, 1, 0, 0]
2084 ]
2085 }]
2086 }]
2087 }"#;
2088 let out = patch_detail(json);
2089 assert_eq!(out["/abs/kept.rs"].regions, vec![(1, 1, true)]);
2090 assert!(!out.contains_key("/abs/ignored.rs"));
2091 }
2092
2093 #[test]
2094 fn llvm_cov_patch_detail_malformed_json_is_an_error() {
2095 assert!(llvm_cov_patch_detail("{ not json", &BTreeMap::new()).is_err());
2096 }
2097
2098 #[test]
2099 fn llvm_cov_patch_detail_skips_a_negative_file_id() {
2100 let json = r#"{
2101 "data": [{
2102 "files": [{"filename": "/abs/a.rs"}],
2103 "functions": [{
2104 "filenames": ["/abs/a.rs"],
2105 "regions": [[1, 1, 1, 5, 1, -1, 0, 0]]
2106 }]
2107 }]
2108 }"#;
2109 let out = patch_detail(json);
2110 assert!(out.is_empty(), "got: {out:?}");
2111 }
2112
2113 #[test]
2114 fn llvm_cov_patch_detail_skips_an_out_of_range_file_id() {
2115 let json = r#"{
2116 "data": [{
2117 "files": [{"filename": "/abs/a.rs"}],
2118 "functions": [{
2119 "filenames": ["/abs/a.rs"],
2120 "regions": [[1, 1, 1, 5, 1, 7, 0, 0]]
2121 }]
2122 }]
2123 }"#;
2124 let out = patch_detail(json);
2125 assert!(out.is_empty(), "got: {out:?}");
2126 }
2127
2128 #[test]
2129 fn istanbul_patch_detail_reads_statements_arms_and_functions() {
2130 let json = r#"{
2131 "/abs/a.ts": {
2132 "statementMap": {"0": {"start": {"line": 1}, "end": {"line": 2}}},
2133 "s": {"0": 1},
2134 "branchMap": {"0": {"loc": {"start": {"line": 3}, "end": {"line": 3}}}},
2135 "b": {"0": [1, 0]},
2136 "fnMap": {"0": {"decl": {"start": {"line": 7}, "end": {"line": 7}}}},
2137 "f": {"0": 0}
2138 }
2139 }"#;
2140 let out = istanbul_patch_detail(json).expect("valid Istanbul report");
2141 let detail = &out["/abs/a.ts"];
2142 assert_eq!(detail.statements, vec![(1, 2, true)]);
2143 assert_eq!(detail.branch_arms, vec![(3, true), (3, false)]);
2144 assert_eq!(detail.functions, vec![(7, false)]);
2145 }
2146
2147 #[test]
2148 fn istanbul_patch_detail_keeps_a_branch_without_counts() {
2149 let json = r#"{
2150 "/abs/a.ts": {
2151 "statementMap": {},
2152 "s": {},
2153 "branchMap": {"0": {"loc": {"start": {"line": 3}, "end": {"line": 3}}}},
2154 "b": {},
2155 "fnMap": {},
2156 "f": {}
2157 }
2158 }"#;
2159 let out = istanbul_patch_detail(json).expect("valid Istanbul report");
2160 assert!(out["/abs/a.ts"].branch_arms.is_empty(), "got: {out:?}");
2161 }
2162
2163 #[test]
2164 fn default_excludes_that_are_not_json_name_the_output() {
2165 let err = parse_default_excludes(b"vitest warmed up first").unwrap_err();
2166 let msg = format!("{err:#}");
2167 assert!(msg.contains("not a JSON string array"), "got: {msg}");
2168 assert!(msg.contains("vitest warmed up first"), "got: {msg}");
2169 }
2170
2171 #[test]
2172 fn default_excludes_drop_a_nul_bearing_pattern() {
2173 let parsed = parse_default_excludes(br#"["**/dist/**", "**/\u0000*"]"#).unwrap();
2174 assert_eq!(parsed, vec!["**/dist/**".to_string()]);
2175 }
2176
2177 #[test]
2178 fn a_missing_vitest_report_names_the_reporter() {
2179 let path = std::env::temp_dir().join("tc-no-such-report/coverage-final.json");
2180 let err = read_vitest_report(&path, "json").unwrap_err();
2181 assert!(format!("{err:#}").contains("json report"), "got: {err:#}");
2182 }
2183
2184 #[test]
2185 fn rust_ignore_regex_is_none_when_nothing_is_exempt() {
2186 assert_eq!(ignore_filename_regex(Path::new("/repo"), &[]), None);
2187 }
2188
2189 #[test]
2190 fn rust_run_argv_carries_format_features_branch_and_ignore_regex() {
2191 assert_eq!(
2192 run_argv(
2193 &["--json"],
2194 &["alpha".to_string(), "beta".to_string()],
2195 true,
2196 Some("src/ignored\\.rs$".to_string()),
2197 ),
2198 [
2199 "llvm-cov",
2200 "--lib",
2201 "--bins",
2202 "--json",
2203 "--features",
2204 "alpha,beta",
2205 "--branch",
2206 "--ignore-filename-regex",
2207 "src/ignored\\.rs$",
2208 ]
2209 );
2210 assert_eq!(
2211 run_argv(&["--lcov"], &[], false, None),
2212 ["llvm-cov", "--lib", "--bins", "--lcov"]
2213 );
2214 }
2215
2216 #[test]
2217 fn vitest_coverage_argv_names_the_reporter_the_report_dir_and_every_exclude() {
2218 let excludes = ["dist/**".to_string(), "src/generated/**".to_string()];
2219 let argv = vitest_coverage_argv("json-summary", Path::new("/tmp/reports"), excludes.iter());
2220
2221 assert_eq!(argv[0], "--no-install");
2222 assert_eq!(argv[1], "vitest");
2223 assert!(argv.contains(&"--coverage.all=true".to_string()));
2224 assert!(argv.contains(&"--coverage.thresholds.autoUpdate=false".to_string()));
2225 assert!(argv.contains(&"--coverage.reporter=json-summary".to_string()));
2226 assert!(argv.contains(&"--coverage.reportsDirectory=/tmp/reports".to_string()));
2227 assert!(argv.contains(&format!("--coverage.include={TS_INCLUDE}")));
2228 assert_eq!(
2229 argv.iter()
2230 .filter(|arg| arg.starts_with("--coverage.exclude="))
2231 .collect::<Vec<_>>(),
2232 [
2233 "--coverage.exclude=dist/**",
2234 "--coverage.exclude=src/generated/**"
2235 ]
2236 );
2237 }
2238
2239 #[test]
2240 fn vitest_coverage_argv_never_passes_yes_so_a_missing_vitest_fails_clean() {
2241 let argv = vitest_coverage_argv("json", Path::new("/tmp/r"), [].iter());
2242
2243 assert!(!argv.iter().any(|arg| arg == "--yes"));
2244 }
2245
2246 #[test]
2247 fn a_metric_at_or_above_its_floor_names_no_shortfall() {
2248 assert_eq!(shortfall("lines", 100.0, 100), None);
2249 assert_eq!(shortfall("lines", 90.0, 90), None);
2250 assert_eq!(shortfall("lines", 89.999999999, 90), None);
2252 }
2253
2254 #[test]
2255 fn a_metric_below_its_floor_names_the_gap() {
2256 assert_eq!(
2257 shortfall("branches", 72.5, 80),
2258 Some("branches 72.50% < 80%".to_string())
2259 );
2260 }
2261
2262 #[test]
2263 fn a_verdict_passes_on_no_shortfalls_and_names_every_one_otherwise() {
2264 assert_eq!(verdict(Vec::new()), Outcome::Pass);
2265 assert_eq!(
2266 verdict(vec![
2267 "lines 1.00% < 2%".to_string(),
2268 "b 3.00% < 4%".to_string()
2269 ]),
2270 Outcome::Fail("coverage below thresholds: lines 1.00% < 2%, b 3.00% < 4%".to_string())
2271 );
2272 }
2273
2274 #[test]
2275 fn rust_checks_floor_lines_always_and_the_rest_only_when_configured() {
2276 let totals = rust_report_full(10.0, 20.0, 30.0, (4, 50.0)).data[0].totals;
2277
2278 assert_eq!(
2279 rust_checks(
2280 &totals,
2281 RustThresholds {
2282 lines: 90,
2283 regions: None,
2284 functions: None,
2285 branch: None,
2286 }
2287 ),
2288 [("lines", 20.0, 90)]
2289 );
2290 assert_eq!(
2291 rust_checks(
2292 &totals,
2293 RustThresholds {
2294 lines: 90,
2295 regions: Some(80),
2296 functions: Some(70),
2297 branch: Some(60),
2298 }
2299 ),
2300 [
2301 ("regions", 10.0, 80),
2302 ("lines", 20.0, 90),
2303 ("functions", 30.0, 70),
2304 ("branches", 50.0, 60),
2305 ]
2306 );
2307 }
2308
2309 #[test]
2310 fn a_branch_floor_over_a_branchless_crate_is_vacuously_satisfied() {
2311 let totals = rust_report_full(10.0, 20.0, 30.0, (0, 0.0)).data[0].totals;
2312
2313 assert_eq!(
2314 rust_checks(
2315 &totals,
2316 RustThresholds {
2317 lines: 90,
2318 regions: None,
2319 functions: None,
2320 branch: Some(60),
2321 }
2322 ),
2323 [("lines", 20.0, 90)]
2324 );
2325 }
2326
2327 #[test]
2328 fn a_code_region_in_a_measured_file_contributes_its_span_and_hit_state() {
2329 let names = ["src/a.rs".to_string()];
2330
2331 assert_eq!(
2332 patch_region(
2333 &[7, 1, 9, 20, 3, 0, 0, 0],
2334 &names,
2335 &BTreeSet::from(["src/a.rs"]),
2336 &BTreeMap::new()
2337 ),
2338 Some(("src/a.rs".to_string(), 7, 9, true))
2339 );
2340 assert_eq!(
2341 patch_region(
2342 &[7, 1, 9, 20, 0, 0, 0, 0],
2343 &names,
2344 &BTreeSet::from(["src/a.rs"]),
2345 &BTreeMap::new()
2346 ),
2347 Some(("src/a.rs".to_string(), 7, 9, false))
2348 );
2349 }
2350
2351 #[test]
2352 fn a_region_carrying_no_line_signal_contributes_nothing() {
2353 let names = ["src/a.rs".to_string()];
2354 let measured = BTreeSet::from(["src/a.rs"]);
2355 let nothing_hidden = BTreeMap::new();
2356 let reject = |region: &[i64]| patch_region(region, &names, &measured, ¬hing_hidden);
2357
2358 assert_eq!(reject(&[7, 1, 9, 20, 3, 0, 0]), None);
2360 assert_eq!(reject(&[7, 1, 9, 20, 3, 0, 0, 1]), None);
2362 assert_eq!(reject(&[7, 1, 9, 20, 3, 9, 0, 0]), None);
2364 assert_eq!(reject(&[7, 1, 9, 20, 3, -1, 0, 0]), None);
2366 assert_eq!(
2368 patch_region(
2369 &[7, 1, 9, 20, 3, 0, 0, 0],
2370 &names,
2371 &BTreeSet::new(),
2372 ¬hing_hidden
2373 ),
2374 None
2375 );
2376 assert_eq!(
2378 patch_region(
2379 &[7, 1, 9, 20, 3, 0, 0, 0],
2380 &names,
2381 &measured,
2382 &BTreeMap::from([("src/a.rs".to_string(), BTreeSet::from([7u32]))])
2383 ),
2384 None
2385 );
2386 }
2387
2388 #[test]
2389 fn rust_report_argv_carries_format_and_optional_ignore_regex() {
2390 assert_eq!(
2391 report_argv(&["--lcov"], Some("src/ignored\\.rs$".to_string())),
2392 [
2393 "llvm-cov",
2394 "report",
2395 "--lcov",
2396 "--ignore-filename-regex",
2397 "src/ignored\\.rs$"
2398 ]
2399 );
2400 assert_eq!(
2401 report_argv(&["--json"], None),
2402 ["llvm-cov", "report", "--json"]
2403 );
2404 }
2405
2406 #[test]
2407 fn branch_hint_names_the_nightly_requirement_only_for_branch_coverage() {
2408 assert_eq!(branch_hint(false), "");
2409 assert!(branch_hint(true).contains("requires a nightly toolchain"));
2410 }
2411
2412 #[test]
2413 fn aiming_llvm_cov_points_cargo_at_the_crate_and_strips_the_outer_run() {
2414 let mut command = Command::new("cargo");
2415 command.env("RUSTC_WRAPPER", "outer");
2416 aim_llvm_cov(
2417 &mut command,
2418 &TargetDir(PathBuf::from("/tmp/tc-aim")),
2419 Path::new("/repo/crate"),
2420 vec!["llvm-cov".to_string(), "--json".to_string()],
2421 );
2422 assert_eq!(command.get_current_dir(), Some(Path::new("/repo/crate")));
2423 assert_eq!(
2424 command
2425 .get_args()
2426 .map(|arg| arg.to_string_lossy().into_owned())
2427 .collect::<Vec<_>>(),
2428 vec!["llvm-cov", "--json"]
2429 );
2430 let env = |name: &str| {
2431 command
2432 .get_envs()
2433 .find(|(key, _)| key.to_str() == Some(name))
2434 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
2435 };
2436 assert_eq!(
2437 env("CARGO_TARGET_DIR"),
2438 Some(Some("/tmp/tc-aim".to_string()))
2439 );
2440 assert_eq!(env("RUSTC_WRAPPER"), Some(None));
2442 }
2443
2444 #[test]
2445 fn a_failed_llvm_cov_run_names_the_crate_and_only_branch_runs_name_nightly() {
2446 let plain = llvm_cov_failure(Path::new("/repo/crate"), false);
2447 assert_eq!(
2448 plain,
2449 "the unit suite did not run cleanly under cargo llvm-cov in `/repo/crate`:"
2450 );
2451 let branched = llvm_cov_failure(Path::new("/repo/crate"), true);
2452 assert!(branched.starts_with(&plain), "got: {branched}");
2453 assert!(
2454 branched.contains("requires a nightly toolchain"),
2455 "got: {branched}"
2456 );
2457 }
2458
2459 #[test]
2460 fn scrub_removes_outer_llvm_cov_state_from_a_command() {
2461 let mut command = Command::new("cargo");
2462 for name in [
2463 "RUSTFLAGS",
2464 "CARGO_ENCODED_RUSTFLAGS",
2465 "RUSTDOCFLAGS",
2466 "CARGO_ENCODED_RUSTDOCFLAGS",
2467 "LLVM_PROFILE_FILE",
2468 "CARGO_LLVM_COV",
2469 "CARGO_LLVM_COV_SHOW_ENV",
2470 "CARGO_LLVM_COV_TARGET_DIR",
2471 "CARGO_LLVM_COV_BUILD_DIR",
2472 "RUSTC_WRAPPER",
2473 "RUSTC_WORKSPACE_WRAPPER",
2474 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
2475 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
2476 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
2477 "RUSTUP_TOOLCHAIN",
2478 "CARGO",
2479 "RUSTC",
2480 ] {
2481 command.env(name, "outer");
2482 }
2483 command.env("KEEP", "value");
2484 scrub_outer_llvm_cov(&mut command);
2485 let envs: Vec<_> = command.get_envs().collect();
2486 assert_eq!(envs.len(), 18);
2487 assert!(envs
2488 .iter()
2489 .all(|(name, value)| { name == &"KEEP" || value.is_none() }));
2490 assert_eq!(
2491 envs.iter().find(|(name, _)| *name == "KEEP").unwrap().1,
2492 Some("value".as_ref())
2493 );
2494 }
2495
2496 #[test]
2497 fn rust_ignore_regex_anchors_each_exempt_path_to_its_full_path() {
2498 let exempt = vec!["src/shim.rs".to_string(), "src/gen.rs".to_string()];
2500 assert_eq!(
2501 ignore_filename_regex(Path::new("/repo"), &exempt).as_deref(),
2502 Some(r"/repo/src/shim\.rs$|/repo/src/gen\.rs$")
2503 );
2504 }
2505
2506 fn llvm_would_ignore(regex: &str, filename: &str) -> bool {
2509 regex.split('|').any(|alt| {
2510 let (lit, anchored) = match alt.strip_suffix('$') {
2511 Some(head) => (head, true),
2512 None => (alt, false),
2513 };
2514 let lit = lit.replace('\\', "");
2515 if anchored {
2516 filename.ends_with(&lit)
2517 } else {
2518 filename.contains(&lit)
2519 }
2520 })
2521 }
2522
2523 #[test]
2524 fn llvm_would_ignore_matches_an_unanchored_literal_anywhere() {
2525 assert!(llvm_would_ignore("/repo/src", "/repo/src/a.rs"));
2526 assert!(!llvm_would_ignore("/elsewhere", "/repo/src/a.rs"));
2527 }
2528
2529 #[test]
2530 fn rust_ignore_regex_does_not_over_match_a_member_with_the_same_suffix() {
2531 let regex = ignore_filename_regex(Path::new("/repo"), &["src/a.rs".to_string()]).unwrap();
2532 assert!(
2533 llvm_would_ignore(®ex, "/repo/src/a.rs"),
2534 "the exempted file must still be ignored: {regex}"
2535 );
2536 assert!(
2537 !llvm_would_ignore(®ex, "/repo/member/src/a.rs"),
2538 "`src/a.rs` over-matched `member/src/a.rs`: {regex}"
2539 );
2540 assert!(
2541 !llvm_would_ignore(®ex, "/repo/src/xsrc/a.rs"),
2542 "`src/a.rs` over-matched `src/xsrc/a.rs`: {regex}"
2543 );
2544 }
2545
2546 const GATED_EXPORT: &str = r#"{
2549 "data": [{
2550 "files": [{"filename": "/abs/entrypoint.rs"}],
2551 "functions": [
2552 {"name": "report", "count": 1, "filenames": ["/abs/entrypoint.rs"], "branches": [],
2553 "regions": [[11, 1, 11, 37, 1, 0, 0, 0], [12, 5, 12, 19, 1, 0, 0, 0],
2554 [12, 20, 12, 30, 1, 0, 0, 0], [13, 1, 13, 2, 1, 0, 0, 0]]},
2555 {"name": "main", "count": 0, "filenames": ["/abs/entrypoint.rs"], "branches": [],
2556 "regions": [[6, 1, 6, 26, 0, 0, 0, 0], [7, 5, 7, 11, 0, 0, 0, 0],
2557 [7, 12, 7, 46, 0, 0, 0, 0], [8, 1, 8, 2, 0, 0, 0, 0]]},
2558 {"name": "report", "count": 0, "filenames": ["/abs/entrypoint.rs"], "branches": [],
2559 "regions": [[11, 1, 11, 37, 0, 0, 0, 0], [12, 5, 12, 19, 0, 0, 0, 0],
2560 [12, 20, 12, 30, 0, 0, 0, 0], [13, 1, 13, 2, 0, 0, 0, 0]]}
2561 ],
2562 "totals": {
2563 "regions": {"count": 13, "covered": 9, "percent": 69.23},
2564 "lines": {"count": 9, "covered": 6, "percent": 66.67},
2565 "functions": {"count": 3, "covered": 2, "percent": 66.67}
2566 }
2567 }]
2568 }"#;
2569
2570 fn gated_main() -> BTreeMap<String, BTreeSet<u32>> {
2572 BTreeMap::from([(
2573 "/abs/entrypoint.rs".to_string(),
2574 BTreeSet::from([5, 6, 7, 8]),
2575 )])
2576 }
2577
2578 fn totals_less(json: &str, hidden: &BTreeMap<String, BTreeSet<u32>>) -> LlvmCovTotals {
2580 let export = parse_llvm_cov_export(json).expect("valid llvm-cov export");
2581 llvm_cov_totals_less_hidden(&export, hidden).data[0].totals
2582 }
2583
2584 #[test]
2585 fn a_gated_entry_point_leaves_every_ratio_full() {
2586 let totals = totals_less(GATED_EXPORT, &gated_main());
2587 assert_eq!((totals.regions.count, totals.regions.covered), (9, 9));
2588 assert_eq!((totals.lines.count, totals.lines.covered), (6, 6));
2589 assert_eq!((totals.functions.count, totals.functions.covered), (2, 2));
2590 assert_eq!(totals.regions.percent, 100.0);
2591 }
2592
2593 #[test]
2594 fn an_export_with_nothing_gated_keeps_its_totals() {
2595 let totals = totals_less(GATED_EXPORT, &BTreeMap::new());
2596 assert_eq!((totals.regions.count, totals.regions.covered), (13, 9));
2597 assert_eq!((totals.lines.count, totals.lines.covered), (9, 6));
2598 assert_eq!((totals.functions.count, totals.functions.covered), (3, 2));
2599 }
2600
2601 #[test]
2602 fn two_copies_of_one_gated_item_subtract_once() {
2603 let json = GATED_EXPORT.replace(
2604 r#"{"name": "report", "count": 1"#,
2605 r#"{"name": "main", "count": 0, "filenames": ["/abs/entrypoint.rs"], "branches": [],
2606 "regions": [[6, 1, 6, 26, 0, 0, 0, 0], [7, 5, 7, 11, 0, 0, 0, 0],
2607 [7, 12, 7, 46, 0, 0, 0, 0], [8, 1, 8, 2, 0, 0, 0, 0]]},
2608 {"name": "report", "count": 1"#,
2609 );
2610 let totals = totals_less(&json, &gated_main());
2611 assert_eq!((totals.regions.count, totals.regions.covered), (9, 9));
2612 assert_eq!(totals.functions.count, 2);
2613 }
2614
2615 #[test]
2616 fn a_record_with_no_region_to_place_it_is_not_subtracted() {
2617 let json = r#"{
2618 "data": [{
2619 "files": [{"filename": "/abs/a.rs"}],
2620 "functions": [{"name": "empty", "count": 0, "filenames": ["/abs/a.rs"],
2621 "regions": [], "branches": []}],
2622 "totals": {
2623 "regions": {"count": 2, "covered": 2, "percent": 100.0},
2624 "lines": {"count": 2, "covered": 2, "percent": 100.0},
2625 "functions": {"count": 1, "covered": 1, "percent": 100.0}
2626 }
2627 }]
2628 }"#;
2629 let hidden = BTreeMap::from([("/abs/a.rs".to_string(), BTreeSet::from([1]))]);
2630 assert_eq!(totals_less(json, &hidden).regions.count, 2);
2631 }
2632
2633 #[test]
2634 fn a_record_outside_the_files_allowlist_is_not_subtracted() {
2635 let hidden = BTreeMap::from([("/abs/other.rs".to_string(), BTreeSet::from([6]))]);
2636 let json = GATED_EXPORT.replace("\"/abs/entrypoint.rs\"],", "\"/abs/other.rs\"],");
2637 assert_eq!(totals_less(&json, &hidden).regions.count, 13);
2638 }
2639
2640 #[test]
2641 fn a_gated_branch_drops_both_of_its_arms() {
2642 let json = r#"{
2643 "data": [{
2644 "files": [{"filename": "/abs/a.rs"}],
2645 "functions": [{"name": "main", "count": 0, "filenames": ["/abs/a.rs"],
2646 "regions": [[1, 1, 3, 2, 0, 0, 0, 0]],
2647 "branches": [[2, 9, 2, 14, 0, 0, 0, 0, 4], [2, 9, 2, 14, 0, 0, 0, 0]]}],
2648 "totals": {
2649 "regions": {"count": 5, "covered": 4, "percent": 80.0},
2650 "lines": {"count": 9, "covered": 6, "percent": 66.67},
2651 "functions": {"count": 3, "covered": 2, "percent": 66.67},
2652 "branches": {"count": 6, "covered": 4, "percent": 66.67}
2653 }
2654 }]
2655 }"#;
2656 let hidden = BTreeMap::from([("/abs/a.rs".to_string(), BTreeSet::from([1, 2, 3]))]);
2657 let branches = totals_less(json, &hidden).branches.expect("branch totals");
2658 assert_eq!((branches.count, branches.covered), (4, 4));
2660 }
2661
2662 #[test]
2663 fn a_metric_the_subtraction_empties_reads_full() {
2664 let metric = LlvmCovMetric {
2665 count: 4,
2666 covered: 0,
2667 percent: 0.0,
2668 };
2669 let emptied = metric_less(
2670 metric,
2671 Tally {
2672 count: 9,
2673 covered: 9,
2674 },
2675 );
2676 assert_eq!((emptied.count, emptied.covered), (0, 0));
2677 assert_eq!(emptied.percent, 100.0);
2678 }
2679
2680 const TWO_GROUPS_LCOV: &str = "SF:/abs/a.rs\nDA:1,4\nDA:2,4\nDA:3,0\n\
2683 SF:/abs/a.rs\nDA:1,0\nDA:2,0\nDA:3,7\nend_of_record\n";
2684
2685 #[test]
2686 fn a_line_one_instantiation_ran_counts_once_covered() {
2687 let lines = lcov_lines_metric(TWO_GROUPS_LCOV, &BTreeMap::new());
2688 assert_eq!((lines.count, lines.covered), (3, 3));
2689 assert_eq!(lines.percent, 100.0);
2690 }
2691
2692 #[test]
2693 fn a_gated_line_leaves_the_lcov_denominator() {
2694 let hidden = BTreeMap::from([("/abs/a.rs".to_string(), BTreeSet::from([3]))]);
2695 let lines = lcov_lines_metric(TWO_GROUPS_LCOV, &hidden);
2696 assert_eq!((lines.count, lines.covered), (2, 2));
2697 }
2698
2699 #[test]
2700 fn a_line_no_instantiation_ran_is_still_missing() {
2701 let lcov = "SF:/abs/a.rs\nDA:1,4\nDA:2,0\nend_of_record\n";
2702 let lines = lcov_lines_metric(lcov, &BTreeMap::new());
2703 assert_eq!((lines.count, lines.covered), (2, 1));
2704 }
2705
2706 #[test]
2707 fn records_outside_a_source_file_are_not_lines() {
2708 let lcov = "DA:1,4\nSF:/abs/a.rs\nFN:1,main\nDA:x,4\nDA:2\nDA:3,y\nDA:4,1\nLF:4\n";
2709 assert_eq!(
2710 lcov_lines(lcov),
2711 BTreeMap::from([("/abs/a.rs".to_string(), BTreeMap::from([(4, true)]))]),
2712 "a `DA:` before any `SF:`, and a malformed one after, name no line"
2713 );
2714 }
2715
2716 #[test]
2717 fn a_failed_export_carries_both_streams() {
2718 let err = llvm_cov_stdout(false, b"out\n", b"err\n", "could not re-export:")
2719 .expect_err("a non-zero exit is an error");
2720 assert_eq!(format!("{err}"), "could not re-export:\nout\nerr\n");
2721 }
2722
2723 #[test]
2724 fn an_export_measuring_nothing_reads_full() {
2725 let lines = lcov_lines_metric("", &BTreeMap::new());
2726 assert_eq!((lines.count, lines.covered), (0, 0));
2727 assert_eq!(lines.percent, 100.0);
2728 }
2729
2730 #[test]
2731 fn a_function_record_without_a_usable_first_region_has_no_group() {
2732 let short = LlvmCovFunction {
2733 filenames: vec!["/abs/a.rs".to_string()],
2734 regions: vec![vec![1, 1, 1, 2]],
2735 count: 0,
2736 branches: Vec::new(),
2737 };
2738 let unnamed = LlvmCovFunction {
2739 filenames: Vec::new(),
2740 regions: vec![vec![1, 1, 1, 2, 0, 7, 0, 0]],
2741 count: 0,
2742 branches: Vec::new(),
2743 };
2744 let empty = LlvmCovFunction {
2745 filenames: vec!["/abs/a.rs".to_string()],
2746 regions: Vec::new(),
2747 count: 0,
2748 branches: Vec::new(),
2749 };
2750 assert_eq!(function_start(&short), None);
2751 assert_eq!(function_start(&unnamed), None);
2752 assert_eq!(function_start(&empty), None);
2753 }
2754
2755 #[test]
2756 fn a_gated_region_drops_out_of_the_changed_line_detail() {
2757 let hidden =
2758 BTreeMap::from([("/abs/entrypoint.rs".to_string(), BTreeSet::from([6, 7, 8]))]);
2759 let detail = llvm_cov_patch_detail(GATED_EXPORT, &hidden).expect("valid export");
2760 assert_eq!(
2761 detail["/abs/entrypoint.rs"]
2762 .regions
2763 .iter()
2764 .map(|(start, _, _)| *start)
2765 .collect::<BTreeSet<_>>(),
2766 BTreeSet::from([11, 12, 13])
2767 );
2768 }
2769
2770 #[test]
2771 fn hidden_lines_come_from_the_sources_the_export_measured() {
2772 let dir = std::env::temp_dir().join(format!("tc-cov-hidden-{}", std::process::id()));
2773 std::fs::create_dir_all(&dir).unwrap();
2774 let gated = dir.join("gated.rs");
2775 std::fs::write(&gated, "#[cfg(not(test))]\nfn main() {}\n").unwrap();
2776 let json = format!(
2777 r#"{{"data": [{{"files": [{{"filename": "{}"}}, {{"filename": "{}"}}],
2778 "functions": []}}]}}"#,
2779 gated.display(),
2780 dir.join("absent.rs").display(),
2781 );
2782 let hidden = hidden_lines_by_file(&json).expect("valid export");
2783 std::fs::remove_dir_all(&dir).ok();
2784 assert_eq!(
2785 hidden,
2786 BTreeMap::from([(gated.display().to_string(), BTreeSet::from([1, 2]))]),
2787 "an unreadable source hides nothing"
2788 );
2789 }
2790
2791 #[test]
2792 fn a_clean_vitest_exit_raises_nothing() {
2793 assert!(vitest_exit(true, Path::new("/tmp/project"), b"ignored", b"ignored").is_ok());
2794 }
2795
2796 #[test]
2797 fn a_failed_vitest_exit_names_the_project_both_streams_and_the_no_install_rule() {
2798 let err = vitest_exit(
2799 false,
2800 Path::new("/tmp/project"),
2801 b"out detail",
2802 b"err detail",
2803 )
2804 .unwrap_err()
2805 .to_string();
2806
2807 assert!(err.contains("/tmp/project"), "got: {err}");
2808 assert!(err.contains("out detail"), "got: {err}");
2809 assert!(err.contains("err detail"), "got: {err}");
2810 assert!(err.contains("npx --no-install"), "got: {err}");
2811 assert!(err.contains("@vitest/coverage-v8"), "got: {err}");
2812 }
2813}