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, Default, 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, Default, 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(
699 root: &Path,
700 ignore: &[String],
701 features: &[String],
702 branch: bool,
703) -> Result<LlvmCovReport> {
704 let (json, lcov) = run_cargo_llvm_cov_json_and_lcov(root, ignore, features, branch)?;
705 let hidden = hidden_lines_by_file(&json)?;
706 let mut report = llvm_cov_totals_less_hidden(&parse_llvm_cov_export(&json)?, &hidden);
707 let lines = lcov_lines_metric(&lcov, &hidden);
708 for data in &mut report.data {
709 data.totals.lines = lines;
710 }
711 Ok(report)
712}
713
714fn lcov_lines(lcov: &str) -> BTreeMap<String, BTreeMap<u32, bool>> {
716 let mut out: BTreeMap<String, BTreeMap<u32, bool>> = BTreeMap::new();
717 let mut file: Option<&str> = None;
718 for record in lcov.lines() {
719 if let Some(name) = record.strip_prefix("SF:") {
720 file = Some(name);
721 } else if let Some((number, count)) =
722 record.strip_prefix("DA:").and_then(|da| da.split_once(','))
723 {
724 let (Some(file), Ok(number), Ok(count)) =
725 (file, number.parse::<u32>(), count.trim().parse::<u64>())
726 else {
727 continue;
728 };
729 *out.entry(file.to_string())
730 .or_default()
731 .entry(number)
732 .or_default() |= count > 0;
733 }
734 }
735 out
736}
737
738fn lcov_lines_metric(lcov: &str, hidden: &BTreeMap<String, BTreeSet<u32>>) -> LlvmCovMetric {
747 let mut tally = Tally::default();
748 for (file, lines) in lcov_lines(lcov) {
749 let gated = hidden.get(&file);
750 for (number, covered) in lines {
751 if gated.is_some_and(|gated| gated.contains(&number)) {
752 continue;
753 }
754 tally.count += 1;
755 tally.covered += u64::from(covered);
756 }
757 }
758 as_metric(tally.count, tally.covered)
759}
760
761fn hidden_lines_by_file(json: &str) -> Result<BTreeMap<String, BTreeSet<u32>>> {
769 let export = parse_llvm_cov_export(json)?;
770 let mut out = BTreeMap::new();
771 for data in &export.data {
772 for file in &data.files {
773 let Ok(source) = std::fs::read_to_string(&file.filename) else {
774 continue;
775 };
776 let lines = crate::isolation::lines_hidden_from_tests(&source);
777 if !lines.is_empty() {
778 out.insert(file.filename.clone(), lines);
779 }
780 }
781 }
782 Ok(out)
783}
784
785#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
787struct Tally {
788 count: u64,
789 covered: u64,
790}
791
792impl Tally {
793 fn merge(&mut self, other: Tally) {
796 self.count = self.count.max(other.count);
797 self.covered = self.covered.max(other.covered);
798 }
799
800 fn add(&mut self, other: Tally) {
801 self.count += other.count;
802 self.covered += other.covered;
803 }
804}
805
806#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
808struct HiddenTotals {
809 regions: Tally,
810 lines: Tally,
811 functions: Tally,
812 branches: Tally,
813}
814
815impl HiddenTotals {
816 fn merge(&mut self, other: HiddenTotals) {
817 self.regions.merge(other.regions);
818 self.lines.merge(other.lines);
819 self.functions.merge(other.functions);
820 self.branches.merge(other.branches);
821 }
822
823 fn add(&mut self, other: HiddenTotals) {
824 self.regions.add(other.regions);
825 self.lines.add(other.lines);
826 self.functions.add(other.functions);
827 self.branches.add(other.branches);
828 }
829}
830
831fn llvm_cov_totals_less_hidden(
833 export: &LlvmCovExport,
834 hidden: &BTreeMap<String, BTreeSet<u32>>,
835) -> LlvmCovReport {
836 LlvmCovReport {
837 data: export
838 .data
839 .iter()
840 .map(|data| LlvmCovData {
841 totals: totals_less_hidden(data, hidden),
842 })
843 .collect(),
844 }
845}
846
847fn totals_less_hidden(
849 data: &LlvmCovExportData,
850 hidden: &BTreeMap<String, BTreeSet<u32>>,
851) -> LlvmCovTotals {
852 let delta = hidden_totals(data, hidden);
853 LlvmCovTotals {
854 regions: metric_less(data.totals.regions, delta.regions),
855 lines: metric_less(data.totals.lines, delta.lines),
856 functions: metric_less(data.totals.functions, delta.functions),
857 branches: data
858 .totals
859 .branches
860 .map(|metric| metric_less(metric, delta.branches)),
861 }
862}
863
864fn metric_less(metric: LlvmCovMetric, delta: Tally) -> LlvmCovMetric {
866 let count = metric.count.saturating_sub(delta.count);
867 as_metric(
868 count,
869 metric.covered.saturating_sub(delta.covered).min(count),
870 )
871}
872
873fn as_metric(count: u64, covered: u64) -> LlvmCovMetric {
876 let percent = if count == 0 {
877 100.0
878 } else {
879 covered as f64 * 100.0 / count as f64
880 };
881 LlvmCovMetric {
882 count,
883 covered,
884 percent,
885 }
886}
887
888fn hidden_totals(
892 data: &LlvmCovExportData,
893 hidden: &BTreeMap<String, BTreeSet<u32>>,
894) -> HiddenTotals {
895 let measured: BTreeSet<&str> = data.files.iter().map(|f| f.filename.as_str()).collect();
896 let mut groups: BTreeMap<(String, i64, i64), HiddenTotals> = BTreeMap::new();
897 for function in &data.functions {
898 let Some((file, line, column)) = function_start(function) else {
899 continue;
900 };
901 if !measured.contains(file.as_str()) {
902 continue;
903 }
904 let Some(lines) = hidden.get(&file) else {
905 continue;
906 };
907 if !lines.contains(&(line.max(0) as u32)) {
908 continue;
909 }
910 groups
911 .entry((file, line, column))
912 .or_default()
913 .merge(function_totals(function, lines));
914 }
915 groups.values().fold(HiddenTotals::default(), |mut acc, g| {
916 acc.add(*g);
917 acc
918 })
919}
920
921fn function_start(function: &LlvmCovFunction) -> Option<(String, i64, i64)> {
924 let region = function.regions.first()?;
925 if region.len() < 8 {
926 return None;
927 }
928 let file = function.filenames.get(usize::try_from(region[5]).ok()?)?;
929 Some((file.clone(), region[0], region[1]))
930}
931
932fn function_totals(function: &LlvmCovFunction, hidden: &BTreeSet<u32>) -> HiddenTotals {
935 let code: Vec<&Vec<i64>> = function
936 .regions
937 .iter()
938 .filter(|region| region.len() >= 8 && region[7] == 0)
939 .collect();
940 let mut lines: BTreeMap<u32, bool> = BTreeMap::new();
941 for region in &code {
942 for line in region[0].max(0) as u32..=region[2].max(0) as u32 {
943 if hidden.contains(&line) {
944 *lines.entry(line).or_default() |= region[4] > 0;
945 }
946 }
947 }
948 HiddenTotals {
949 regions: Tally {
950 count: code.len() as u64,
951 covered: code.iter().filter(|region| region[4] > 0).count() as u64,
952 },
953 lines: Tally {
954 count: lines.len() as u64,
955 covered: lines.values().filter(|covered| **covered).count() as u64,
956 },
957 functions: Tally {
958 count: 1,
959 covered: u64::from(function.count > 0),
960 },
961 branches: branch_tally(&function.branches),
962 }
963}
964
965fn branch_tally(branches: &[Vec<i64>]) -> Tally {
967 let mut tally = Tally::default();
968 for branch in branches.iter().filter(|branch| branch.len() >= 9) {
969 tally.count += 2;
970 tally.covered += u64::from(branch[4] > 0) + u64::from(branch[5] > 0);
971 }
972 tally
973}
974
975fn run_cargo_llvm_cov(
979 root: &Path,
980 ignore: &[String],
981 format: &[&str],
982 features: &[String],
983 branch: bool,
984) -> Result<String> {
985 run_in(&TargetDir::new(), root, ignore, format, features, branch)
986}
987
988fn run_cargo_llvm_cov_json_and_lcov(
992 root: &Path,
993 ignore: &[String],
994 features: &[String],
995 branch: bool,
996) -> Result<(String, String)> {
997 let target = TargetDir::new();
998 let json = run_in(&target, root, ignore, &["--json"], features, branch)?;
999 let lcov = report_in(&target, root, ignore, &["--lcov"])?;
1000 Ok((json, lcov))
1001}
1002
1003fn report_in(
1006 target: &TargetDir,
1007 root: &Path,
1008 ignore: &[String],
1009 format: &[&str],
1010) -> Result<String> {
1011 let mut command = Command::new("cargo");
1012 command
1013 .current_dir(root)
1014 .args(report_argv(format, ignore_filename_regex(root, ignore)))
1015 .env("CARGO_TARGET_DIR", &target.0);
1016 scrub_outer_llvm_cov(&mut command);
1017 let output = command
1018 .output()
1019 .context("running `cargo llvm-cov report` (is cargo-llvm-cov installed?)")?;
1020 llvm_cov_stdout(
1021 output.status.success(),
1022 &output.stdout,
1023 &output.stderr,
1024 &format!(
1025 "cargo llvm-cov could not re-export the profile it just wrote in `{}`:",
1026 root.display()
1027 ),
1028 )
1029}
1030
1031fn report_argv(format: &[&str], ignore_regex: Option<String>) -> Vec<String> {
1032 let mut argv = vec!["llvm-cov".to_string(), "report".to_string()];
1033 argv.extend(format.iter().map(|arg| (*arg).to_string()));
1034 if let Some(regex) = ignore_regex {
1035 argv.extend(["--ignore-filename-regex".to_string(), regex]);
1036 }
1037 argv
1038}
1039
1040fn llvm_cov_stdout(success: bool, stdout: &[u8], stderr: &[u8], failure: &str) -> Result<String> {
1042 if !success {
1043 bail!(
1044 "{failure}\n{}{}",
1045 String::from_utf8_lossy(stdout),
1046 String::from_utf8_lossy(stderr)
1047 );
1048 }
1049 Ok(String::from_utf8_lossy(stdout).into_owned())
1050}
1051
1052fn run_in(
1053 target: &TargetDir,
1054 root: &Path,
1055 ignore: &[String],
1056 format: &[&str],
1057 features: &[String],
1058 branch: bool,
1059) -> Result<String> {
1060 let mut command = Command::new("cargo");
1061 command
1062 .current_dir(root)
1063 .args(run_argv(
1064 format,
1065 features,
1066 branch,
1067 ignore_filename_regex(root, ignore),
1068 ))
1069 .env("CARGO_TARGET_DIR", &target.0);
1070 scrub_outer_llvm_cov(&mut command);
1071 let output = command
1072 .output()
1073 .context("running `cargo llvm-cov` (is cargo-llvm-cov installed?)")?;
1074 llvm_cov_stdout(
1075 output.status.success(),
1076 &output.stdout,
1077 &output.stderr,
1078 &format!(
1079 "the unit suite did not run cleanly under cargo llvm-cov in `{}`:{}",
1080 root.display(),
1081 branch_hint(branch)
1082 ),
1083 )
1084}
1085
1086fn run_argv(
1087 format: &[&str],
1088 features: &[String],
1089 branch: bool,
1090 ignore_regex: Option<String>,
1091) -> Vec<String> {
1092 let mut argv = vec![
1093 "llvm-cov".to_string(),
1094 "--lib".to_string(),
1095 "--bins".to_string(),
1096 ];
1097 argv.extend(format.iter().map(|arg| (*arg).to_string()));
1098 if !features.is_empty() {
1099 argv.extend(["--features".to_string(), features.join(",")]);
1100 }
1101 if branch {
1102 argv.push("--branch".to_string());
1103 }
1104 if let Some(regex) = ignore_regex {
1105 argv.extend(["--ignore-filename-regex".to_string(), regex]);
1106 }
1107 argv
1108}
1109
1110fn branch_hint(branch: bool) -> &'static str {
1111 if branch {
1112 "\n(the [rust].coverage `branch` floor runs with --branch, which requires a \
1113 nightly toolchain — pin one in the crate's rust-toolchain.toml with \
1114 llvm-tools-preview, or set a rustup directory override)"
1115 } else {
1116 ""
1117 }
1118}
1119
1120fn scrub_outer_llvm_cov(command: &mut Command) {
1126 for var in [
1127 "RUSTFLAGS",
1128 "CARGO_ENCODED_RUSTFLAGS",
1129 "RUSTDOCFLAGS",
1130 "CARGO_ENCODED_RUSTDOCFLAGS",
1131 "LLVM_PROFILE_FILE",
1132 "CARGO_LLVM_COV",
1133 "CARGO_LLVM_COV_SHOW_ENV",
1134 "CARGO_LLVM_COV_TARGET_DIR",
1135 "CARGO_LLVM_COV_BUILD_DIR",
1136 "RUSTC_WRAPPER",
1137 "RUSTC_WORKSPACE_WRAPPER",
1138 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
1139 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
1140 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
1141 "RUSTUP_TOOLCHAIN",
1145 "CARGO",
1146 "RUSTC",
1147 ] {
1148 command.env_remove(var);
1149 }
1150}
1151
1152#[derive(Debug, Clone, Default)]
1155pub struct RustPatchCoverage {
1156 pub regions: Vec<(u64, u64, bool)>,
1159}
1160
1161#[derive(Debug, Clone, Deserialize)]
1164struct LlvmCovExport {
1165 data: Vec<LlvmCovExportData>,
1166}
1167
1168#[derive(Debug, Clone, Deserialize)]
1172struct LlvmCovExportData {
1173 files: Vec<LlvmCovExportFile>,
1174 functions: Vec<LlvmCovFunction>,
1175 #[serde(default)]
1178 totals: LlvmCovTotals,
1179}
1180
1181#[derive(Debug, Clone, Deserialize)]
1184struct LlvmCovExportFile {
1185 filename: String,
1186}
1187
1188#[derive(Debug, Clone, Deserialize)]
1193struct LlvmCovFunction {
1194 filenames: Vec<String>,
1195 regions: Vec<Vec<i64>>,
1196 #[serde(default)]
1198 count: u64,
1199 #[serde(default)]
1200 branches: Vec<Vec<i64>>,
1201}
1202
1203pub fn measure_patch_rust_detail(
1207 root: &Path,
1208 ignore: &[String],
1209 features: &[String],
1210) -> Result<BTreeMap<String, RustPatchCoverage>> {
1211 let json = run_cargo_llvm_cov(root, ignore, &["--json"], features, false)?;
1213 let hidden = hidden_lines_by_file(&json)?;
1214 llvm_cov_patch_detail(&json, &hidden)
1215}
1216
1217fn parse_llvm_cov_export(json: &str) -> Result<LlvmCovExport> {
1219 serde_json::from_str(json).context("parsing cargo llvm-cov JSON export")
1220}
1221
1222fn llvm_cov_patch_detail(
1227 json: &str,
1228 hidden: &BTreeMap<String, BTreeSet<u32>>,
1229) -> Result<BTreeMap<String, RustPatchCoverage>> {
1230 let export = parse_llvm_cov_export(json)?;
1231 let mut out: BTreeMap<String, RustPatchCoverage> = BTreeMap::new();
1232 for data in &export.data {
1233 let measured: BTreeSet<&str> = data.files.iter().map(|f| f.filename.as_str()).collect();
1234 for function in &data.functions {
1235 for region in &function.regions {
1236 if region.len() < 8 {
1237 continue;
1238 }
1239 if region[7] != 0 {
1241 continue;
1242 }
1243 let file_id = region[5];
1244 let Ok(file_id) = usize::try_from(file_id) else {
1245 continue;
1246 };
1247 let Some(file) = function.filenames.get(file_id) else {
1248 continue;
1249 };
1250 if !measured.contains(file.as_str()) {
1252 continue;
1253 }
1254 let start = region[0].max(0) as u64;
1255 let end = region[2].max(0) as u64;
1256 if hidden
1259 .get(file)
1260 .is_some_and(|lines| lines.contains(&(start as u32)))
1261 {
1262 continue;
1263 }
1264 let covered = region[4] > 0;
1265 out.entry(file.clone())
1266 .or_default()
1267 .regions
1268 .push((start, end, covered));
1269 }
1270 }
1271 }
1272 Ok(out)
1273}
1274
1275fn ignore_filename_regex(root: &Path, ignore: &[String]) -> Option<String> {
1279 if ignore.is_empty() {
1280 return None;
1281 }
1282 Some(
1283 ignore
1284 .iter()
1285 .map(|rel| {
1286 let full = root.join(rel);
1289 let full = full.canonicalize().unwrap_or(full);
1290 format!("{}$", regex_escape(&full.to_string_lossy()))
1291 })
1292 .collect::<Vec<_>>()
1293 .join("|"),
1294 )
1295}
1296
1297fn regex_escape(s: &str) -> String {
1299 const META: &str = r"\.+*?()|[]{}^$";
1300 let mut out = String::with_capacity(s.len());
1301 for c in s.chars() {
1302 if META.contains(c) {
1303 out.push('\\');
1304 }
1305 out.push(c);
1306 }
1307 out
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312 use super::*;
1313
1314 fn report(percent_covered: f64, num_branches: u64) -> CoverageReport {
1315 CoverageReport {
1316 totals: Totals {
1317 percent_covered,
1318 num_branches,
1319 },
1320 files: BTreeMap::new(),
1321 }
1322 }
1323
1324 #[test]
1325 fn passes_when_total_meets_the_floor() {
1326 assert_eq!(
1327 evaluate(
1328 &report(100.0, 12),
1329 Thresholds {
1330 fail_under: 100,
1331 branch: true
1332 }
1333 ),
1334 Outcome::Pass
1335 );
1336 }
1337
1338 #[test]
1339 fn fails_when_total_is_below_the_floor() {
1340 assert!(matches!(
1341 evaluate(
1342 &report(80.0, 12),
1343 Thresholds {
1344 fail_under: 100,
1345 branch: true
1346 }
1347 ),
1348 Outcome::Fail(_)
1349 ));
1350 }
1351
1352 #[test]
1353 fn passes_when_branch_required_and_none_are_measured() {
1354 assert_eq!(
1355 evaluate(
1356 &report(100.0, 0),
1357 Thresholds {
1358 fail_under: 100,
1359 branch: true
1360 }
1361 ),
1362 Outcome::Pass
1363 );
1364 }
1365
1366 #[test]
1367 fn parses_a_coverage_py_report() {
1368 let json = r#"{"totals":{"percent_covered":91.5,"num_branches":8,"covered_lines":91}}"#;
1369 let report = parse_report(json).expect("valid coverage.py json");
1370 assert_eq!(report.totals.percent_covered, 91.5);
1371 assert_eq!(report.totals.num_branches, 8);
1372 }
1373
1374 #[test]
1375 fn parses_the_per_file_block_for_patch_coverage() {
1376 let json = r#"{
1377 "files": {
1378 "widget.py": {
1379 "executed_lines": [1, 2, 3, 4, 6],
1380 "summary": {"percent_covered": 85.0},
1381 "missing_lines": [5],
1382 "excluded_lines": [],
1383 "missing_branches": [[4, 5]]
1384 }
1385 },
1386 "totals": {"percent_covered": 85.0, "num_branches": 4}
1387 }"#;
1388 let report = parse_report(json).expect("valid coverage.py json with files");
1389 let widget = report.files.get("widget.py").expect("widget.py is present");
1390 assert_eq!(widget.missing_lines, vec![5]);
1391 assert_eq!(widget.missing_branches, vec![vec![4, 5]]);
1392 assert_eq!(report.totals.percent_covered, 85.0);
1393 }
1394
1395 #[test]
1396 fn a_report_without_a_files_block_parses_with_an_empty_map() {
1397 let report = parse_report(r#"{"totals":{"percent_covered":100.0,"num_branches":2}}"#)
1398 .expect("valid coverage.py json");
1399 assert!(report.files.is_empty());
1400 }
1401
1402 #[test]
1403 fn omit_is_the_test_and_support_globs_when_nothing_is_exempt() {
1404 assert_eq!(build_omit(&[]), "*_test.py,*conftest.py");
1405 }
1406
1407 #[test]
1408 fn omit_folds_in_the_exempt_paths_after_the_test_glob() {
1409 let exempt = vec!["pkg/gen.py".to_string(), "shim.py".to_string()];
1410 assert_eq!(
1411 build_omit(&exempt),
1412 "*_test.py,*conftest.py,pkg/gen.py,shim.py"
1413 );
1414 }
1415
1416 fn metric(pct: f64) -> VitestMetric {
1417 VitestMetric {
1418 pct: Some(pct),
1419 total: 10,
1420 }
1421 }
1422
1423 fn ts_report(lines: f64, branches: f64, functions: f64, statements: f64) -> VitestReport {
1424 VitestReport {
1425 total: VitestTotals {
1426 lines: metric(lines),
1427 branches: metric(branches),
1428 functions: metric(functions),
1429 statements: metric(statements),
1430 },
1431 }
1432 }
1433
1434 const TS_FULL: TypeScriptThresholds = TypeScriptThresholds {
1435 lines: 100,
1436 branches: 100,
1437 functions: 100,
1438 statements: 100,
1439 };
1440 const TS_MID: TypeScriptThresholds = TypeScriptThresholds {
1441 lines: 80,
1442 branches: 75,
1443 functions: 80,
1444 statements: 80,
1445 };
1446
1447 #[test]
1448 fn typescript_passes_when_every_metric_meets_its_floor() {
1449 assert_eq!(
1450 evaluate_typescript(&ts_report(100.0, 100.0, 100.0, 100.0), TS_FULL),
1451 Outcome::Pass
1452 );
1453 }
1454
1455 #[test]
1456 fn typescript_fails_on_the_one_metric_below_its_floor() {
1457 let outcome = evaluate_typescript(&ts_report(100.0, 66.66, 100.0, 100.0), TS_MID);
1458 assert!(
1459 matches!(&outcome, Outcome::Fail(message) if message.contains("branches") && !message.contains("lines")),
1460 "got: {outcome:?}"
1461 );
1462 }
1463
1464 #[test]
1465 fn typescript_fail_message_names_every_metric_below() {
1466 let outcome = evaluate_typescript(&ts_report(70.0, 70.0, 70.0, 70.0), TS_MID);
1467 assert!(
1468 matches!(&outcome, Outcome::Fail(message)
1469 if message.contains("lines")
1470 && message.contains("branches")
1471 && message.contains("functions")
1472 && message.contains("statements")),
1473 "got: {outcome:?}"
1474 );
1475 }
1476
1477 #[test]
1478 fn typescript_tolerates_float_noise_at_the_floor() {
1479 assert_eq!(
1480 evaluate_typescript(&ts_report(99.999_999_999, 100.0, 100.0, 100.0), TS_FULL),
1481 Outcome::Pass
1482 );
1483 }
1484
1485 #[test]
1486 fn typescript_empty_denominator_metric_is_vacuously_satisfied() {
1487 let report = VitestReport {
1488 total: VitestTotals {
1489 lines: metric(100.0),
1490 branches: VitestMetric {
1491 pct: None,
1492 total: 0,
1493 },
1494 functions: metric(100.0),
1495 statements: metric(100.0),
1496 },
1497 };
1498 assert_eq!(evaluate_typescript(&report, TS_FULL), Outcome::Pass);
1499 }
1500
1501 #[test]
1502 fn typescript_fails_a_vacuous_run_that_measured_no_code() {
1503 let nothing = VitestMetric {
1504 pct: None,
1505 total: 0,
1506 };
1507 let report = VitestReport {
1508 total: VitestTotals {
1509 lines: nothing,
1510 branches: nothing,
1511 functions: nothing,
1512 statements: nothing,
1513 },
1514 };
1515 let outcome = evaluate_typescript(&report, TS_MID);
1516 assert!(
1517 matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1518 "got: {outcome:?}"
1519 );
1520 }
1521
1522 #[test]
1523 fn parses_a_vitest_summary_report() {
1524 let json = r#"{
1525 "total": {
1526 "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1527 "statements": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1528 "functions": {"total": 2, "covered": 2, "skipped": 0, "pct": 100},
1529 "branches": {"total": 3, "covered": 2, "skipped": 0, "pct": 66.66},
1530 "branchesTrue": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1531 },
1532 "/abs/widget.ts": {
1533 "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80}
1534 }
1535 }"#;
1536 let report = parse_vitest_report(json).expect("valid vitest json-summary");
1537 assert_eq!(report.total.lines.pct, Some(80.0));
1539 assert_eq!(report.total.branches.pct, Some(66.66));
1540 assert_eq!(report.total.functions.total, 2);
1541 }
1542
1543 #[test]
1544 fn parses_an_unknown_pct_as_unmeasured() {
1545 let json = r#"{"total": {
1546 "lines": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1547 "statements": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1548 "functions": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1549 "branches": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1550 }}"#;
1551 let report = parse_vitest_report(json).expect("valid vitest json-summary");
1552 assert_eq!(report.total.lines.pct, None);
1553 assert_eq!(report.total.lines.total, 0);
1554 }
1555
1556 #[test]
1557 fn a_pct_that_is_neither_number_nor_string_is_a_parse_error() {
1558 let json = r#"{"total":{
1559 "lines": {"total": 1, "covered": 1, "skipped": 0, "pct": true},
1560 "statements": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1561 "functions": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1562 "branches": {"total": 1, "covered": 1, "skipped": 0, "pct": 100}
1563 }}"#;
1564 assert!(parse_vitest_report(json).is_err());
1565 }
1566
1567 fn rust_metric(percent: f64) -> LlvmCovMetric {
1568 LlvmCovMetric {
1569 count: 10,
1570 covered: 10,
1571 percent,
1572 }
1573 }
1574
1575 fn rust_report(regions: f64, lines: f64) -> LlvmCovReport {
1576 LlvmCovReport {
1577 data: vec![LlvmCovData {
1578 totals: LlvmCovTotals {
1579 regions: rust_metric(regions),
1580 lines: rust_metric(lines),
1581 functions: rust_metric(lines),
1582 branches: None,
1583 },
1584 }],
1585 }
1586 }
1587
1588 fn rust_report_full(
1591 regions: f64,
1592 lines: f64,
1593 functions: f64,
1594 branches: (u64, f64),
1595 ) -> LlvmCovReport {
1596 let (count, percent) = branches;
1597 LlvmCovReport {
1598 data: vec![LlvmCovData {
1599 totals: LlvmCovTotals {
1600 regions: rust_metric(regions),
1601 lines: rust_metric(lines),
1602 functions: rust_metric(functions),
1603 branches: Some(LlvmCovMetric {
1604 count,
1605 covered: count,
1606 percent,
1607 }),
1608 },
1609 }],
1610 }
1611 }
1612
1613 const RUST_FULL: RustThresholds = RustThresholds {
1614 regions: Some(100),
1615 lines: 100,
1616 functions: None,
1617 branch: None,
1618 };
1619 const RUST_MID: RustThresholds = RustThresholds {
1620 regions: Some(80),
1621 lines: 85,
1622 functions: None,
1623 branch: None,
1624 };
1625
1626 #[test]
1627 fn rust_functions_floor_fails_below_and_passes_at_its_bar() {
1628 let report = rust_report_full(100.0, 100.0, 66.67, (0, 0.0));
1629 let floor = |functions| RustThresholds {
1630 regions: None,
1631 lines: 50,
1632 functions: Some(functions),
1633 branch: None,
1634 };
1635 assert!(matches!(
1636 evaluate_rust(&report, floor(100)),
1637 Outcome::Fail(message) if message.contains("functions")
1638 ));
1639 assert_eq!(evaluate_rust(&report, floor(60)), Outcome::Pass);
1640 }
1641
1642 #[test]
1643 fn rust_branch_floor_fails_below_and_passes_at_its_bar() {
1644 let report = rust_report_full(100.0, 100.0, 100.0, (2, 50.0));
1645 let floor = |branch| RustThresholds {
1646 regions: None,
1647 lines: 50,
1648 functions: None,
1649 branch: Some(branch),
1650 };
1651 assert!(matches!(
1652 evaluate_rust(&report, floor(100)),
1653 Outcome::Fail(message) if message.contains("branches")
1654 ));
1655 assert_eq!(evaluate_rust(&report, floor(50)), Outcome::Pass);
1656 }
1657
1658 #[test]
1659 fn rust_a_branchless_crate_clears_any_branch_floor_vacuously() {
1660 let report = rust_report_full(100.0, 100.0, 100.0, (0, 0.0));
1661 let floor = RustThresholds {
1662 regions: None,
1663 lines: 50,
1664 functions: None,
1665 branch: Some(100),
1666 };
1667 assert_eq!(evaluate_rust(&report, floor), Outcome::Pass);
1668 }
1669
1670 #[test]
1671 fn rust_passes_when_both_metrics_meet_their_floor() {
1672 assert_eq!(
1673 evaluate_rust(&rust_report(100.0, 100.0), RUST_FULL),
1674 Outcome::Pass
1675 );
1676 }
1677
1678 #[test]
1679 fn rust_fails_on_the_one_metric_below_its_floor() {
1680 let outcome = evaluate_rust(&rust_report(70.0, 100.0), RUST_MID);
1681 assert!(
1682 matches!(&outcome, Outcome::Fail(message) if message.contains("regions") && !message.contains("lines")),
1683 "got: {outcome:?}"
1684 );
1685 }
1686
1687 #[test]
1688 fn rust_fail_message_names_every_metric_below() {
1689 let outcome = evaluate_rust(&rust_report(50.0, 50.0), RUST_MID);
1690 assert!(
1691 matches!(&outcome, Outcome::Fail(message)
1692 if message.contains("regions") && message.contains("lines")),
1693 "got: {outcome:?}"
1694 );
1695 }
1696
1697 #[test]
1698 fn rust_skips_the_region_check_when_regions_is_opt_out() {
1699 let thresholds = RustThresholds {
1700 regions: None,
1701 lines: 100,
1702 functions: None,
1703 branch: None,
1704 };
1705 assert_eq!(
1706 evaluate_rust(&rust_report(40.0, 100.0), thresholds),
1707 Outcome::Pass
1708 );
1709 }
1710
1711 #[test]
1712 fn rust_still_fails_lines_with_regions_opt_out() {
1713 let thresholds = RustThresholds {
1714 regions: None,
1715 lines: 100,
1716 functions: None,
1717 branch: None,
1718 };
1719 let outcome = evaluate_rust(&rust_report(100.0, 80.0), thresholds);
1720 assert!(
1721 matches!(&outcome, Outcome::Fail(message)
1722 if message.contains("lines") && !message.contains("regions")),
1723 "got: {outcome:?}"
1724 );
1725 }
1726
1727 #[test]
1728 fn rust_tolerates_float_noise_at_the_floor() {
1729 assert_eq!(
1730 evaluate_rust(&rust_report(99.999_999_999, 100.0), RUST_FULL),
1731 Outcome::Pass
1732 );
1733 }
1734
1735 #[test]
1736 fn rust_fails_a_vacuous_run_that_measured_no_code() {
1737 let nothing = LlvmCovMetric {
1738 count: 0,
1739 covered: 0,
1740 percent: 0.0,
1741 };
1742 let report = LlvmCovReport {
1743 data: vec![LlvmCovData {
1744 totals: LlvmCovTotals {
1745 regions: nothing,
1746 lines: nothing,
1747 functions: nothing,
1748 branches: None,
1749 },
1750 }],
1751 };
1752 let outcome = evaluate_rust(&report, RUST_MID);
1753 assert!(
1754 matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1755 "got: {outcome:?}"
1756 );
1757 }
1758
1759 #[test]
1760 fn rust_fails_an_export_with_no_data() {
1761 let report = LlvmCovReport { data: vec![] };
1762 assert!(matches!(evaluate_rust(&report, RUST_MID), Outcome::Fail(_)));
1763 }
1764
1765 #[test]
1766 fn parses_a_cargo_llvm_cov_report() {
1767 let json = r#"{
1768 "data": [{"totals": {
1769 "regions": {"count": 12, "covered": 9, "notcovered": 3, "percent": 75.0},
1770 "lines": {"count": 20, "covered": 18, "percent": 90.0},
1771 "functions": {"count": 3, "covered": 3, "percent": 100.0}
1772 }}],
1773 "type": "llvm.coverage.json.export",
1774 "version": "2.0.1"
1775 }"#;
1776 let report = parse_llvm_cov_report(json).expect("valid llvm-cov json");
1777 assert_eq!(report.data[0].totals.regions.percent, 75.0);
1778 assert_eq!(report.data[0].totals.lines.count, 20);
1779 }
1780
1781 fn patch_detail(json: &str) -> BTreeMap<String, RustPatchCoverage> {
1783 llvm_cov_patch_detail(json, &BTreeMap::new()).expect("valid llvm-cov export")
1784 }
1785
1786 #[test]
1787 fn llvm_cov_patch_detail_reads_code_regions_per_file() {
1788 let json = r#"{
1789 "data": [{
1790 "files": [{"filename": "/abs/grade.rs"}],
1791 "functions": [{
1792 "filenames": ["/abs/grade.rs"],
1793 "regions": [
1794 [6, 5, 6, 26, 1, 0, 0, 0],
1795 [10, 9, 10, 17, 0, 0, 0, 0]
1796 ]
1797 }]
1798 }],
1799 "type": "llvm.coverage.json.export",
1800 "version": "3.0.1"
1801 }"#;
1802 let out = patch_detail(json);
1803 assert_eq!(
1804 out["/abs/grade.rs"].regions,
1805 vec![(6, 6, true), (10, 10, false)]
1806 );
1807 }
1808
1809 #[test]
1810 fn llvm_cov_patch_detail_skips_non_code_regions() {
1811 let json = r#"{
1812 "data": [{
1813 "files": [{"filename": "/abs/a.rs"}],
1814 "functions": [{
1815 "filenames": ["/abs/a.rs"],
1816 "regions": [
1817 [1, 1, 1, 10, 2, 0, 0, 0],
1818 [2, 1, 2, 10, 0, 0, 0, 1],
1819 [3, 1, 3, 10, 0, 0, 0, 2]
1820 ]
1821 }]
1822 }]
1823 }"#;
1824 let out = patch_detail(json);
1825 assert_eq!(out["/abs/a.rs"].regions, vec![(1, 1, true)]);
1826 }
1827
1828 #[test]
1829 fn llvm_cov_patch_detail_groups_regions_by_filename_id() {
1830 let json = r#"{
1831 "data": [{
1832 "files": [{"filename": "/abs/a.rs"}, {"filename": "/abs/b.rs"}],
1833 "functions": [{
1834 "filenames": ["/abs/a.rs", "/abs/b.rs"],
1835 "regions": [
1836 [1, 1, 1, 5, 1, 0, 0, 0],
1837 [9, 1, 9, 5, 0, 1, 1, 0]
1838 ]
1839 }]
1840 }]
1841 }"#;
1842 let out = patch_detail(json);
1843 assert_eq!(out["/abs/a.rs"].regions, vec![(1, 1, true)]);
1844 assert_eq!(out["/abs/b.rs"].regions, vec![(9, 9, false)]);
1845 }
1846
1847 #[test]
1848 fn llvm_cov_patch_detail_skips_a_malformed_short_region() {
1849 let json = r#"{
1850 "data": [{
1851 "files": [{"filename": "/abs/a.rs"}],
1852 "functions": [{
1853 "filenames": ["/abs/a.rs"],
1854 "regions": [
1855 [4, 1, 4],
1856 [5, 1, 5, 9, 1, 0, 0, 0]
1857 ]
1858 }]
1859 }]
1860 }"#;
1861 let out = patch_detail(json);
1862 assert_eq!(out["/abs/a.rs"].regions, vec![(5, 5, true)]);
1863 }
1864
1865 #[test]
1866 fn llvm_cov_patch_detail_spans_a_multiline_region() {
1867 let json = r#"{
1868 "data": [{
1869 "files": [{"filename": "/abs/a.rs"}],
1870 "functions": [{
1871 "filenames": ["/abs/a.rs"],
1872 "regions": [[3, 5, 5, 6, 0, 0, 0, 0]]
1873 }]
1874 }]
1875 }"#;
1876 let out = patch_detail(json);
1877 assert_eq!(out["/abs/a.rs"].regions, vec![(3, 5, false)]);
1878 }
1879
1880 #[test]
1881 fn llvm_cov_patch_detail_drops_a_file_absent_from_the_files_allowlist() {
1882 let json = r#"{
1883 "data": [{
1884 "files": [{"filename": "/abs/kept.rs"}],
1885 "functions": [{
1886 "filenames": ["/abs/kept.rs", "/abs/ignored.rs"],
1887 "regions": [
1888 [1, 1, 1, 9, 1, 0, 0, 0],
1889 [2, 1, 2, 9, 0, 1, 0, 0]
1890 ]
1891 }]
1892 }]
1893 }"#;
1894 let out = patch_detail(json);
1895 assert_eq!(out["/abs/kept.rs"].regions, vec![(1, 1, true)]);
1896 assert!(!out.contains_key("/abs/ignored.rs"));
1897 }
1898
1899 #[test]
1900 fn llvm_cov_patch_detail_malformed_json_is_an_error() {
1901 assert!(llvm_cov_patch_detail("{ not json", &BTreeMap::new()).is_err());
1902 }
1903
1904 #[test]
1905 fn llvm_cov_patch_detail_skips_a_negative_file_id() {
1906 let json = r#"{
1907 "data": [{
1908 "files": [{"filename": "/abs/a.rs"}],
1909 "functions": [{
1910 "filenames": ["/abs/a.rs"],
1911 "regions": [[1, 1, 1, 5, 1, -1, 0, 0]]
1912 }]
1913 }]
1914 }"#;
1915 let out = patch_detail(json);
1916 assert!(out.is_empty(), "got: {out:?}");
1917 }
1918
1919 #[test]
1920 fn llvm_cov_patch_detail_skips_an_out_of_range_file_id() {
1921 let json = r#"{
1922 "data": [{
1923 "files": [{"filename": "/abs/a.rs"}],
1924 "functions": [{
1925 "filenames": ["/abs/a.rs"],
1926 "regions": [[1, 1, 1, 5, 1, 7, 0, 0]]
1927 }]
1928 }]
1929 }"#;
1930 let out = patch_detail(json);
1931 assert!(out.is_empty(), "got: {out:?}");
1932 }
1933
1934 #[test]
1935 fn istanbul_patch_detail_reads_statements_arms_and_functions() {
1936 let json = r#"{
1937 "/abs/a.ts": {
1938 "statementMap": {"0": {"start": {"line": 1}, "end": {"line": 2}}},
1939 "s": {"0": 1},
1940 "branchMap": {"0": {"loc": {"start": {"line": 3}, "end": {"line": 3}}}},
1941 "b": {"0": [1, 0]},
1942 "fnMap": {"0": {"decl": {"start": {"line": 7}, "end": {"line": 7}}}},
1943 "f": {"0": 0}
1944 }
1945 }"#;
1946 let out = istanbul_patch_detail(json).expect("valid Istanbul report");
1947 let detail = &out["/abs/a.ts"];
1948 assert_eq!(detail.statements, vec![(1, 2, true)]);
1949 assert_eq!(detail.branch_arms, vec![(3, true), (3, false)]);
1950 assert_eq!(detail.functions, vec![(7, false)]);
1951 }
1952
1953 #[test]
1954 fn istanbul_patch_detail_keeps_a_branch_without_counts() {
1955 let json = r#"{
1956 "/abs/a.ts": {
1957 "statementMap": {},
1958 "s": {},
1959 "branchMap": {"0": {"loc": {"start": {"line": 3}, "end": {"line": 3}}}},
1960 "b": {},
1961 "fnMap": {},
1962 "f": {}
1963 }
1964 }"#;
1965 let out = istanbul_patch_detail(json).expect("valid Istanbul report");
1966 assert!(out["/abs/a.ts"].branch_arms.is_empty(), "got: {out:?}");
1967 }
1968
1969 #[test]
1970 fn default_excludes_that_are_not_json_name_the_output() {
1971 let err = parse_default_excludes(b"vitest warmed up first").unwrap_err();
1972 let msg = format!("{err:#}");
1973 assert!(msg.contains("not a JSON string array"), "got: {msg}");
1974 assert!(msg.contains("vitest warmed up first"), "got: {msg}");
1975 }
1976
1977 #[test]
1978 fn default_excludes_drop_a_nul_bearing_pattern() {
1979 let parsed = parse_default_excludes(br#"["**/dist/**", "**/\u0000*"]"#).unwrap();
1980 assert_eq!(parsed, vec!["**/dist/**".to_string()]);
1981 }
1982
1983 #[test]
1984 fn a_missing_vitest_report_names_the_reporter() {
1985 let path = std::env::temp_dir().join("tc-no-such-report/coverage-final.json");
1986 let err = read_vitest_report(&path, "json").unwrap_err();
1987 assert!(format!("{err:#}").contains("json report"), "got: {err:#}");
1988 }
1989
1990 #[test]
1991 fn rust_ignore_regex_is_none_when_nothing_is_exempt() {
1992 assert_eq!(ignore_filename_regex(Path::new("/repo"), &[]), None);
1993 }
1994
1995 #[test]
1996 fn rust_run_argv_carries_format_features_branch_and_ignore_regex() {
1997 assert_eq!(
1998 run_argv(
1999 &["--json"],
2000 &["alpha".to_string(), "beta".to_string()],
2001 true,
2002 Some("src/ignored\\.rs$".to_string()),
2003 ),
2004 [
2005 "llvm-cov",
2006 "--lib",
2007 "--bins",
2008 "--json",
2009 "--features",
2010 "alpha,beta",
2011 "--branch",
2012 "--ignore-filename-regex",
2013 "src/ignored\\.rs$",
2014 ]
2015 );
2016 assert_eq!(
2017 run_argv(&["--lcov"], &[], false, None),
2018 ["llvm-cov", "--lib", "--bins", "--lcov"]
2019 );
2020 }
2021
2022 #[test]
2023 fn rust_report_argv_carries_format_and_optional_ignore_regex() {
2024 assert_eq!(
2025 report_argv(&["--lcov"], Some("src/ignored\\.rs$".to_string())),
2026 [
2027 "llvm-cov",
2028 "report",
2029 "--lcov",
2030 "--ignore-filename-regex",
2031 "src/ignored\\.rs$"
2032 ]
2033 );
2034 assert_eq!(
2035 report_argv(&["--json"], None),
2036 ["llvm-cov", "report", "--json"]
2037 );
2038 }
2039
2040 #[test]
2041 fn branch_hint_names_the_nightly_requirement_only_for_branch_coverage() {
2042 assert_eq!(branch_hint(false), "");
2043 assert!(branch_hint(true).contains("requires a nightly toolchain"));
2044 }
2045
2046 #[test]
2047 fn scrub_removes_outer_llvm_cov_state_from_a_command() {
2048 let mut command = Command::new("cargo");
2049 for name in [
2050 "RUSTFLAGS",
2051 "CARGO_ENCODED_RUSTFLAGS",
2052 "RUSTDOCFLAGS",
2053 "CARGO_ENCODED_RUSTDOCFLAGS",
2054 "LLVM_PROFILE_FILE",
2055 "CARGO_LLVM_COV",
2056 "CARGO_LLVM_COV_SHOW_ENV",
2057 "CARGO_LLVM_COV_TARGET_DIR",
2058 "CARGO_LLVM_COV_BUILD_DIR",
2059 "RUSTC_WRAPPER",
2060 "RUSTC_WORKSPACE_WRAPPER",
2061 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
2062 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
2063 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
2064 "RUSTUP_TOOLCHAIN",
2065 "CARGO",
2066 "RUSTC",
2067 ] {
2068 command.env(name, "outer");
2069 }
2070 command.env("KEEP", "value");
2071 scrub_outer_llvm_cov(&mut command);
2072 let envs: Vec<_> = command.get_envs().collect();
2073 assert_eq!(envs.len(), 18);
2074 assert!(envs
2075 .iter()
2076 .all(|(name, value)| { name == &"KEEP" || value.is_none() }));
2077 assert_eq!(
2078 envs.iter().find(|(name, _)| *name == "KEEP").unwrap().1,
2079 Some("value".as_ref())
2080 );
2081 }
2082
2083 #[test]
2084 fn rust_ignore_regex_anchors_each_exempt_path_to_its_full_path() {
2085 let exempt = vec!["src/shim.rs".to_string(), "src/gen.rs".to_string()];
2087 assert_eq!(
2088 ignore_filename_regex(Path::new("/repo"), &exempt).as_deref(),
2089 Some(r"/repo/src/shim\.rs$|/repo/src/gen\.rs$")
2090 );
2091 }
2092
2093 fn llvm_would_ignore(regex: &str, filename: &str) -> bool {
2096 regex.split('|').any(|alt| {
2097 let (lit, anchored) = match alt.strip_suffix('$') {
2098 Some(head) => (head, true),
2099 None => (alt, false),
2100 };
2101 let lit = lit.replace('\\', "");
2102 if anchored {
2103 filename.ends_with(&lit)
2104 } else {
2105 filename.contains(&lit)
2106 }
2107 })
2108 }
2109
2110 #[test]
2111 fn llvm_would_ignore_matches_an_unanchored_literal_anywhere() {
2112 assert!(llvm_would_ignore("/repo/src", "/repo/src/a.rs"));
2113 assert!(!llvm_would_ignore("/elsewhere", "/repo/src/a.rs"));
2114 }
2115
2116 #[test]
2117 fn rust_ignore_regex_does_not_over_match_a_member_with_the_same_suffix() {
2118 let regex = ignore_filename_regex(Path::new("/repo"), &["src/a.rs".to_string()]).unwrap();
2119 assert!(
2120 llvm_would_ignore(®ex, "/repo/src/a.rs"),
2121 "the exempted file must still be ignored: {regex}"
2122 );
2123 assert!(
2124 !llvm_would_ignore(®ex, "/repo/member/src/a.rs"),
2125 "`src/a.rs` over-matched `member/src/a.rs`: {regex}"
2126 );
2127 assert!(
2128 !llvm_would_ignore(®ex, "/repo/src/xsrc/a.rs"),
2129 "`src/a.rs` over-matched `src/xsrc/a.rs`: {regex}"
2130 );
2131 }
2132
2133 const GATED_EXPORT: &str = r#"{
2136 "data": [{
2137 "files": [{"filename": "/abs/entrypoint.rs"}],
2138 "functions": [
2139 {"name": "report", "count": 1, "filenames": ["/abs/entrypoint.rs"], "branches": [],
2140 "regions": [[11, 1, 11, 37, 1, 0, 0, 0], [12, 5, 12, 19, 1, 0, 0, 0],
2141 [12, 20, 12, 30, 1, 0, 0, 0], [13, 1, 13, 2, 1, 0, 0, 0]]},
2142 {"name": "main", "count": 0, "filenames": ["/abs/entrypoint.rs"], "branches": [],
2143 "regions": [[6, 1, 6, 26, 0, 0, 0, 0], [7, 5, 7, 11, 0, 0, 0, 0],
2144 [7, 12, 7, 46, 0, 0, 0, 0], [8, 1, 8, 2, 0, 0, 0, 0]]},
2145 {"name": "report", "count": 0, "filenames": ["/abs/entrypoint.rs"], "branches": [],
2146 "regions": [[11, 1, 11, 37, 0, 0, 0, 0], [12, 5, 12, 19, 0, 0, 0, 0],
2147 [12, 20, 12, 30, 0, 0, 0, 0], [13, 1, 13, 2, 0, 0, 0, 0]]}
2148 ],
2149 "totals": {
2150 "regions": {"count": 13, "covered": 9, "percent": 69.23},
2151 "lines": {"count": 9, "covered": 6, "percent": 66.67},
2152 "functions": {"count": 3, "covered": 2, "percent": 66.67}
2153 }
2154 }]
2155 }"#;
2156
2157 fn gated_main() -> BTreeMap<String, BTreeSet<u32>> {
2159 BTreeMap::from([(
2160 "/abs/entrypoint.rs".to_string(),
2161 BTreeSet::from([5, 6, 7, 8]),
2162 )])
2163 }
2164
2165 fn totals_less(json: &str, hidden: &BTreeMap<String, BTreeSet<u32>>) -> LlvmCovTotals {
2167 let export = parse_llvm_cov_export(json).expect("valid llvm-cov export");
2168 llvm_cov_totals_less_hidden(&export, hidden).data[0].totals
2169 }
2170
2171 #[test]
2172 fn a_gated_entry_point_leaves_every_ratio_full() {
2173 let totals = totals_less(GATED_EXPORT, &gated_main());
2174 assert_eq!((totals.regions.count, totals.regions.covered), (9, 9));
2175 assert_eq!((totals.lines.count, totals.lines.covered), (6, 6));
2176 assert_eq!((totals.functions.count, totals.functions.covered), (2, 2));
2177 assert_eq!(totals.regions.percent, 100.0);
2178 }
2179
2180 #[test]
2181 fn an_export_with_nothing_gated_keeps_its_totals() {
2182 let totals = totals_less(GATED_EXPORT, &BTreeMap::new());
2183 assert_eq!((totals.regions.count, totals.regions.covered), (13, 9));
2184 assert_eq!((totals.lines.count, totals.lines.covered), (9, 6));
2185 assert_eq!((totals.functions.count, totals.functions.covered), (3, 2));
2186 }
2187
2188 #[test]
2189 fn two_copies_of_one_gated_item_subtract_once() {
2190 let json = GATED_EXPORT.replace(
2191 r#"{"name": "report", "count": 1"#,
2192 r#"{"name": "main", "count": 0, "filenames": ["/abs/entrypoint.rs"], "branches": [],
2193 "regions": [[6, 1, 6, 26, 0, 0, 0, 0], [7, 5, 7, 11, 0, 0, 0, 0],
2194 [7, 12, 7, 46, 0, 0, 0, 0], [8, 1, 8, 2, 0, 0, 0, 0]]},
2195 {"name": "report", "count": 1"#,
2196 );
2197 let totals = totals_less(&json, &gated_main());
2198 assert_eq!((totals.regions.count, totals.regions.covered), (9, 9));
2199 assert_eq!(totals.functions.count, 2);
2200 }
2201
2202 #[test]
2203 fn a_record_with_no_region_to_place_it_is_not_subtracted() {
2204 let json = r#"{
2205 "data": [{
2206 "files": [{"filename": "/abs/a.rs"}],
2207 "functions": [{"name": "empty", "count": 0, "filenames": ["/abs/a.rs"],
2208 "regions": [], "branches": []}],
2209 "totals": {
2210 "regions": {"count": 2, "covered": 2, "percent": 100.0},
2211 "lines": {"count": 2, "covered": 2, "percent": 100.0},
2212 "functions": {"count": 1, "covered": 1, "percent": 100.0}
2213 }
2214 }]
2215 }"#;
2216 let hidden = BTreeMap::from([("/abs/a.rs".to_string(), BTreeSet::from([1]))]);
2217 assert_eq!(totals_less(json, &hidden).regions.count, 2);
2218 }
2219
2220 #[test]
2221 fn a_record_outside_the_files_allowlist_is_not_subtracted() {
2222 let hidden = BTreeMap::from([("/abs/other.rs".to_string(), BTreeSet::from([6]))]);
2223 let json = GATED_EXPORT.replace("\"/abs/entrypoint.rs\"],", "\"/abs/other.rs\"],");
2224 assert_eq!(totals_less(&json, &hidden).regions.count, 13);
2225 }
2226
2227 #[test]
2228 fn a_gated_branch_drops_both_of_its_arms() {
2229 let json = r#"{
2230 "data": [{
2231 "files": [{"filename": "/abs/a.rs"}],
2232 "functions": [{"name": "main", "count": 0, "filenames": ["/abs/a.rs"],
2233 "regions": [[1, 1, 3, 2, 0, 0, 0, 0]],
2234 "branches": [[2, 9, 2, 14, 0, 0, 0, 0, 4], [2, 9, 2, 14, 0, 0, 0, 0]]}],
2235 "totals": {
2236 "regions": {"count": 5, "covered": 4, "percent": 80.0},
2237 "lines": {"count": 9, "covered": 6, "percent": 66.67},
2238 "functions": {"count": 3, "covered": 2, "percent": 66.67},
2239 "branches": {"count": 6, "covered": 4, "percent": 66.67}
2240 }
2241 }]
2242 }"#;
2243 let hidden = BTreeMap::from([("/abs/a.rs".to_string(), BTreeSet::from([1, 2, 3]))]);
2244 let branches = totals_less(json, &hidden).branches.expect("branch totals");
2245 assert_eq!((branches.count, branches.covered), (4, 4));
2247 }
2248
2249 #[test]
2250 fn a_metric_the_subtraction_empties_reads_full() {
2251 let metric = LlvmCovMetric {
2252 count: 4,
2253 covered: 0,
2254 percent: 0.0,
2255 };
2256 let emptied = metric_less(
2257 metric,
2258 Tally {
2259 count: 9,
2260 covered: 9,
2261 },
2262 );
2263 assert_eq!((emptied.count, emptied.covered), (0, 0));
2264 assert_eq!(emptied.percent, 100.0);
2265 }
2266
2267 const TWO_GROUPS_LCOV: &str = "SF:/abs/a.rs\nDA:1,4\nDA:2,4\nDA:3,0\n\
2270 SF:/abs/a.rs\nDA:1,0\nDA:2,0\nDA:3,7\nend_of_record\n";
2271
2272 #[test]
2273 fn a_line_one_instantiation_ran_counts_once_covered() {
2274 let lines = lcov_lines_metric(TWO_GROUPS_LCOV, &BTreeMap::new());
2275 assert_eq!((lines.count, lines.covered), (3, 3));
2276 assert_eq!(lines.percent, 100.0);
2277 }
2278
2279 #[test]
2280 fn a_gated_line_leaves_the_lcov_denominator() {
2281 let hidden = BTreeMap::from([("/abs/a.rs".to_string(), BTreeSet::from([3]))]);
2282 let lines = lcov_lines_metric(TWO_GROUPS_LCOV, &hidden);
2283 assert_eq!((lines.count, lines.covered), (2, 2));
2284 }
2285
2286 #[test]
2287 fn a_line_no_instantiation_ran_is_still_missing() {
2288 let lcov = "SF:/abs/a.rs\nDA:1,4\nDA:2,0\nend_of_record\n";
2289 let lines = lcov_lines_metric(lcov, &BTreeMap::new());
2290 assert_eq!((lines.count, lines.covered), (2, 1));
2291 }
2292
2293 #[test]
2294 fn records_outside_a_source_file_are_not_lines() {
2295 let lcov = "DA:1,4\nSF:/abs/a.rs\nFN:1,main\nDA:x,4\nDA:2\nDA:3,y\nDA:4,1\nLF:4\n";
2296 assert_eq!(
2297 lcov_lines(lcov),
2298 BTreeMap::from([("/abs/a.rs".to_string(), BTreeMap::from([(4, true)]))]),
2299 "a `DA:` before any `SF:`, and a malformed one after, name no line"
2300 );
2301 }
2302
2303 #[test]
2304 fn a_failed_export_carries_both_streams() {
2305 let err = llvm_cov_stdout(false, b"out\n", b"err\n", "could not re-export:")
2306 .expect_err("a non-zero exit is an error");
2307 assert_eq!(format!("{err}"), "could not re-export:\nout\nerr\n");
2308 }
2309
2310 #[test]
2311 fn an_export_measuring_nothing_reads_full() {
2312 let lines = lcov_lines_metric("", &BTreeMap::new());
2313 assert_eq!((lines.count, lines.covered), (0, 0));
2314 assert_eq!(lines.percent, 100.0);
2315 }
2316
2317 #[test]
2318 fn a_function_record_without_a_usable_first_region_has_no_group() {
2319 let short = LlvmCovFunction {
2320 filenames: vec!["/abs/a.rs".to_string()],
2321 regions: vec![vec![1, 1, 1, 2]],
2322 count: 0,
2323 branches: Vec::new(),
2324 };
2325 let unnamed = LlvmCovFunction {
2326 filenames: Vec::new(),
2327 regions: vec![vec![1, 1, 1, 2, 0, 7, 0, 0]],
2328 count: 0,
2329 branches: Vec::new(),
2330 };
2331 let empty = LlvmCovFunction {
2332 filenames: vec!["/abs/a.rs".to_string()],
2333 regions: Vec::new(),
2334 count: 0,
2335 branches: Vec::new(),
2336 };
2337 assert_eq!(function_start(&short), None);
2338 assert_eq!(function_start(&unnamed), None);
2339 assert_eq!(function_start(&empty), None);
2340 }
2341
2342 #[test]
2343 fn a_gated_region_drops_out_of_the_changed_line_detail() {
2344 let hidden =
2345 BTreeMap::from([("/abs/entrypoint.rs".to_string(), BTreeSet::from([6, 7, 8]))]);
2346 let detail = llvm_cov_patch_detail(GATED_EXPORT, &hidden).expect("valid export");
2347 assert_eq!(
2348 detail["/abs/entrypoint.rs"]
2349 .regions
2350 .iter()
2351 .map(|(start, _, _)| *start)
2352 .collect::<BTreeSet<_>>(),
2353 BTreeSet::from([11, 12, 13])
2354 );
2355 }
2356
2357 #[test]
2358 fn hidden_lines_come_from_the_sources_the_export_measured() {
2359 let dir = std::env::temp_dir().join(format!("tc-cov-hidden-{}", std::process::id()));
2360 std::fs::create_dir_all(&dir).unwrap();
2361 let gated = dir.join("gated.rs");
2362 std::fs::write(&gated, "#[cfg(not(test))]\nfn main() {}\n").unwrap();
2363 let json = format!(
2364 r#"{{"data": [{{"files": [{{"filename": "{}"}}, {{"filename": "{}"}}],
2365 "functions": []}}]}}"#,
2366 gated.display(),
2367 dir.join("absent.rs").display(),
2368 );
2369 let hidden = hidden_lines_by_file(&json).expect("valid export");
2370 std::fs::remove_dir_all(&dir).ok();
2371 assert_eq!(
2372 hidden,
2373 BTreeMap::from([(gated.display().to_string(), BTreeSet::from([1, 2]))]),
2374 "an unreadable source hides nothing"
2375 );
2376 }
2377}