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_file_detail(file: &IstanbulFile) -> TsPatchCoverage {
596 let mut detail = TsPatchCoverage::default();
597 for (id, span) in &file.statement_map {
598 let covered = file.s.get(id).is_some_and(|&count| count > 0);
599 detail
600 .statements
601 .push((span.start.line, span.end.line, covered));
602 }
603 for (id, branch) in &file.branch_map {
604 let line = branch.loc.start.line;
605 if let Some(counts) = file.b.get(id) {
606 for &count in counts {
607 detail.branch_arms.push((line, count > 0));
608 }
609 }
610 }
611 for (id, function) in &file.fn_map {
612 let covered = file.f.get(id).is_some_and(|&count| count > 0);
613 detail.functions.push((function.decl.start.line, covered));
614 }
615 detail
616}
617
618#[derive(Debug, Clone, Copy, PartialEq, Eq)]
622pub struct RustThresholds {
623 pub regions: Option<u8>,
624 pub lines: u8,
625 pub functions: Option<u8>,
626 pub branch: Option<u8>,
627}
628
629#[derive(Debug, Clone, Deserialize)]
632pub struct LlvmCovReport {
633 pub data: Vec<LlvmCovData>,
634}
635
636#[derive(Debug, Clone, Copy, Deserialize)]
638pub struct LlvmCovData {
639 pub totals: LlvmCovTotals,
640}
641
642#[derive(Debug, Clone, Copy, Default, Deserialize)]
645pub struct LlvmCovTotals {
646 pub regions: LlvmCovMetric,
647 pub lines: LlvmCovMetric,
648 pub functions: LlvmCovMetric,
649 #[serde(default)]
650 pub branches: Option<LlvmCovMetric>,
651}
652
653#[derive(Debug, Clone, Copy, Default, Deserialize)]
655pub struct LlvmCovMetric {
656 pub count: u64,
658 pub covered: u64,
659 pub percent: f64,
660}
661
662pub fn parse_llvm_cov_report(json: &str) -> Result<LlvmCovReport> {
664 serde_json::from_str(json).context("parsing cargo llvm-cov JSON report")
665}
666
667pub fn evaluate_rust(report: &LlvmCovReport, thresholds: RustThresholds) -> Outcome {
670 let Some(totals) = report.data.first().map(|entry| &entry.totals) else {
671 return Outcome::Fail("the cargo llvm-cov report contained no data".to_string());
672 };
673 if totals.regions.count == 0 {
675 return Outcome::Fail(NOTHING_MEASURED.to_string());
676 }
677 verdict(
678 rust_checks(totals, thresholds)
679 .into_iter()
680 .filter_map(|(name, actual, required)| shortfall(name, actual, required))
681 .collect(),
682 )
683}
684
685fn rust_checks(totals: &LlvmCovTotals, thresholds: RustThresholds) -> Vec<(&'static str, f64, u8)> {
688 let mut checks = Vec::new();
689 if let Some(regions) = thresholds.regions {
690 checks.push(("regions", totals.regions.percent, regions));
691 }
692 checks.push(("lines", totals.lines.percent, thresholds.lines));
693 if let Some(functions) = thresholds.functions {
694 checks.push(("functions", totals.functions.percent, functions));
695 }
696 if let Some(branch) = thresholds.branch {
697 if let Some(branches) = totals.branches.filter(|metric| metric.count > 0) {
700 checks.push(("branches", branches.percent, branch));
701 }
702 }
703 checks
704}
705
706pub fn measure_rust(
710 root: &Path,
711 thresholds: RustThresholds,
712 ignore: &[String],
713 features: &[String],
714) -> Result<Outcome> {
715 let report = run_llvm_cov(root, ignore, features, thresholds.branch.is_some())?;
716 Ok(evaluate_rust(&report, thresholds))
717}
718
719struct TargetDir(PathBuf);
722
723impl TargetDir {
724 fn new() -> Self {
725 static COUNTER: AtomicU64 = AtomicU64::new(0);
726 let name = format!(
727 "testing-conventions-llvm-cov-{}-{}",
728 std::process::id(),
729 COUNTER.fetch_add(1, Ordering::Relaxed),
730 );
731 TargetDir(std::env::temp_dir().join(name))
732 }
733}
734
735impl Drop for TargetDir {
736 fn drop(&mut self) {
737 let _ = std::fs::remove_dir_all(&self.0);
738 }
739}
740
741fn run_llvm_cov(
746 root: &Path,
747 ignore: &[String],
748 features: &[String],
749 branch: bool,
750) -> Result<LlvmCovReport> {
751 let (json, lcov) = run_cargo_llvm_cov_json_and_lcov(root, ignore, features, branch)?;
752 let hidden = hidden_lines_by_file(&json)?;
753 let mut report = llvm_cov_totals_less_hidden(&parse_llvm_cov_export(&json)?, &hidden);
754 let lines = lcov_lines_metric(&lcov, &hidden);
755 for data in &mut report.data {
756 data.totals.lines = lines;
757 }
758 Ok(report)
759}
760
761fn lcov_lines(lcov: &str) -> BTreeMap<String, BTreeMap<u32, bool>> {
763 let mut out: BTreeMap<String, BTreeMap<u32, bool>> = BTreeMap::new();
764 let mut file: Option<&str> = None;
765 for record in lcov.lines() {
766 if let Some(name) = record.strip_prefix("SF:") {
767 file = Some(name);
768 } else if let Some((number, count)) =
769 record.strip_prefix("DA:").and_then(|da| da.split_once(','))
770 {
771 let (Some(file), Ok(number), Ok(count)) =
772 (file, number.parse::<u32>(), count.trim().parse::<u64>())
773 else {
774 continue;
775 };
776 *out.entry(file.to_string())
777 .or_default()
778 .entry(number)
779 .or_default() |= count > 0;
780 }
781 }
782 out
783}
784
785fn lcov_lines_metric(lcov: &str, hidden: &BTreeMap<String, BTreeSet<u32>>) -> LlvmCovMetric {
794 let mut tally = Tally::default();
795 for (file, lines) in lcov_lines(lcov) {
796 let gated = hidden.get(&file);
797 for (number, covered) in lines {
798 if gated.is_some_and(|gated| gated.contains(&number)) {
799 continue;
800 }
801 tally.count += 1;
802 tally.covered += u64::from(covered);
803 }
804 }
805 as_metric(tally.count, tally.covered)
806}
807
808fn hidden_lines_by_file(json: &str) -> Result<BTreeMap<String, BTreeSet<u32>>> {
816 let export = parse_llvm_cov_export(json)?;
817 let mut out = BTreeMap::new();
818 for data in &export.data {
819 for file in &data.files {
820 let Ok(source) = std::fs::read_to_string(&file.filename) else {
821 continue;
822 };
823 let lines = crate::isolation::lines_hidden_from_tests(&source);
824 if !lines.is_empty() {
825 out.insert(file.filename.clone(), lines);
826 }
827 }
828 }
829 Ok(out)
830}
831
832#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
834struct Tally {
835 count: u64,
836 covered: u64,
837}
838
839impl Tally {
840 fn merge(&mut self, other: Tally) {
843 self.count = self.count.max(other.count);
844 self.covered = self.covered.max(other.covered);
845 }
846
847 fn add(&mut self, other: Tally) {
848 self.count += other.count;
849 self.covered += other.covered;
850 }
851}
852
853#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
855struct HiddenTotals {
856 regions: Tally,
857 lines: Tally,
858 functions: Tally,
859 branches: Tally,
860}
861
862impl HiddenTotals {
863 fn merge(&mut self, other: HiddenTotals) {
864 self.regions.merge(other.regions);
865 self.lines.merge(other.lines);
866 self.functions.merge(other.functions);
867 self.branches.merge(other.branches);
868 }
869
870 fn add(&mut self, other: HiddenTotals) {
871 self.regions.add(other.regions);
872 self.lines.add(other.lines);
873 self.functions.add(other.functions);
874 self.branches.add(other.branches);
875 }
876}
877
878fn llvm_cov_totals_less_hidden(
880 export: &LlvmCovExport,
881 hidden: &BTreeMap<String, BTreeSet<u32>>,
882) -> LlvmCovReport {
883 LlvmCovReport {
884 data: export
885 .data
886 .iter()
887 .map(|data| LlvmCovData {
888 totals: totals_less_hidden(data, hidden),
889 })
890 .collect(),
891 }
892}
893
894fn totals_less_hidden(
896 data: &LlvmCovExportData,
897 hidden: &BTreeMap<String, BTreeSet<u32>>,
898) -> LlvmCovTotals {
899 let delta = hidden_totals(data, hidden);
900 LlvmCovTotals {
901 regions: metric_less(data.totals.regions, delta.regions),
902 lines: metric_less(data.totals.lines, delta.lines),
903 functions: metric_less(data.totals.functions, delta.functions),
904 branches: data
905 .totals
906 .branches
907 .map(|metric| metric_less(metric, delta.branches)),
908 }
909}
910
911fn metric_less(metric: LlvmCovMetric, delta: Tally) -> LlvmCovMetric {
913 let count = metric.count.saturating_sub(delta.count);
914 as_metric(
915 count,
916 metric.covered.saturating_sub(delta.covered).min(count),
917 )
918}
919
920fn as_metric(count: u64, covered: u64) -> LlvmCovMetric {
923 let percent = if count == 0 {
924 100.0
925 } else {
926 covered as f64 * 100.0 / count as f64
927 };
928 LlvmCovMetric {
929 count,
930 covered,
931 percent,
932 }
933}
934
935fn hidden_totals(
939 data: &LlvmCovExportData,
940 hidden: &BTreeMap<String, BTreeSet<u32>>,
941) -> HiddenTotals {
942 let measured: BTreeSet<&str> = data.files.iter().map(|f| f.filename.as_str()).collect();
943 let mut groups: BTreeMap<(String, i64, i64), HiddenTotals> = BTreeMap::new();
944 for function in &data.functions {
945 let Some((file, line, column)) = function_start(function) else {
946 continue;
947 };
948 if !measured.contains(file.as_str()) {
949 continue;
950 }
951 let Some(lines) = hidden.get(&file) else {
952 continue;
953 };
954 if !lines.contains(&(line.max(0) as u32)) {
955 continue;
956 }
957 groups
958 .entry((file, line, column))
959 .or_default()
960 .merge(function_totals(function, lines));
961 }
962 groups.values().fold(HiddenTotals::default(), |mut acc, g| {
963 acc.add(*g);
964 acc
965 })
966}
967
968fn function_start(function: &LlvmCovFunction) -> Option<(String, i64, i64)> {
971 let region = function.regions.first()?;
972 if region.len() < 8 {
973 return None;
974 }
975 let file = function.filenames.get(usize::try_from(region[5]).ok()?)?;
976 Some((file.clone(), region[0], region[1]))
977}
978
979fn function_totals(function: &LlvmCovFunction, hidden: &BTreeSet<u32>) -> HiddenTotals {
982 let code = code_regions(function);
983 let lines = hidden_line_coverage(&code, hidden);
984 HiddenTotals {
985 regions: Tally {
986 count: code.len() as u64,
987 covered: code.iter().filter(|region| region[4] > 0).count() as u64,
988 },
989 lines: Tally {
990 count: lines.len() as u64,
991 covered: lines.values().filter(|covered| **covered).count() as u64,
992 },
993 functions: Tally {
994 count: 1,
995 covered: u64::from(function.count > 0),
996 },
997 branches: branch_tally(&function.branches),
998 }
999}
1000
1001fn code_regions(function: &LlvmCovFunction) -> Vec<&Vec<i64>> {
1004 function
1005 .regions
1006 .iter()
1007 .filter(|region| region.len() >= 8 && region[7] == 0)
1008 .collect()
1009}
1010
1011fn hidden_line_coverage(code: &[&Vec<i64>], hidden: &BTreeSet<u32>) -> BTreeMap<u32, bool> {
1014 let mut lines: BTreeMap<u32, bool> = BTreeMap::new();
1015 for region in code {
1016 for line in region[0].max(0) as u32..=region[2].max(0) as u32 {
1017 if hidden.contains(&line) {
1018 *lines.entry(line).or_default() |= region[4] > 0;
1019 }
1020 }
1021 }
1022 lines
1023}
1024
1025fn branch_tally(branches: &[Vec<i64>]) -> Tally {
1027 let mut tally = Tally::default();
1028 for branch in branches.iter().filter(|branch| branch.len() >= 9) {
1029 tally.count += 2;
1030 tally.covered += u64::from(branch[4] > 0) + u64::from(branch[5] > 0);
1031 }
1032 tally
1033}
1034
1035fn run_cargo_llvm_cov(
1039 root: &Path,
1040 ignore: &[String],
1041 format: &[&str],
1042 features: &[String],
1043 branch: bool,
1044) -> Result<String> {
1045 run_in(&TargetDir::new(), root, ignore, format, features, branch)
1046}
1047
1048fn run_cargo_llvm_cov_json_and_lcov(
1052 root: &Path,
1053 ignore: &[String],
1054 features: &[String],
1055 branch: bool,
1056) -> Result<(String, String)> {
1057 let target = TargetDir::new();
1058 let json = run_in(&target, root, ignore, &["--json"], features, branch)?;
1059 let lcov = report_in(&target, root, ignore, &["--lcov"])?;
1060 Ok((json, lcov))
1061}
1062
1063fn report_in(
1066 target: &TargetDir,
1067 root: &Path,
1068 ignore: &[String],
1069 format: &[&str],
1070) -> Result<String> {
1071 let mut command = Command::new("cargo");
1072 command
1073 .current_dir(root)
1074 .args(report_argv(format, ignore_filename_regex(root, ignore)))
1075 .env("CARGO_TARGET_DIR", &target.0);
1076 scrub_outer_llvm_cov(&mut command);
1077 let output = command
1078 .output()
1079 .context("running `cargo llvm-cov report` (is cargo-llvm-cov installed?)")?;
1080 llvm_cov_stdout(
1081 output.status.success(),
1082 &output.stdout,
1083 &output.stderr,
1084 &format!(
1085 "cargo llvm-cov could not re-export the profile it just wrote in `{}`:",
1086 root.display()
1087 ),
1088 )
1089}
1090
1091fn report_argv(format: &[&str], ignore_regex: Option<String>) -> Vec<String> {
1092 let mut argv = vec!["llvm-cov".to_string(), "report".to_string()];
1093 argv.extend(format.iter().map(|arg| (*arg).to_string()));
1094 if let Some(regex) = ignore_regex {
1095 argv.extend(["--ignore-filename-regex".to_string(), regex]);
1096 }
1097 argv
1098}
1099
1100fn llvm_cov_stdout(success: bool, stdout: &[u8], stderr: &[u8], failure: &str) -> Result<String> {
1102 if !success {
1103 bail!(
1104 "{failure}\n{}{}",
1105 String::from_utf8_lossy(stdout),
1106 String::from_utf8_lossy(stderr)
1107 );
1108 }
1109 Ok(String::from_utf8_lossy(stdout).into_owned())
1110}
1111
1112fn run_in(
1113 target: &TargetDir,
1114 root: &Path,
1115 ignore: &[String],
1116 format: &[&str],
1117 features: &[String],
1118 branch: bool,
1119) -> Result<String> {
1120 let mut command = Command::new("cargo");
1121 command
1122 .current_dir(root)
1123 .args(run_argv(
1124 format,
1125 features,
1126 branch,
1127 ignore_filename_regex(root, ignore),
1128 ))
1129 .env("CARGO_TARGET_DIR", &target.0);
1130 scrub_outer_llvm_cov(&mut command);
1131 let output = command
1132 .output()
1133 .context("running `cargo llvm-cov` (is cargo-llvm-cov installed?)")?;
1134 llvm_cov_stdout(
1135 output.status.success(),
1136 &output.stdout,
1137 &output.stderr,
1138 &format!(
1139 "the unit suite did not run cleanly under cargo llvm-cov in `{}`:{}",
1140 root.display(),
1141 branch_hint(branch)
1142 ),
1143 )
1144}
1145
1146fn run_argv(
1147 format: &[&str],
1148 features: &[String],
1149 branch: bool,
1150 ignore_regex: Option<String>,
1151) -> Vec<String> {
1152 let mut argv = vec![
1153 "llvm-cov".to_string(),
1154 "--lib".to_string(),
1155 "--bins".to_string(),
1156 ];
1157 argv.extend(format.iter().map(|arg| (*arg).to_string()));
1158 if !features.is_empty() {
1159 argv.extend(["--features".to_string(), features.join(",")]);
1160 }
1161 if branch {
1162 argv.push("--branch".to_string());
1163 }
1164 if let Some(regex) = ignore_regex {
1165 argv.extend(["--ignore-filename-regex".to_string(), regex]);
1166 }
1167 argv
1168}
1169
1170fn branch_hint(branch: bool) -> &'static str {
1171 if branch {
1172 "\n(the [rust].coverage `branch` floor runs with --branch, which requires a \
1173 nightly toolchain — pin one in the crate's rust-toolchain.toml with \
1174 llvm-tools-preview, or set a rustup directory override)"
1175 } else {
1176 ""
1177 }
1178}
1179
1180fn scrub_outer_llvm_cov(command: &mut Command) {
1186 for var in [
1187 "RUSTFLAGS",
1188 "CARGO_ENCODED_RUSTFLAGS",
1189 "RUSTDOCFLAGS",
1190 "CARGO_ENCODED_RUSTDOCFLAGS",
1191 "LLVM_PROFILE_FILE",
1192 "CARGO_LLVM_COV",
1193 "CARGO_LLVM_COV_SHOW_ENV",
1194 "CARGO_LLVM_COV_TARGET_DIR",
1195 "CARGO_LLVM_COV_BUILD_DIR",
1196 "RUSTC_WRAPPER",
1197 "RUSTC_WORKSPACE_WRAPPER",
1198 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
1199 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
1200 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
1201 "RUSTUP_TOOLCHAIN",
1205 "CARGO",
1206 "RUSTC",
1207 ] {
1208 command.env_remove(var);
1209 }
1210}
1211
1212#[derive(Debug, Clone, Default)]
1215pub struct RustPatchCoverage {
1216 pub regions: Vec<(u64, u64, bool)>,
1219}
1220
1221#[derive(Debug, Clone, Deserialize)]
1224struct LlvmCovExport {
1225 data: Vec<LlvmCovExportData>,
1226}
1227
1228#[derive(Debug, Clone, Deserialize)]
1232struct LlvmCovExportData {
1233 files: Vec<LlvmCovExportFile>,
1234 functions: Vec<LlvmCovFunction>,
1235 #[serde(default)]
1238 totals: LlvmCovTotals,
1239}
1240
1241#[derive(Debug, Clone, Deserialize)]
1244struct LlvmCovExportFile {
1245 filename: String,
1246}
1247
1248#[derive(Debug, Clone, Deserialize)]
1253struct LlvmCovFunction {
1254 filenames: Vec<String>,
1255 regions: Vec<Vec<i64>>,
1256 #[serde(default)]
1258 count: u64,
1259 #[serde(default)]
1260 branches: Vec<Vec<i64>>,
1261}
1262
1263pub fn measure_patch_rust_detail(
1267 root: &Path,
1268 ignore: &[String],
1269 features: &[String],
1270) -> Result<BTreeMap<String, RustPatchCoverage>> {
1271 let json = run_cargo_llvm_cov(root, ignore, &["--json"], features, false)?;
1273 let hidden = hidden_lines_by_file(&json)?;
1274 llvm_cov_patch_detail(&json, &hidden)
1275}
1276
1277fn parse_llvm_cov_export(json: &str) -> Result<LlvmCovExport> {
1279 serde_json::from_str(json).context("parsing cargo llvm-cov JSON export")
1280}
1281
1282fn llvm_cov_patch_detail(
1285 json: &str,
1286 hidden: &BTreeMap<String, BTreeSet<u32>>,
1287) -> Result<BTreeMap<String, RustPatchCoverage>> {
1288 let export = parse_llvm_cov_export(json)?;
1289 let mut out: BTreeMap<String, RustPatchCoverage> = BTreeMap::new();
1290 for data in &export.data {
1291 let measured: BTreeSet<&str> = data.files.iter().map(|f| f.filename.as_str()).collect();
1292 for function in &data.functions {
1293 for region in &function.regions {
1294 if let Some((file, start, end, covered)) =
1295 patch_region(region, &function.filenames, &measured, hidden)
1296 {
1297 out.entry(file)
1298 .or_default()
1299 .regions
1300 .push((start, end, covered));
1301 }
1302 }
1303 }
1304 }
1305 Ok(out)
1306}
1307
1308fn patch_region(
1313 region: &[i64],
1314 filenames: &[String],
1315 measured: &BTreeSet<&str>,
1316 hidden: &BTreeMap<String, BTreeSet<u32>>,
1317) -> Option<(String, u64, u64, bool)> {
1318 if region.len() < 8 {
1319 return None;
1320 }
1321 if region[7] != 0 {
1323 return None;
1324 }
1325 let file = filenames.get(usize::try_from(region[5]).ok()?)?;
1326 if !measured.contains(file.as_str()) {
1328 return None;
1329 }
1330 let start = region[0].max(0) as u64;
1331 if hidden
1334 .get(file)
1335 .is_some_and(|lines| lines.contains(&(start as u32)))
1336 {
1337 return None;
1338 }
1339 Some((file.clone(), start, region[2].max(0) as u64, region[4] > 0))
1340}
1341
1342fn ignore_filename_regex(root: &Path, ignore: &[String]) -> Option<String> {
1346 if ignore.is_empty() {
1347 return None;
1348 }
1349 Some(
1350 ignore
1351 .iter()
1352 .map(|rel| {
1353 let full = root.join(rel);
1356 let full = full.canonicalize().unwrap_or(full);
1357 format!("{}$", regex_escape(&full.to_string_lossy()))
1358 })
1359 .collect::<Vec<_>>()
1360 .join("|"),
1361 )
1362}
1363
1364fn regex_escape(s: &str) -> String {
1366 const META: &str = r"\.+*?()|[]{}^$";
1367 let mut out = String::with_capacity(s.len());
1368 for c in s.chars() {
1369 if META.contains(c) {
1370 out.push('\\');
1371 }
1372 out.push(c);
1373 }
1374 out
1375}
1376
1377#[cfg(test)]
1378mod tests {
1379 use super::*;
1380
1381 #[test]
1382 fn istanbul_detail_reads_statements_branches_and_functions() {
1383 let json = r#"{
1384 "/pkg/src/widget.ts": {
1385 "path": "/pkg/src/widget.ts",
1386 "statementMap": {
1387 "0": {"start": {"line": 3}, "end": {"line": 5}},
1388 "1": {"start": {"line": 9}, "end": {"line": 9}}
1389 },
1390 "s": {"0": 2, "1": 0},
1391 "branchMap": {"0": {"loc": {"start": {"line": 4}, "end": {"line": 7}}}},
1392 "b": {"0": [1, 0]},
1393 "fnMap": {
1394 "0": {"decl": {"start": {"line": 2}, "end": {"line": 2}}},
1395 "1": {"decl": {"start": {"line": 8}, "end": {"line": 8}}}
1396 },
1397 "f": {"0": 0, "1": 3}
1398 }
1399 }"#;
1400 let detail = istanbul_patch_detail(json).unwrap();
1401 let file = &detail["/pkg/src/widget.ts"];
1402 assert!(file.statements.contains(&(3, 5, true)));
1404 assert!(file.statements.contains(&(9, 9, false)));
1405 assert_eq!(file.branch_arms, vec![(4, true), (4, false)]);
1407 assert_eq!(file.functions, vec![(2, false), (8, true)]);
1410 }
1411
1412 #[test]
1413 fn a_zero_hit_count_is_uncovered_rather_than_covered() {
1414 let json = r#"{
1415 "/pkg/src/widget.ts": {
1416 "path": "/pkg/src/widget.ts",
1417 "statementMap": {"0": {"start": {"line": 1}, "end": {"line": 1}}},
1418 "s": {"0": 0},
1419 "branchMap": {},
1420 "b": {},
1421 "fnMap": {},
1422 "f": {}
1423 }
1424 }"#;
1425 let detail = istanbul_patch_detail(json).unwrap();
1426 assert_eq!(detail["/pkg/src/widget.ts"].statements, vec![(1, 1, false)]);
1427 }
1428
1429 #[test]
1430 fn only_kind_zero_regions_count_as_code() {
1431 let function = LlvmCovFunction {
1432 filenames: vec!["a.rs".into()],
1433 regions: vec![vec![1, 0, 3, 0, 1, 0, 0, 0], vec![4, 0, 6, 0, 1, 0, 0, 2]],
1435 count: 1,
1436 branches: Vec::new(),
1437 };
1438 let code = code_regions(&function);
1439 assert_eq!(code.len(), 1);
1440 assert_eq!(code[0][0], 1);
1441 }
1442
1443 #[test]
1444 fn a_region_too_short_to_carry_a_kind_is_not_code() {
1445 let function = LlvmCovFunction {
1446 filenames: vec!["a.rs".into()],
1447 regions: vec![vec![1, 0, 3, 0, 1]],
1448 count: 1,
1449 branches: Vec::new(),
1450 };
1451 assert!(code_regions(&function).is_empty());
1452 }
1453
1454 #[test]
1455 fn several_regions_over_one_hidden_line_or_together() {
1456 let uncovered = vec![1i64, 0, 1, 0, 0, 0, 0, 0];
1457 let covered = vec![1i64, 0, 1, 0, 4, 0, 0, 0];
1458 let hidden: BTreeSet<u32> = [1].into_iter().collect();
1459 let code: Vec<&Vec<i64>> = vec![&uncovered, &covered];
1460 assert_eq!(hidden_line_coverage(&code, &hidden).get(&1), Some(&true));
1461
1462 let only_uncovered: Vec<&Vec<i64>> = vec![&uncovered];
1463 assert_eq!(
1464 hidden_line_coverage(&only_uncovered, &hidden).get(&1),
1465 Some(&false)
1466 );
1467 }
1468
1469 #[test]
1470 fn a_line_outside_the_gated_set_is_not_tallied() {
1471 let region = vec![1i64, 0, 9, 0, 1, 0, 0, 0];
1472 let code: Vec<&Vec<i64>> = vec![®ion];
1473 let hidden: BTreeSet<u32> = [4].into_iter().collect();
1474 let lines = hidden_line_coverage(&code, &hidden);
1475 assert_eq!(lines.keys().copied().collect::<Vec<u32>>(), vec![4]);
1476 }
1477
1478 fn report(percent_covered: f64, num_branches: u64) -> CoverageReport {
1479 CoverageReport {
1480 totals: Totals {
1481 percent_covered,
1482 num_branches,
1483 },
1484 files: BTreeMap::new(),
1485 }
1486 }
1487
1488 #[test]
1489 fn passes_when_total_meets_the_floor() {
1490 assert_eq!(
1491 evaluate(
1492 &report(100.0, 12),
1493 Thresholds {
1494 fail_under: 100,
1495 branch: true
1496 }
1497 ),
1498 Outcome::Pass
1499 );
1500 }
1501
1502 #[test]
1503 fn fails_when_total_is_below_the_floor() {
1504 assert!(matches!(
1505 evaluate(
1506 &report(80.0, 12),
1507 Thresholds {
1508 fail_under: 100,
1509 branch: true
1510 }
1511 ),
1512 Outcome::Fail(_)
1513 ));
1514 }
1515
1516 #[test]
1517 fn passes_when_branch_required_and_none_are_measured() {
1518 assert_eq!(
1519 evaluate(
1520 &report(100.0, 0),
1521 Thresholds {
1522 fail_under: 100,
1523 branch: true
1524 }
1525 ),
1526 Outcome::Pass
1527 );
1528 }
1529
1530 #[test]
1531 fn parses_a_coverage_py_report() {
1532 let json = r#"{"totals":{"percent_covered":91.5,"num_branches":8,"covered_lines":91}}"#;
1533 let report = parse_report(json).expect("valid coverage.py json");
1534 assert_eq!(report.totals.percent_covered, 91.5);
1535 assert_eq!(report.totals.num_branches, 8);
1536 }
1537
1538 #[test]
1539 fn parses_the_per_file_block_for_patch_coverage() {
1540 let json = r#"{
1541 "files": {
1542 "widget.py": {
1543 "executed_lines": [1, 2, 3, 4, 6],
1544 "summary": {"percent_covered": 85.0},
1545 "missing_lines": [5],
1546 "excluded_lines": [],
1547 "missing_branches": [[4, 5]]
1548 }
1549 },
1550 "totals": {"percent_covered": 85.0, "num_branches": 4}
1551 }"#;
1552 let report = parse_report(json).expect("valid coverage.py json with files");
1553 let widget = report.files.get("widget.py").expect("widget.py is present");
1554 assert_eq!(widget.missing_lines, vec![5]);
1555 assert_eq!(widget.missing_branches, vec![vec![4, 5]]);
1556 assert_eq!(report.totals.percent_covered, 85.0);
1557 }
1558
1559 #[test]
1560 fn a_report_without_a_files_block_parses_with_an_empty_map() {
1561 let report = parse_report(r#"{"totals":{"percent_covered":100.0,"num_branches":2}}"#)
1562 .expect("valid coverage.py json");
1563 assert!(report.files.is_empty());
1564 }
1565
1566 #[test]
1567 fn omit_is_the_test_and_support_globs_when_nothing_is_exempt() {
1568 assert_eq!(build_omit(&[]), "*_test.py,*conftest.py");
1569 }
1570
1571 #[test]
1572 fn omit_folds_in_the_exempt_paths_after_the_test_glob() {
1573 let exempt = vec!["pkg/gen.py".to_string(), "shim.py".to_string()];
1574 assert_eq!(
1575 build_omit(&exempt),
1576 "*_test.py,*conftest.py,pkg/gen.py,shim.py"
1577 );
1578 }
1579
1580 fn metric(pct: f64) -> VitestMetric {
1581 VitestMetric {
1582 pct: Some(pct),
1583 total: 10,
1584 }
1585 }
1586
1587 fn ts_report(lines: f64, branches: f64, functions: f64, statements: f64) -> VitestReport {
1588 VitestReport {
1589 total: VitestTotals {
1590 lines: metric(lines),
1591 branches: metric(branches),
1592 functions: metric(functions),
1593 statements: metric(statements),
1594 },
1595 }
1596 }
1597
1598 const TS_FULL: TypeScriptThresholds = TypeScriptThresholds {
1599 lines: 100,
1600 branches: 100,
1601 functions: 100,
1602 statements: 100,
1603 };
1604 const TS_MID: TypeScriptThresholds = TypeScriptThresholds {
1605 lines: 80,
1606 branches: 75,
1607 functions: 80,
1608 statements: 80,
1609 };
1610
1611 #[test]
1612 fn typescript_passes_when_every_metric_meets_its_floor() {
1613 assert_eq!(
1614 evaluate_typescript(&ts_report(100.0, 100.0, 100.0, 100.0), TS_FULL),
1615 Outcome::Pass
1616 );
1617 }
1618
1619 #[test]
1620 fn typescript_fails_on_the_one_metric_below_its_floor() {
1621 let outcome = evaluate_typescript(&ts_report(100.0, 66.66, 100.0, 100.0), TS_MID);
1622 assert!(
1623 matches!(&outcome, Outcome::Fail(message) if message.contains("branches") && !message.contains("lines")),
1624 "got: {outcome:?}"
1625 );
1626 }
1627
1628 #[test]
1629 fn typescript_fail_message_names_every_metric_below() {
1630 let outcome = evaluate_typescript(&ts_report(70.0, 70.0, 70.0, 70.0), TS_MID);
1631 assert!(
1632 matches!(&outcome, Outcome::Fail(message)
1633 if message.contains("lines")
1634 && message.contains("branches")
1635 && message.contains("functions")
1636 && message.contains("statements")),
1637 "got: {outcome:?}"
1638 );
1639 }
1640
1641 #[test]
1642 fn typescript_tolerates_float_noise_at_the_floor() {
1643 assert_eq!(
1644 evaluate_typescript(&ts_report(99.999_999_999, 100.0, 100.0, 100.0), TS_FULL),
1645 Outcome::Pass
1646 );
1647 }
1648
1649 #[test]
1650 fn typescript_empty_denominator_metric_is_vacuously_satisfied() {
1651 let report = VitestReport {
1652 total: VitestTotals {
1653 lines: metric(100.0),
1654 branches: VitestMetric {
1655 pct: None,
1656 total: 0,
1657 },
1658 functions: metric(100.0),
1659 statements: metric(100.0),
1660 },
1661 };
1662 assert_eq!(evaluate_typescript(&report, TS_FULL), Outcome::Pass);
1663 }
1664
1665 #[test]
1666 fn typescript_fails_a_vacuous_run_that_measured_no_code() {
1667 let nothing = VitestMetric {
1668 pct: None,
1669 total: 0,
1670 };
1671 let report = VitestReport {
1672 total: VitestTotals {
1673 lines: nothing,
1674 branches: nothing,
1675 functions: nothing,
1676 statements: nothing,
1677 },
1678 };
1679 let outcome = evaluate_typescript(&report, TS_MID);
1680 assert!(
1681 matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1682 "got: {outcome:?}"
1683 );
1684 }
1685
1686 #[test]
1687 fn parses_a_vitest_summary_report() {
1688 let json = r#"{
1689 "total": {
1690 "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1691 "statements": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1692 "functions": {"total": 2, "covered": 2, "skipped": 0, "pct": 100},
1693 "branches": {"total": 3, "covered": 2, "skipped": 0, "pct": 66.66},
1694 "branchesTrue": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1695 },
1696 "/abs/widget.ts": {
1697 "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80}
1698 }
1699 }"#;
1700 let report = parse_vitest_report(json).expect("valid vitest json-summary");
1701 assert_eq!(report.total.lines.pct, Some(80.0));
1703 assert_eq!(report.total.branches.pct, Some(66.66));
1704 assert_eq!(report.total.functions.total, 2);
1705 }
1706
1707 #[test]
1708 fn parses_an_unknown_pct_as_unmeasured() {
1709 let json = r#"{"total": {
1710 "lines": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1711 "statements": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1712 "functions": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1713 "branches": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1714 }}"#;
1715 let report = parse_vitest_report(json).expect("valid vitest json-summary");
1716 assert_eq!(report.total.lines.pct, None);
1717 assert_eq!(report.total.lines.total, 0);
1718 }
1719
1720 #[test]
1721 fn a_pct_that_is_neither_number_nor_string_is_a_parse_error() {
1722 let json = r#"{"total":{
1723 "lines": {"total": 1, "covered": 1, "skipped": 0, "pct": true},
1724 "statements": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1725 "functions": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1726 "branches": {"total": 1, "covered": 1, "skipped": 0, "pct": 100}
1727 }}"#;
1728 assert!(parse_vitest_report(json).is_err());
1729 }
1730
1731 fn rust_metric(percent: f64) -> LlvmCovMetric {
1732 LlvmCovMetric {
1733 count: 10,
1734 covered: 10,
1735 percent,
1736 }
1737 }
1738
1739 fn rust_report(regions: f64, lines: f64) -> LlvmCovReport {
1740 LlvmCovReport {
1741 data: vec![LlvmCovData {
1742 totals: LlvmCovTotals {
1743 regions: rust_metric(regions),
1744 lines: rust_metric(lines),
1745 functions: rust_metric(lines),
1746 branches: None,
1747 },
1748 }],
1749 }
1750 }
1751
1752 fn rust_report_full(
1755 regions: f64,
1756 lines: f64,
1757 functions: f64,
1758 branches: (u64, f64),
1759 ) -> LlvmCovReport {
1760 let (count, percent) = branches;
1761 LlvmCovReport {
1762 data: vec![LlvmCovData {
1763 totals: LlvmCovTotals {
1764 regions: rust_metric(regions),
1765 lines: rust_metric(lines),
1766 functions: rust_metric(functions),
1767 branches: Some(LlvmCovMetric {
1768 count,
1769 covered: count,
1770 percent,
1771 }),
1772 },
1773 }],
1774 }
1775 }
1776
1777 const RUST_FULL: RustThresholds = RustThresholds {
1778 regions: Some(100),
1779 lines: 100,
1780 functions: None,
1781 branch: None,
1782 };
1783 const RUST_MID: RustThresholds = RustThresholds {
1784 regions: Some(80),
1785 lines: 85,
1786 functions: None,
1787 branch: None,
1788 };
1789
1790 #[test]
1791 fn rust_functions_floor_fails_below_and_passes_at_its_bar() {
1792 let report = rust_report_full(100.0, 100.0, 66.67, (0, 0.0));
1793 let floor = |functions| RustThresholds {
1794 regions: None,
1795 lines: 50,
1796 functions: Some(functions),
1797 branch: None,
1798 };
1799 assert!(matches!(
1800 evaluate_rust(&report, floor(100)),
1801 Outcome::Fail(message) if message.contains("functions")
1802 ));
1803 assert_eq!(evaluate_rust(&report, floor(60)), Outcome::Pass);
1804 }
1805
1806 #[test]
1807 fn rust_branch_floor_fails_below_and_passes_at_its_bar() {
1808 let report = rust_report_full(100.0, 100.0, 100.0, (2, 50.0));
1809 let floor = |branch| RustThresholds {
1810 regions: None,
1811 lines: 50,
1812 functions: None,
1813 branch: Some(branch),
1814 };
1815 assert!(matches!(
1816 evaluate_rust(&report, floor(100)),
1817 Outcome::Fail(message) if message.contains("branches")
1818 ));
1819 assert_eq!(evaluate_rust(&report, floor(50)), Outcome::Pass);
1820 }
1821
1822 #[test]
1823 fn rust_a_branchless_crate_clears_any_branch_floor_vacuously() {
1824 let report = rust_report_full(100.0, 100.0, 100.0, (0, 0.0));
1825 let floor = RustThresholds {
1826 regions: None,
1827 lines: 50,
1828 functions: None,
1829 branch: Some(100),
1830 };
1831 assert_eq!(evaluate_rust(&report, floor), Outcome::Pass);
1832 }
1833
1834 #[test]
1835 fn rust_passes_when_both_metrics_meet_their_floor() {
1836 assert_eq!(
1837 evaluate_rust(&rust_report(100.0, 100.0), RUST_FULL),
1838 Outcome::Pass
1839 );
1840 }
1841
1842 #[test]
1843 fn rust_fails_on_the_one_metric_below_its_floor() {
1844 let outcome = evaluate_rust(&rust_report(70.0, 100.0), RUST_MID);
1845 assert!(
1846 matches!(&outcome, Outcome::Fail(message) if message.contains("regions") && !message.contains("lines")),
1847 "got: {outcome:?}"
1848 );
1849 }
1850
1851 #[test]
1852 fn rust_fail_message_names_every_metric_below() {
1853 let outcome = evaluate_rust(&rust_report(50.0, 50.0), RUST_MID);
1854 assert!(
1855 matches!(&outcome, Outcome::Fail(message)
1856 if message.contains("regions") && message.contains("lines")),
1857 "got: {outcome:?}"
1858 );
1859 }
1860
1861 #[test]
1862 fn rust_skips_the_region_check_when_regions_is_opt_out() {
1863 let thresholds = RustThresholds {
1864 regions: None,
1865 lines: 100,
1866 functions: None,
1867 branch: None,
1868 };
1869 assert_eq!(
1870 evaluate_rust(&rust_report(40.0, 100.0), thresholds),
1871 Outcome::Pass
1872 );
1873 }
1874
1875 #[test]
1876 fn rust_still_fails_lines_with_regions_opt_out() {
1877 let thresholds = RustThresholds {
1878 regions: None,
1879 lines: 100,
1880 functions: None,
1881 branch: None,
1882 };
1883 let outcome = evaluate_rust(&rust_report(100.0, 80.0), thresholds);
1884 assert!(
1885 matches!(&outcome, Outcome::Fail(message)
1886 if message.contains("lines") && !message.contains("regions")),
1887 "got: {outcome:?}"
1888 );
1889 }
1890
1891 #[test]
1892 fn rust_tolerates_float_noise_at_the_floor() {
1893 assert_eq!(
1894 evaluate_rust(&rust_report(99.999_999_999, 100.0), RUST_FULL),
1895 Outcome::Pass
1896 );
1897 }
1898
1899 #[test]
1900 fn rust_fails_a_vacuous_run_that_measured_no_code() {
1901 let nothing = LlvmCovMetric {
1902 count: 0,
1903 covered: 0,
1904 percent: 0.0,
1905 };
1906 let report = LlvmCovReport {
1907 data: vec![LlvmCovData {
1908 totals: LlvmCovTotals {
1909 regions: nothing,
1910 lines: nothing,
1911 functions: nothing,
1912 branches: None,
1913 },
1914 }],
1915 };
1916 let outcome = evaluate_rust(&report, RUST_MID);
1917 assert!(
1918 matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1919 "got: {outcome:?}"
1920 );
1921 }
1922
1923 #[test]
1924 fn rust_fails_an_export_with_no_data() {
1925 let report = LlvmCovReport { data: vec![] };
1926 assert!(matches!(evaluate_rust(&report, RUST_MID), Outcome::Fail(_)));
1927 }
1928
1929 #[test]
1930 fn parses_a_cargo_llvm_cov_report() {
1931 let json = r#"{
1932 "data": [{"totals": {
1933 "regions": {"count": 12, "covered": 9, "notcovered": 3, "percent": 75.0},
1934 "lines": {"count": 20, "covered": 18, "percent": 90.0},
1935 "functions": {"count": 3, "covered": 3, "percent": 100.0}
1936 }}],
1937 "type": "llvm.coverage.json.export",
1938 "version": "2.0.1"
1939 }"#;
1940 let report = parse_llvm_cov_report(json).expect("valid llvm-cov json");
1941 assert_eq!(report.data[0].totals.regions.percent, 75.0);
1942 assert_eq!(report.data[0].totals.lines.count, 20);
1943 }
1944
1945 fn patch_detail(json: &str) -> BTreeMap<String, RustPatchCoverage> {
1947 llvm_cov_patch_detail(json, &BTreeMap::new()).expect("valid llvm-cov export")
1948 }
1949
1950 #[test]
1951 fn llvm_cov_patch_detail_reads_code_regions_per_file() {
1952 let json = r#"{
1953 "data": [{
1954 "files": [{"filename": "/abs/grade.rs"}],
1955 "functions": [{
1956 "filenames": ["/abs/grade.rs"],
1957 "regions": [
1958 [6, 5, 6, 26, 1, 0, 0, 0],
1959 [10, 9, 10, 17, 0, 0, 0, 0]
1960 ]
1961 }]
1962 }],
1963 "type": "llvm.coverage.json.export",
1964 "version": "3.0.1"
1965 }"#;
1966 let out = patch_detail(json);
1967 assert_eq!(
1968 out["/abs/grade.rs"].regions,
1969 vec![(6, 6, true), (10, 10, false)]
1970 );
1971 }
1972
1973 #[test]
1974 fn llvm_cov_patch_detail_skips_non_code_regions() {
1975 let json = r#"{
1976 "data": [{
1977 "files": [{"filename": "/abs/a.rs"}],
1978 "functions": [{
1979 "filenames": ["/abs/a.rs"],
1980 "regions": [
1981 [1, 1, 1, 10, 2, 0, 0, 0],
1982 [2, 1, 2, 10, 0, 0, 0, 1],
1983 [3, 1, 3, 10, 0, 0, 0, 2]
1984 ]
1985 }]
1986 }]
1987 }"#;
1988 let out = patch_detail(json);
1989 assert_eq!(out["/abs/a.rs"].regions, vec![(1, 1, true)]);
1990 }
1991
1992 #[test]
1993 fn llvm_cov_patch_detail_groups_regions_by_filename_id() {
1994 let json = r#"{
1995 "data": [{
1996 "files": [{"filename": "/abs/a.rs"}, {"filename": "/abs/b.rs"}],
1997 "functions": [{
1998 "filenames": ["/abs/a.rs", "/abs/b.rs"],
1999 "regions": [
2000 [1, 1, 1, 5, 1, 0, 0, 0],
2001 [9, 1, 9, 5, 0, 1, 1, 0]
2002 ]
2003 }]
2004 }]
2005 }"#;
2006 let out = patch_detail(json);
2007 assert_eq!(out["/abs/a.rs"].regions, vec![(1, 1, true)]);
2008 assert_eq!(out["/abs/b.rs"].regions, vec![(9, 9, false)]);
2009 }
2010
2011 #[test]
2012 fn llvm_cov_patch_detail_skips_a_malformed_short_region() {
2013 let json = r#"{
2014 "data": [{
2015 "files": [{"filename": "/abs/a.rs"}],
2016 "functions": [{
2017 "filenames": ["/abs/a.rs"],
2018 "regions": [
2019 [4, 1, 4],
2020 [5, 1, 5, 9, 1, 0, 0, 0]
2021 ]
2022 }]
2023 }]
2024 }"#;
2025 let out = patch_detail(json);
2026 assert_eq!(out["/abs/a.rs"].regions, vec![(5, 5, true)]);
2027 }
2028
2029 #[test]
2030 fn llvm_cov_patch_detail_spans_a_multiline_region() {
2031 let json = r#"{
2032 "data": [{
2033 "files": [{"filename": "/abs/a.rs"}],
2034 "functions": [{
2035 "filenames": ["/abs/a.rs"],
2036 "regions": [[3, 5, 5, 6, 0, 0, 0, 0]]
2037 }]
2038 }]
2039 }"#;
2040 let out = patch_detail(json);
2041 assert_eq!(out["/abs/a.rs"].regions, vec![(3, 5, false)]);
2042 }
2043
2044 #[test]
2045 fn llvm_cov_patch_detail_drops_a_file_absent_from_the_files_allowlist() {
2046 let json = r#"{
2047 "data": [{
2048 "files": [{"filename": "/abs/kept.rs"}],
2049 "functions": [{
2050 "filenames": ["/abs/kept.rs", "/abs/ignored.rs"],
2051 "regions": [
2052 [1, 1, 1, 9, 1, 0, 0, 0],
2053 [2, 1, 2, 9, 0, 1, 0, 0]
2054 ]
2055 }]
2056 }]
2057 }"#;
2058 let out = patch_detail(json);
2059 assert_eq!(out["/abs/kept.rs"].regions, vec![(1, 1, true)]);
2060 assert!(!out.contains_key("/abs/ignored.rs"));
2061 }
2062
2063 #[test]
2064 fn llvm_cov_patch_detail_malformed_json_is_an_error() {
2065 assert!(llvm_cov_patch_detail("{ not json", &BTreeMap::new()).is_err());
2066 }
2067
2068 #[test]
2069 fn llvm_cov_patch_detail_skips_a_negative_file_id() {
2070 let json = r#"{
2071 "data": [{
2072 "files": [{"filename": "/abs/a.rs"}],
2073 "functions": [{
2074 "filenames": ["/abs/a.rs"],
2075 "regions": [[1, 1, 1, 5, 1, -1, 0, 0]]
2076 }]
2077 }]
2078 }"#;
2079 let out = patch_detail(json);
2080 assert!(out.is_empty(), "got: {out:?}");
2081 }
2082
2083 #[test]
2084 fn llvm_cov_patch_detail_skips_an_out_of_range_file_id() {
2085 let json = r#"{
2086 "data": [{
2087 "files": [{"filename": "/abs/a.rs"}],
2088 "functions": [{
2089 "filenames": ["/abs/a.rs"],
2090 "regions": [[1, 1, 1, 5, 1, 7, 0, 0]]
2091 }]
2092 }]
2093 }"#;
2094 let out = patch_detail(json);
2095 assert!(out.is_empty(), "got: {out:?}");
2096 }
2097
2098 #[test]
2099 fn istanbul_patch_detail_reads_statements_arms_and_functions() {
2100 let json = r#"{
2101 "/abs/a.ts": {
2102 "statementMap": {"0": {"start": {"line": 1}, "end": {"line": 2}}},
2103 "s": {"0": 1},
2104 "branchMap": {"0": {"loc": {"start": {"line": 3}, "end": {"line": 3}}}},
2105 "b": {"0": [1, 0]},
2106 "fnMap": {"0": {"decl": {"start": {"line": 7}, "end": {"line": 7}}}},
2107 "f": {"0": 0}
2108 }
2109 }"#;
2110 let out = istanbul_patch_detail(json).expect("valid Istanbul report");
2111 let detail = &out["/abs/a.ts"];
2112 assert_eq!(detail.statements, vec![(1, 2, true)]);
2113 assert_eq!(detail.branch_arms, vec![(3, true), (3, false)]);
2114 assert_eq!(detail.functions, vec![(7, false)]);
2115 }
2116
2117 #[test]
2118 fn istanbul_patch_detail_keeps_a_branch_without_counts() {
2119 let json = r#"{
2120 "/abs/a.ts": {
2121 "statementMap": {},
2122 "s": {},
2123 "branchMap": {"0": {"loc": {"start": {"line": 3}, "end": {"line": 3}}}},
2124 "b": {},
2125 "fnMap": {},
2126 "f": {}
2127 }
2128 }"#;
2129 let out = istanbul_patch_detail(json).expect("valid Istanbul report");
2130 assert!(out["/abs/a.ts"].branch_arms.is_empty(), "got: {out:?}");
2131 }
2132
2133 #[test]
2134 fn default_excludes_that_are_not_json_name_the_output() {
2135 let err = parse_default_excludes(b"vitest warmed up first").unwrap_err();
2136 let msg = format!("{err:#}");
2137 assert!(msg.contains("not a JSON string array"), "got: {msg}");
2138 assert!(msg.contains("vitest warmed up first"), "got: {msg}");
2139 }
2140
2141 #[test]
2142 fn default_excludes_drop_a_nul_bearing_pattern() {
2143 let parsed = parse_default_excludes(br#"["**/dist/**", "**/\u0000*"]"#).unwrap();
2144 assert_eq!(parsed, vec!["**/dist/**".to_string()]);
2145 }
2146
2147 #[test]
2148 fn a_missing_vitest_report_names_the_reporter() {
2149 let path = std::env::temp_dir().join("tc-no-such-report/coverage-final.json");
2150 let err = read_vitest_report(&path, "json").unwrap_err();
2151 assert!(format!("{err:#}").contains("json report"), "got: {err:#}");
2152 }
2153
2154 #[test]
2155 fn rust_ignore_regex_is_none_when_nothing_is_exempt() {
2156 assert_eq!(ignore_filename_regex(Path::new("/repo"), &[]), None);
2157 }
2158
2159 #[test]
2160 fn rust_run_argv_carries_format_features_branch_and_ignore_regex() {
2161 assert_eq!(
2162 run_argv(
2163 &["--json"],
2164 &["alpha".to_string(), "beta".to_string()],
2165 true,
2166 Some("src/ignored\\.rs$".to_string()),
2167 ),
2168 [
2169 "llvm-cov",
2170 "--lib",
2171 "--bins",
2172 "--json",
2173 "--features",
2174 "alpha,beta",
2175 "--branch",
2176 "--ignore-filename-regex",
2177 "src/ignored\\.rs$",
2178 ]
2179 );
2180 assert_eq!(
2181 run_argv(&["--lcov"], &[], false, None),
2182 ["llvm-cov", "--lib", "--bins", "--lcov"]
2183 );
2184 }
2185
2186 #[test]
2187 fn vitest_coverage_argv_names_the_reporter_the_report_dir_and_every_exclude() {
2188 let excludes = ["dist/**".to_string(), "src/generated/**".to_string()];
2189 let argv = vitest_coverage_argv("json-summary", Path::new("/tmp/reports"), excludes.iter());
2190
2191 assert_eq!(argv[0], "--no-install");
2192 assert_eq!(argv[1], "vitest");
2193 assert!(argv.contains(&"--coverage.all=true".to_string()));
2194 assert!(argv.contains(&"--coverage.thresholds.autoUpdate=false".to_string()));
2195 assert!(argv.contains(&"--coverage.reporter=json-summary".to_string()));
2196 assert!(argv.contains(&"--coverage.reportsDirectory=/tmp/reports".to_string()));
2197 assert!(argv.contains(&format!("--coverage.include={TS_INCLUDE}")));
2198 assert_eq!(
2199 argv.iter()
2200 .filter(|arg| arg.starts_with("--coverage.exclude="))
2201 .collect::<Vec<_>>(),
2202 [
2203 "--coverage.exclude=dist/**",
2204 "--coverage.exclude=src/generated/**"
2205 ]
2206 );
2207 }
2208
2209 #[test]
2210 fn vitest_coverage_argv_never_passes_yes_so_a_missing_vitest_fails_clean() {
2211 let argv = vitest_coverage_argv("json", Path::new("/tmp/r"), [].iter());
2212
2213 assert!(!argv.iter().any(|arg| arg == "--yes"));
2214 }
2215
2216 #[test]
2217 fn a_metric_at_or_above_its_floor_names_no_shortfall() {
2218 assert_eq!(shortfall("lines", 100.0, 100), None);
2219 assert_eq!(shortfall("lines", 90.0, 90), None);
2220 assert_eq!(shortfall("lines", 89.999999999, 90), None);
2222 }
2223
2224 #[test]
2225 fn a_metric_below_its_floor_names_the_gap() {
2226 assert_eq!(
2227 shortfall("branches", 72.5, 80),
2228 Some("branches 72.50% < 80%".to_string())
2229 );
2230 }
2231
2232 #[test]
2233 fn a_verdict_passes_on_no_shortfalls_and_names_every_one_otherwise() {
2234 assert_eq!(verdict(Vec::new()), Outcome::Pass);
2235 assert_eq!(
2236 verdict(vec![
2237 "lines 1.00% < 2%".to_string(),
2238 "b 3.00% < 4%".to_string()
2239 ]),
2240 Outcome::Fail("coverage below thresholds: lines 1.00% < 2%, b 3.00% < 4%".to_string())
2241 );
2242 }
2243
2244 #[test]
2245 fn rust_checks_floor_lines_always_and_the_rest_only_when_configured() {
2246 let totals = rust_report_full(10.0, 20.0, 30.0, (4, 50.0)).data[0].totals;
2247
2248 assert_eq!(
2249 rust_checks(
2250 &totals,
2251 RustThresholds {
2252 lines: 90,
2253 regions: None,
2254 functions: None,
2255 branch: None,
2256 }
2257 ),
2258 [("lines", 20.0, 90)]
2259 );
2260 assert_eq!(
2261 rust_checks(
2262 &totals,
2263 RustThresholds {
2264 lines: 90,
2265 regions: Some(80),
2266 functions: Some(70),
2267 branch: Some(60),
2268 }
2269 ),
2270 [
2271 ("regions", 10.0, 80),
2272 ("lines", 20.0, 90),
2273 ("functions", 30.0, 70),
2274 ("branches", 50.0, 60),
2275 ]
2276 );
2277 }
2278
2279 #[test]
2280 fn a_branch_floor_over_a_branchless_crate_is_vacuously_satisfied() {
2281 let totals = rust_report_full(10.0, 20.0, 30.0, (0, 0.0)).data[0].totals;
2282
2283 assert_eq!(
2284 rust_checks(
2285 &totals,
2286 RustThresholds {
2287 lines: 90,
2288 regions: None,
2289 functions: None,
2290 branch: Some(60),
2291 }
2292 ),
2293 [("lines", 20.0, 90)]
2294 );
2295 }
2296
2297 #[test]
2298 fn a_code_region_in_a_measured_file_contributes_its_span_and_hit_state() {
2299 let names = ["src/a.rs".to_string()];
2300
2301 assert_eq!(
2302 patch_region(
2303 &[7, 1, 9, 20, 3, 0, 0, 0],
2304 &names,
2305 &BTreeSet::from(["src/a.rs"]),
2306 &BTreeMap::new()
2307 ),
2308 Some(("src/a.rs".to_string(), 7, 9, true))
2309 );
2310 assert_eq!(
2311 patch_region(
2312 &[7, 1, 9, 20, 0, 0, 0, 0],
2313 &names,
2314 &BTreeSet::from(["src/a.rs"]),
2315 &BTreeMap::new()
2316 ),
2317 Some(("src/a.rs".to_string(), 7, 9, false))
2318 );
2319 }
2320
2321 #[test]
2322 fn a_region_carrying_no_line_signal_contributes_nothing() {
2323 let names = ["src/a.rs".to_string()];
2324 let measured = BTreeSet::from(["src/a.rs"]);
2325 let nothing_hidden = BTreeMap::new();
2326 let reject = |region: &[i64]| patch_region(region, &names, &measured, ¬hing_hidden);
2327
2328 assert_eq!(reject(&[7, 1, 9, 20, 3, 0, 0]), None);
2330 assert_eq!(reject(&[7, 1, 9, 20, 3, 0, 0, 1]), None);
2332 assert_eq!(reject(&[7, 1, 9, 20, 3, 9, 0, 0]), None);
2334 assert_eq!(reject(&[7, 1, 9, 20, 3, -1, 0, 0]), None);
2336 assert_eq!(
2338 patch_region(
2339 &[7, 1, 9, 20, 3, 0, 0, 0],
2340 &names,
2341 &BTreeSet::new(),
2342 ¬hing_hidden
2343 ),
2344 None
2345 );
2346 assert_eq!(
2348 patch_region(
2349 &[7, 1, 9, 20, 3, 0, 0, 0],
2350 &names,
2351 &measured,
2352 &BTreeMap::from([("src/a.rs".to_string(), BTreeSet::from([7u32]))])
2353 ),
2354 None
2355 );
2356 }
2357
2358 #[test]
2359 fn rust_report_argv_carries_format_and_optional_ignore_regex() {
2360 assert_eq!(
2361 report_argv(&["--lcov"], Some("src/ignored\\.rs$".to_string())),
2362 [
2363 "llvm-cov",
2364 "report",
2365 "--lcov",
2366 "--ignore-filename-regex",
2367 "src/ignored\\.rs$"
2368 ]
2369 );
2370 assert_eq!(
2371 report_argv(&["--json"], None),
2372 ["llvm-cov", "report", "--json"]
2373 );
2374 }
2375
2376 #[test]
2377 fn branch_hint_names_the_nightly_requirement_only_for_branch_coverage() {
2378 assert_eq!(branch_hint(false), "");
2379 assert!(branch_hint(true).contains("requires a nightly toolchain"));
2380 }
2381
2382 #[test]
2383 fn scrub_removes_outer_llvm_cov_state_from_a_command() {
2384 let mut command = Command::new("cargo");
2385 for name in [
2386 "RUSTFLAGS",
2387 "CARGO_ENCODED_RUSTFLAGS",
2388 "RUSTDOCFLAGS",
2389 "CARGO_ENCODED_RUSTDOCFLAGS",
2390 "LLVM_PROFILE_FILE",
2391 "CARGO_LLVM_COV",
2392 "CARGO_LLVM_COV_SHOW_ENV",
2393 "CARGO_LLVM_COV_TARGET_DIR",
2394 "CARGO_LLVM_COV_BUILD_DIR",
2395 "RUSTC_WRAPPER",
2396 "RUSTC_WORKSPACE_WRAPPER",
2397 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
2398 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
2399 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
2400 "RUSTUP_TOOLCHAIN",
2401 "CARGO",
2402 "RUSTC",
2403 ] {
2404 command.env(name, "outer");
2405 }
2406 command.env("KEEP", "value");
2407 scrub_outer_llvm_cov(&mut command);
2408 let envs: Vec<_> = command.get_envs().collect();
2409 assert_eq!(envs.len(), 18);
2410 assert!(envs
2411 .iter()
2412 .all(|(name, value)| { name == &"KEEP" || value.is_none() }));
2413 assert_eq!(
2414 envs.iter().find(|(name, _)| *name == "KEEP").unwrap().1,
2415 Some("value".as_ref())
2416 );
2417 }
2418
2419 #[test]
2420 fn rust_ignore_regex_anchors_each_exempt_path_to_its_full_path() {
2421 let exempt = vec!["src/shim.rs".to_string(), "src/gen.rs".to_string()];
2423 assert_eq!(
2424 ignore_filename_regex(Path::new("/repo"), &exempt).as_deref(),
2425 Some(r"/repo/src/shim\.rs$|/repo/src/gen\.rs$")
2426 );
2427 }
2428
2429 fn llvm_would_ignore(regex: &str, filename: &str) -> bool {
2432 regex.split('|').any(|alt| {
2433 let (lit, anchored) = match alt.strip_suffix('$') {
2434 Some(head) => (head, true),
2435 None => (alt, false),
2436 };
2437 let lit = lit.replace('\\', "");
2438 if anchored {
2439 filename.ends_with(&lit)
2440 } else {
2441 filename.contains(&lit)
2442 }
2443 })
2444 }
2445
2446 #[test]
2447 fn llvm_would_ignore_matches_an_unanchored_literal_anywhere() {
2448 assert!(llvm_would_ignore("/repo/src", "/repo/src/a.rs"));
2449 assert!(!llvm_would_ignore("/elsewhere", "/repo/src/a.rs"));
2450 }
2451
2452 #[test]
2453 fn rust_ignore_regex_does_not_over_match_a_member_with_the_same_suffix() {
2454 let regex = ignore_filename_regex(Path::new("/repo"), &["src/a.rs".to_string()]).unwrap();
2455 assert!(
2456 llvm_would_ignore(®ex, "/repo/src/a.rs"),
2457 "the exempted file must still be ignored: {regex}"
2458 );
2459 assert!(
2460 !llvm_would_ignore(®ex, "/repo/member/src/a.rs"),
2461 "`src/a.rs` over-matched `member/src/a.rs`: {regex}"
2462 );
2463 assert!(
2464 !llvm_would_ignore(®ex, "/repo/src/xsrc/a.rs"),
2465 "`src/a.rs` over-matched `src/xsrc/a.rs`: {regex}"
2466 );
2467 }
2468
2469 const GATED_EXPORT: &str = r#"{
2472 "data": [{
2473 "files": [{"filename": "/abs/entrypoint.rs"}],
2474 "functions": [
2475 {"name": "report", "count": 1, "filenames": ["/abs/entrypoint.rs"], "branches": [],
2476 "regions": [[11, 1, 11, 37, 1, 0, 0, 0], [12, 5, 12, 19, 1, 0, 0, 0],
2477 [12, 20, 12, 30, 1, 0, 0, 0], [13, 1, 13, 2, 1, 0, 0, 0]]},
2478 {"name": "main", "count": 0, "filenames": ["/abs/entrypoint.rs"], "branches": [],
2479 "regions": [[6, 1, 6, 26, 0, 0, 0, 0], [7, 5, 7, 11, 0, 0, 0, 0],
2480 [7, 12, 7, 46, 0, 0, 0, 0], [8, 1, 8, 2, 0, 0, 0, 0]]},
2481 {"name": "report", "count": 0, "filenames": ["/abs/entrypoint.rs"], "branches": [],
2482 "regions": [[11, 1, 11, 37, 0, 0, 0, 0], [12, 5, 12, 19, 0, 0, 0, 0],
2483 [12, 20, 12, 30, 0, 0, 0, 0], [13, 1, 13, 2, 0, 0, 0, 0]]}
2484 ],
2485 "totals": {
2486 "regions": {"count": 13, "covered": 9, "percent": 69.23},
2487 "lines": {"count": 9, "covered": 6, "percent": 66.67},
2488 "functions": {"count": 3, "covered": 2, "percent": 66.67}
2489 }
2490 }]
2491 }"#;
2492
2493 fn gated_main() -> BTreeMap<String, BTreeSet<u32>> {
2495 BTreeMap::from([(
2496 "/abs/entrypoint.rs".to_string(),
2497 BTreeSet::from([5, 6, 7, 8]),
2498 )])
2499 }
2500
2501 fn totals_less(json: &str, hidden: &BTreeMap<String, BTreeSet<u32>>) -> LlvmCovTotals {
2503 let export = parse_llvm_cov_export(json).expect("valid llvm-cov export");
2504 llvm_cov_totals_less_hidden(&export, hidden).data[0].totals
2505 }
2506
2507 #[test]
2508 fn a_gated_entry_point_leaves_every_ratio_full() {
2509 let totals = totals_less(GATED_EXPORT, &gated_main());
2510 assert_eq!((totals.regions.count, totals.regions.covered), (9, 9));
2511 assert_eq!((totals.lines.count, totals.lines.covered), (6, 6));
2512 assert_eq!((totals.functions.count, totals.functions.covered), (2, 2));
2513 assert_eq!(totals.regions.percent, 100.0);
2514 }
2515
2516 #[test]
2517 fn an_export_with_nothing_gated_keeps_its_totals() {
2518 let totals = totals_less(GATED_EXPORT, &BTreeMap::new());
2519 assert_eq!((totals.regions.count, totals.regions.covered), (13, 9));
2520 assert_eq!((totals.lines.count, totals.lines.covered), (9, 6));
2521 assert_eq!((totals.functions.count, totals.functions.covered), (3, 2));
2522 }
2523
2524 #[test]
2525 fn two_copies_of_one_gated_item_subtract_once() {
2526 let json = GATED_EXPORT.replace(
2527 r#"{"name": "report", "count": 1"#,
2528 r#"{"name": "main", "count": 0, "filenames": ["/abs/entrypoint.rs"], "branches": [],
2529 "regions": [[6, 1, 6, 26, 0, 0, 0, 0], [7, 5, 7, 11, 0, 0, 0, 0],
2530 [7, 12, 7, 46, 0, 0, 0, 0], [8, 1, 8, 2, 0, 0, 0, 0]]},
2531 {"name": "report", "count": 1"#,
2532 );
2533 let totals = totals_less(&json, &gated_main());
2534 assert_eq!((totals.regions.count, totals.regions.covered), (9, 9));
2535 assert_eq!(totals.functions.count, 2);
2536 }
2537
2538 #[test]
2539 fn a_record_with_no_region_to_place_it_is_not_subtracted() {
2540 let json = r#"{
2541 "data": [{
2542 "files": [{"filename": "/abs/a.rs"}],
2543 "functions": [{"name": "empty", "count": 0, "filenames": ["/abs/a.rs"],
2544 "regions": [], "branches": []}],
2545 "totals": {
2546 "regions": {"count": 2, "covered": 2, "percent": 100.0},
2547 "lines": {"count": 2, "covered": 2, "percent": 100.0},
2548 "functions": {"count": 1, "covered": 1, "percent": 100.0}
2549 }
2550 }]
2551 }"#;
2552 let hidden = BTreeMap::from([("/abs/a.rs".to_string(), BTreeSet::from([1]))]);
2553 assert_eq!(totals_less(json, &hidden).regions.count, 2);
2554 }
2555
2556 #[test]
2557 fn a_record_outside_the_files_allowlist_is_not_subtracted() {
2558 let hidden = BTreeMap::from([("/abs/other.rs".to_string(), BTreeSet::from([6]))]);
2559 let json = GATED_EXPORT.replace("\"/abs/entrypoint.rs\"],", "\"/abs/other.rs\"],");
2560 assert_eq!(totals_less(&json, &hidden).regions.count, 13);
2561 }
2562
2563 #[test]
2564 fn a_gated_branch_drops_both_of_its_arms() {
2565 let json = r#"{
2566 "data": [{
2567 "files": [{"filename": "/abs/a.rs"}],
2568 "functions": [{"name": "main", "count": 0, "filenames": ["/abs/a.rs"],
2569 "regions": [[1, 1, 3, 2, 0, 0, 0, 0]],
2570 "branches": [[2, 9, 2, 14, 0, 0, 0, 0, 4], [2, 9, 2, 14, 0, 0, 0, 0]]}],
2571 "totals": {
2572 "regions": {"count": 5, "covered": 4, "percent": 80.0},
2573 "lines": {"count": 9, "covered": 6, "percent": 66.67},
2574 "functions": {"count": 3, "covered": 2, "percent": 66.67},
2575 "branches": {"count": 6, "covered": 4, "percent": 66.67}
2576 }
2577 }]
2578 }"#;
2579 let hidden = BTreeMap::from([("/abs/a.rs".to_string(), BTreeSet::from([1, 2, 3]))]);
2580 let branches = totals_less(json, &hidden).branches.expect("branch totals");
2581 assert_eq!((branches.count, branches.covered), (4, 4));
2583 }
2584
2585 #[test]
2586 fn a_metric_the_subtraction_empties_reads_full() {
2587 let metric = LlvmCovMetric {
2588 count: 4,
2589 covered: 0,
2590 percent: 0.0,
2591 };
2592 let emptied = metric_less(
2593 metric,
2594 Tally {
2595 count: 9,
2596 covered: 9,
2597 },
2598 );
2599 assert_eq!((emptied.count, emptied.covered), (0, 0));
2600 assert_eq!(emptied.percent, 100.0);
2601 }
2602
2603 const TWO_GROUPS_LCOV: &str = "SF:/abs/a.rs\nDA:1,4\nDA:2,4\nDA:3,0\n\
2606 SF:/abs/a.rs\nDA:1,0\nDA:2,0\nDA:3,7\nend_of_record\n";
2607
2608 #[test]
2609 fn a_line_one_instantiation_ran_counts_once_covered() {
2610 let lines = lcov_lines_metric(TWO_GROUPS_LCOV, &BTreeMap::new());
2611 assert_eq!((lines.count, lines.covered), (3, 3));
2612 assert_eq!(lines.percent, 100.0);
2613 }
2614
2615 #[test]
2616 fn a_gated_line_leaves_the_lcov_denominator() {
2617 let hidden = BTreeMap::from([("/abs/a.rs".to_string(), BTreeSet::from([3]))]);
2618 let lines = lcov_lines_metric(TWO_GROUPS_LCOV, &hidden);
2619 assert_eq!((lines.count, lines.covered), (2, 2));
2620 }
2621
2622 #[test]
2623 fn a_line_no_instantiation_ran_is_still_missing() {
2624 let lcov = "SF:/abs/a.rs\nDA:1,4\nDA:2,0\nend_of_record\n";
2625 let lines = lcov_lines_metric(lcov, &BTreeMap::new());
2626 assert_eq!((lines.count, lines.covered), (2, 1));
2627 }
2628
2629 #[test]
2630 fn records_outside_a_source_file_are_not_lines() {
2631 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";
2632 assert_eq!(
2633 lcov_lines(lcov),
2634 BTreeMap::from([("/abs/a.rs".to_string(), BTreeMap::from([(4, true)]))]),
2635 "a `DA:` before any `SF:`, and a malformed one after, name no line"
2636 );
2637 }
2638
2639 #[test]
2640 fn a_failed_export_carries_both_streams() {
2641 let err = llvm_cov_stdout(false, b"out\n", b"err\n", "could not re-export:")
2642 .expect_err("a non-zero exit is an error");
2643 assert_eq!(format!("{err}"), "could not re-export:\nout\nerr\n");
2644 }
2645
2646 #[test]
2647 fn an_export_measuring_nothing_reads_full() {
2648 let lines = lcov_lines_metric("", &BTreeMap::new());
2649 assert_eq!((lines.count, lines.covered), (0, 0));
2650 assert_eq!(lines.percent, 100.0);
2651 }
2652
2653 #[test]
2654 fn a_function_record_without_a_usable_first_region_has_no_group() {
2655 let short = LlvmCovFunction {
2656 filenames: vec!["/abs/a.rs".to_string()],
2657 regions: vec![vec![1, 1, 1, 2]],
2658 count: 0,
2659 branches: Vec::new(),
2660 };
2661 let unnamed = LlvmCovFunction {
2662 filenames: Vec::new(),
2663 regions: vec![vec![1, 1, 1, 2, 0, 7, 0, 0]],
2664 count: 0,
2665 branches: Vec::new(),
2666 };
2667 let empty = LlvmCovFunction {
2668 filenames: vec!["/abs/a.rs".to_string()],
2669 regions: Vec::new(),
2670 count: 0,
2671 branches: Vec::new(),
2672 };
2673 assert_eq!(function_start(&short), None);
2674 assert_eq!(function_start(&unnamed), None);
2675 assert_eq!(function_start(&empty), None);
2676 }
2677
2678 #[test]
2679 fn a_gated_region_drops_out_of_the_changed_line_detail() {
2680 let hidden =
2681 BTreeMap::from([("/abs/entrypoint.rs".to_string(), BTreeSet::from([6, 7, 8]))]);
2682 let detail = llvm_cov_patch_detail(GATED_EXPORT, &hidden).expect("valid export");
2683 assert_eq!(
2684 detail["/abs/entrypoint.rs"]
2685 .regions
2686 .iter()
2687 .map(|(start, _, _)| *start)
2688 .collect::<BTreeSet<_>>(),
2689 BTreeSet::from([11, 12, 13])
2690 );
2691 }
2692
2693 #[test]
2694 fn hidden_lines_come_from_the_sources_the_export_measured() {
2695 let dir = std::env::temp_dir().join(format!("tc-cov-hidden-{}", std::process::id()));
2696 std::fs::create_dir_all(&dir).unwrap();
2697 let gated = dir.join("gated.rs");
2698 std::fs::write(&gated, "#[cfg(not(test))]\nfn main() {}\n").unwrap();
2699 let json = format!(
2700 r#"{{"data": [{{"files": [{{"filename": "{}"}}, {{"filename": "{}"}}],
2701 "functions": []}}]}}"#,
2702 gated.display(),
2703 dir.join("absent.rs").display(),
2704 );
2705 let hidden = hidden_lines_by_file(&json).expect("valid export");
2706 std::fs::remove_dir_all(&dir).ok();
2707 assert_eq!(
2708 hidden,
2709 BTreeMap::from([(gated.display().to_string(), BTreeSet::from([1, 2]))]),
2710 "an unreadable source hides nothing"
2711 );
2712 }
2713
2714 #[test]
2715 fn a_clean_vitest_exit_raises_nothing() {
2716 assert!(vitest_exit(true, Path::new("/tmp/project"), b"ignored", b"ignored").is_ok());
2717 }
2718
2719 #[test]
2720 fn a_failed_vitest_exit_names_the_project_both_streams_and_the_no_install_rule() {
2721 let err = vitest_exit(
2722 false,
2723 Path::new("/tmp/project"),
2724 b"out detail",
2725 b"err detail",
2726 )
2727 .unwrap_err()
2728 .to_string();
2729
2730 assert!(err.contains("/tmp/project"), "got: {err}");
2731 assert!(err.contains("out detail"), "got: {err}");
2732 assert!(err.contains("err detail"), "got: {err}");
2733 assert!(err.contains("npx --no-install"), "got: {err}");
2734 assert!(err.contains("@vitest/coverage-v8"), "got: {err}");
2735 }
2736}