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