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