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