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