1use std::collections::{BTreeMap, BTreeSet};
28use std::path::{Path, PathBuf};
29use std::process::Command;
30use std::sync::atomic::{AtomicU64, Ordering};
31
32use anyhow::{bail, Context, Result};
33use serde::Deserialize;
34
35const TEST_OMIT: &str = "*_test.py";
38
39const SUPPORT_OMIT: &str = "*conftest.py";
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct Thresholds {
47 pub fail_under: u8,
49 pub branch: bool,
51}
52
53#[derive(Debug, Clone, Deserialize)]
58pub struct CoverageReport {
59 pub totals: Totals,
60 #[serde(default)]
64 pub files: BTreeMap<String, FileCoverage>,
65}
66
67#[derive(Debug, Clone, Default, Deserialize)]
71pub struct FileCoverage {
72 #[serde(default)]
74 pub executed_lines: Vec<u64>,
75 #[serde(default)]
78 pub missing_lines: Vec<u64>,
79 #[serde(default)]
81 pub excluded_lines: Vec<u64>,
82 #[serde(default)]
86 pub missing_branches: Vec<Vec<i64>>,
87}
88
89#[derive(Debug, Clone, Deserialize)]
91pub struct Totals {
92 pub percent_covered: f64,
94 #[serde(default)]
96 pub num_branches: u64,
97}
98
99#[derive(Debug, Clone, PartialEq)]
101pub enum Outcome {
102 Pass,
104 Fail(String),
106}
107
108pub fn parse_report(json: &str) -> Result<CoverageReport> {
110 serde_json::from_str(json).context("parsing coverage.py JSON report")
111}
112
113pub fn evaluate(report: &CoverageReport, thresholds: Thresholds) -> Outcome {
118 if thresholds.branch && report.totals.num_branches == 0 {
119 return Outcome::Fail(
120 "branch coverage is required but the report measured no branches".to_string(),
121 );
122 }
123 let actual = report.totals.percent_covered;
124 let required = f64::from(thresholds.fail_under);
125 if actual + 1e-9 >= required {
128 Outcome::Pass
129 } else {
130 Outcome::Fail(format!(
131 "coverage {actual:.2}% is below the required {}%",
132 thresholds.fail_under
133 ))
134 }
135}
136
137pub fn measure(root: &Path, thresholds: Thresholds, omit: &[String]) -> Result<Outcome> {
146 let report = run_coverage(root, omit, false)?;
147 Ok(evaluate(&report, thresholds))
148}
149
150pub fn measure_patch_report(root: &Path, omit: &[String]) -> Result<CoverageReport> {
157 run_coverage(root, omit, true)
158}
159
160struct DataFile(PathBuf);
164
165impl DataFile {
166 fn new() -> Self {
167 static COUNTER: AtomicU64 = AtomicU64::new(0);
168 let name = format!(
169 "testing-conventions-{}-{}.coverage",
170 std::process::id(),
171 COUNTER.fetch_add(1, Ordering::Relaxed),
172 );
173 DataFile(std::env::temp_dir().join(name))
174 }
175}
176
177impl Drop for DataFile {
178 fn drop(&mut self) {
179 let _ = std::fs::remove_file(&self.0);
180 }
181}
182
183fn run_coverage(root: &Path, omit: &[String], include_all_sources: bool) -> Result<CoverageReport> {
190 let data = DataFile::new();
191 let omit = build_omit(omit);
192
193 let mut command = Command::new("coverage");
197 command
198 .current_dir(root)
199 .args(["run", "--branch"])
200 .arg(format!("--omit={omit}"));
201 if include_all_sources {
202 command.arg("--source=.");
203 }
204 let run = command
205 .args(["-m", "pytest", "-q", "-p", "no:cacheprovider", "."])
206 .env("COVERAGE_FILE", &data.0)
207 .env("PYTHONDONTWRITEBYTECODE", "1")
208 .output()
209 .context("running `coverage run -m pytest` (is coverage.py installed?)")?;
210 if !run.status.success() {
211 bail!(
212 "the unit suite did not run cleanly under coverage in `{}`:\n{}{}",
213 root.display(),
214 String::from_utf8_lossy(&run.stdout),
215 String::from_utf8_lossy(&run.stderr),
216 );
217 }
218
219 let json = Command::new("coverage")
221 .current_dir(root)
222 .args(["json", "-o", "-"])
223 .env("COVERAGE_FILE", &data.0)
224 .output()
225 .context("running `coverage json`")?;
226 if !json.status.success() {
227 bail!(
228 "`coverage json` failed:\n{}",
229 String::from_utf8_lossy(&json.stderr),
230 );
231 }
232
233 parse_report(&String::from_utf8_lossy(&json.stdout))
234}
235
236fn build_omit(omit: &[String]) -> String {
243 [TEST_OMIT.to_string(), SUPPORT_OMIT.to_string()]
244 .into_iter()
245 .chain(omit.iter().cloned())
246 .collect::<Vec<_>>()
247 .join(",")
248}
249
250const TS_INCLUDE: &str = "**/*.{ts,tsx,mts,cts}";
264const TS_TEST_EXCLUDE: &str = "**/*.test.*";
268const TS_DECL_EXCLUDE: &str = "**/*.d.{ts,mts,cts}";
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub struct TypeScriptThresholds {
274 pub lines: u8,
275 pub branches: u8,
276 pub functions: u8,
277 pub statements: u8,
278}
279
280#[derive(Debug, Clone, Copy, Deserialize)]
283pub struct VitestReport {
284 pub total: VitestTotals,
285}
286
287#[derive(Debug, Clone, Copy, Deserialize)]
290pub struct VitestTotals {
291 pub lines: VitestMetric,
292 pub branches: VitestMetric,
293 pub functions: VitestMetric,
294 pub statements: VitestMetric,
295}
296
297#[derive(Debug, Clone, Copy, Deserialize)]
300pub struct VitestMetric {
301 #[serde(deserialize_with = "deserialize_pct")]
304 pub pct: Option<f64>,
305 pub total: u64,
307}
308
309fn deserialize_pct<'de, D>(deserializer: D) -> std::result::Result<Option<f64>, D::Error>
313where
314 D: serde::Deserializer<'de>,
315{
316 struct PctVisitor;
317 impl serde::de::Visitor<'_> for PctVisitor {
318 type Value = Option<f64>;
319
320 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
321 f.write_str("a coverage percent number or the string \"Unknown\"")
322 }
323
324 fn visit_f64<E>(self, value: f64) -> std::result::Result<Self::Value, E> {
325 Ok(Some(value))
326 }
327
328 fn visit_u64<E>(self, value: u64) -> std::result::Result<Self::Value, E> {
331 Ok(Some(value as f64))
332 }
333
334 fn visit_str<E>(self, _value: &str) -> std::result::Result<Self::Value, E> {
337 Ok(None)
338 }
339 }
340 deserializer.deserialize_any(PctVisitor)
341}
342
343pub fn parse_vitest_report(json: &str) -> Result<VitestReport> {
345 serde_json::from_str(json).context("parsing vitest coverage-summary JSON report")
346}
347
348pub fn evaluate_typescript(report: &VitestReport, thresholds: TypeScriptThresholds) -> Outcome {
357 let total = &report.total;
358 if total.lines.total == 0 {
362 return Outcome::Fail(
363 "the unit suite measured no code — check the path and that the suite runs".to_string(),
364 );
365 }
366 let checks = [
367 ("lines", total.lines, thresholds.lines),
368 ("branches", total.branches, thresholds.branches),
369 ("functions", total.functions, thresholds.functions),
370 ("statements", total.statements, thresholds.statements),
371 ];
372 let mut shortfalls = Vec::new();
373 for (name, metric, required) in checks {
374 let actual = metric.pct.unwrap_or(100.0);
377 if actual + 1e-9 < f64::from(required) {
380 shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
381 }
382 }
383 if shortfalls.is_empty() {
384 Outcome::Pass
385 } else {
386 Outcome::Fail(format!(
387 "coverage below thresholds: {}",
388 shortfalls.join(", ")
389 ))
390 }
391}
392
393pub fn measure_typescript(
403 root: &Path,
404 thresholds: TypeScriptThresholds,
405 exclude: &[String],
406) -> Result<Outcome> {
407 let report = run_vitest(root, exclude)?;
408 Ok(evaluate_typescript(&report, thresholds))
409}
410
411struct ReportDir(PathBuf);
415
416impl ReportDir {
417 fn new() -> Self {
418 static COUNTER: AtomicU64 = AtomicU64::new(0);
419 let name = format!(
420 "testing-conventions-vitest-{}-{}",
421 std::process::id(),
422 COUNTER.fetch_add(1, Ordering::Relaxed),
423 );
424 ReportDir(std::env::temp_dir().join(name))
425 }
426}
427
428impl Drop for ReportDir {
429 fn drop(&mut self) {
430 let _ = std::fs::remove_dir_all(&self.0);
431 }
432}
433
434fn run_vitest(root: &Path, exclude: &[String]) -> Result<VitestReport> {
436 let json = run_vitest_coverage(root, exclude, "json-summary", "coverage-summary.json")?;
437 parse_vitest_report(&json)
438}
439
440fn run_vitest_coverage(
453 root: &Path,
454 exclude: &[String],
455 reporter: &str,
456 report_file: &str,
457) -> Result<String> {
458 let reports = ReportDir::new();
459
460 let mut command = Command::new("npx");
461 command
462 .current_dir(root)
463 .args(["--yes", "vitest", "run", "--no-cache"])
464 .args(["--coverage.enabled", "--coverage.provider=v8"])
465 .arg(format!("--coverage.reporter={reporter}"))
466 .arg("--coverage.all=true")
467 .arg(format!(
468 "--coverage.reportsDirectory={}",
469 reports.0.display()
470 ))
471 .arg(format!("--coverage.include={TS_INCLUDE}"))
472 .arg(format!("--coverage.exclude={TS_TEST_EXCLUDE}"))
473 .arg(format!("--coverage.exclude={TS_DECL_EXCLUDE}"));
474 for path in exclude {
475 command.arg(format!("--coverage.exclude={path}"));
476 }
477 let run = command.env("CI", "1").output().context(
479 "running `npx vitest run --coverage` (are vitest and @vitest/coverage-v8 installed?)",
480 )?;
481 if !run.status.success() {
482 bail!(
483 "the unit suite did not run cleanly under vitest in `{}`:\n{}{}",
484 root.display(),
485 String::from_utf8_lossy(&run.stdout),
486 String::from_utf8_lossy(&run.stderr),
487 );
488 }
489
490 let path = reports.0.join(report_file);
491 std::fs::read_to_string(&path).with_context(|| {
492 format!(
493 "reading vitest coverage report `{}` (did the run produce a {reporter} report?)",
494 path.display()
495 )
496 })
497}
498
499pub fn measure_patch_typescript(
519 root: &Path,
520 exclude: &[String],
521) -> Result<BTreeMap<String, BTreeSet<u64>>> {
522 let json = run_vitest_coverage(root, exclude, "json", "coverage-final.json")?;
523 uncovered_istanbul_lines(&json)
524}
525
526#[derive(Debug, Clone, Deserialize)]
530struct IstanbulFile {
531 #[serde(rename = "statementMap", default)]
534 statement_map: BTreeMap<String, IstanbulSpan>,
535 #[serde(default)]
537 s: BTreeMap<String, u64>,
538 #[serde(rename = "branchMap", default)]
541 branch_map: BTreeMap<String, IstanbulBranch>,
542 #[serde(default)]
544 b: BTreeMap<String, Vec<u64>>,
545}
546
547#[derive(Debug, Clone, Deserialize)]
549struct IstanbulSpan {
550 start: IstanbulPos,
551 end: IstanbulPos,
552}
553
554#[derive(Debug, Clone, Deserialize)]
556struct IstanbulPos {
557 line: u64,
558}
559
560#[derive(Debug, Clone, Deserialize)]
563struct IstanbulBranch {
564 loc: IstanbulSpan,
565}
566
567fn uncovered_istanbul_lines(json: &str) -> Result<BTreeMap<String, BTreeSet<u64>>> {
574 let files: BTreeMap<String, IstanbulFile> = serde_json::from_str(json)
575 .context("parsing vitest coverage-final (Istanbul) JSON report")?;
576 let mut out = BTreeMap::new();
577 for (path, file) in files {
578 let mut lines = BTreeSet::new();
579 for (id, span) in &file.statement_map {
582 if file.s.get(id) == Some(&0) {
583 lines.extend(span.start.line..=span.end.line);
584 }
585 }
586 for (id, branch) in &file.branch_map {
589 if file.b.get(id).is_some_and(|counts| counts.contains(&0)) {
590 lines.insert(branch.loc.start.line);
591 }
592 }
593 out.insert(path, lines);
594 }
595 Ok(out)
596}
597
598#[derive(Debug, Clone, Copy, PartialEq, Eq)]
612pub struct RustThresholds {
613 pub regions: u8,
614 pub lines: u8,
615}
616
617#[derive(Debug, Clone, Deserialize)]
621pub struct LlvmCovReport {
622 pub data: Vec<LlvmCovData>,
623}
624
625#[derive(Debug, Clone, Copy, Deserialize)]
628pub struct LlvmCovData {
629 pub totals: LlvmCovTotals,
630}
631
632#[derive(Debug, Clone, Copy, Deserialize)]
636pub struct LlvmCovTotals {
637 pub regions: LlvmCovMetric,
638 pub lines: LlvmCovMetric,
639}
640
641#[derive(Debug, Clone, Copy, Deserialize)]
644pub struct LlvmCovMetric {
645 pub count: u64,
647 pub covered: u64,
649 pub percent: f64,
651}
652
653pub fn parse_llvm_cov_report(json: &str) -> Result<LlvmCovReport> {
655 serde_json::from_str(json).context("parsing cargo llvm-cov JSON report")
656}
657
658pub fn evaluate_rust(report: &LlvmCovReport, thresholds: RustThresholds) -> Outcome {
664 let Some(totals) = report.data.first().map(|entry| &entry.totals) else {
665 return Outcome::Fail("the cargo llvm-cov report contained no data".to_string());
666 };
667 if totals.regions.count == 0 {
671 return Outcome::Fail(
672 "the unit suite measured no code — check the path and that the suite runs".to_string(),
673 );
674 }
675 let checks = [
676 ("regions", totals.regions.percent, thresholds.regions),
677 ("lines", totals.lines.percent, thresholds.lines),
678 ];
679 let mut shortfalls = Vec::new();
680 for (name, actual, required) in checks {
681 if actual + 1e-9 < f64::from(required) {
684 shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
685 }
686 }
687 if shortfalls.is_empty() {
688 Outcome::Pass
689 } else {
690 Outcome::Fail(format!(
691 "coverage below thresholds: {}",
692 shortfalls.join(", ")
693 ))
694 }
695}
696
697pub fn measure_rust(root: &Path, thresholds: RustThresholds, ignore: &[String]) -> Result<Outcome> {
705 let report = run_llvm_cov(root, ignore)?;
706 Ok(evaluate_rust(&report, thresholds))
707}
708
709struct TargetDir(PathBuf);
713
714impl TargetDir {
715 fn new() -> Self {
716 static COUNTER: AtomicU64 = AtomicU64::new(0);
717 let name = format!(
718 "testing-conventions-llvm-cov-{}-{}",
719 std::process::id(),
720 COUNTER.fetch_add(1, Ordering::Relaxed),
721 );
722 TargetDir(std::env::temp_dir().join(name))
723 }
724}
725
726impl Drop for TargetDir {
727 fn drop(&mut self) {
728 let _ = std::fs::remove_dir_all(&self.0);
729 }
730}
731
732fn run_llvm_cov(root: &Path, ignore: &[String]) -> Result<LlvmCovReport> {
735 parse_llvm_cov_report(&run_cargo_llvm_cov(
736 root,
737 ignore,
738 &["--json", "--summary-only"],
739 )?)
740}
741
742fn run_cargo_llvm_cov(root: &Path, ignore: &[String], format: &[&str]) -> Result<String> {
752 let target = TargetDir::new();
753
754 let mut command = Command::new("cargo");
755 command
756 .current_dir(root)
757 .arg("llvm-cov")
758 .args(format)
759 .env("CARGO_TARGET_DIR", &target.0);
760 if let Some(regex) = ignore_filename_regex(ignore) {
761 command.arg("--ignore-filename-regex").arg(regex);
762 }
763 for var in [
772 "RUSTFLAGS",
773 "CARGO_ENCODED_RUSTFLAGS",
774 "RUSTDOCFLAGS",
775 "CARGO_ENCODED_RUSTDOCFLAGS",
776 "LLVM_PROFILE_FILE",
777 "CARGO_LLVM_COV",
778 "CARGO_LLVM_COV_SHOW_ENV",
779 "CARGO_LLVM_COV_TARGET_DIR",
780 "CARGO_LLVM_COV_BUILD_DIR",
781 "RUSTC_WRAPPER",
782 "RUSTC_WORKSPACE_WRAPPER",
783 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
784 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
785 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
786 ] {
787 command.env_remove(var);
788 }
789 let output = command
790 .output()
791 .context("running `cargo llvm-cov` (is cargo-llvm-cov installed?)")?;
792 if !output.status.success() {
793 bail!(
794 "the unit suite did not run cleanly under cargo llvm-cov in `{}`:\n{}{}",
795 root.display(),
796 String::from_utf8_lossy(&output.stdout),
797 String::from_utf8_lossy(&output.stderr),
798 );
799 }
800 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
801}
802
803pub fn measure_patch_rust(
811 root: &Path,
812 ignore: &[String],
813) -> Result<BTreeMap<String, BTreeSet<u64>>> {
814 Ok(uncovered_lcov_lines(&run_cargo_llvm_cov(
815 root,
816 ignore,
817 &["--lcov"],
818 )?))
819}
820
821fn uncovered_lcov_lines(lcov: &str) -> BTreeMap<String, BTreeSet<u64>> {
828 let mut out: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
829 let mut current: Option<String> = None;
830 for line in lcov.lines() {
831 if let Some(path) = line.strip_prefix("SF:") {
832 let path = path.trim().to_string();
833 out.entry(path.clone()).or_default();
834 current = Some(path);
835 } else if let Some(rest) = line.strip_prefix("DA:") {
836 if let Some(file) = ¤t {
838 let mut fields = rest.split(',');
839 if let (Some(line_no), Some(count)) = (fields.next(), fields.next()) {
840 if let (Ok(line_no), Ok(0)) =
841 (line_no.trim().parse::<u64>(), count.trim().parse::<u64>())
842 {
843 out.entry(file.clone()).or_default().insert(line_no);
844 }
845 }
846 }
847 } else if line.trim() == "end_of_record" {
848 current = None;
849 }
850 }
851 out
852}
853
854fn ignore_filename_regex(ignore: &[String]) -> Option<String> {
860 if ignore.is_empty() {
861 return None;
862 }
863 Some(
864 ignore
865 .iter()
866 .map(|path| regex_escape(path))
867 .collect::<Vec<_>>()
868 .join("|"),
869 )
870}
871
872fn regex_escape(s: &str) -> String {
875 const META: &str = r"\.+*?()|[]{}^$";
876 let mut out = String::with_capacity(s.len());
877 for c in s.chars() {
878 if META.contains(c) {
879 out.push('\\');
880 }
881 out.push(c);
882 }
883 out
884}
885
886#[cfg(test)]
887mod tests {
888 use super::*;
889
890 fn report(percent_covered: f64, num_branches: u64) -> CoverageReport {
891 CoverageReport {
892 totals: Totals {
893 percent_covered,
894 num_branches,
895 },
896 files: BTreeMap::new(),
897 }
898 }
899
900 #[test]
901 fn passes_when_total_meets_the_floor() {
902 assert_eq!(
903 evaluate(
904 &report(100.0, 12),
905 Thresholds {
906 fail_under: 100,
907 branch: true
908 }
909 ),
910 Outcome::Pass
911 );
912 }
913
914 #[test]
915 fn fails_when_total_is_below_the_floor() {
916 assert!(matches!(
917 evaluate(
918 &report(80.0, 12),
919 Thresholds {
920 fail_under: 100,
921 branch: true
922 }
923 ),
924 Outcome::Fail(_)
925 ));
926 }
927
928 #[test]
929 fn fails_when_branch_required_but_unmeasured() {
930 assert!(matches!(
932 evaluate(
933 &report(100.0, 0),
934 Thresholds {
935 fail_under: 90,
936 branch: true
937 }
938 ),
939 Outcome::Fail(_)
940 ));
941 }
942
943 #[test]
944 fn parses_a_coverage_py_report() {
945 let json = r#"{"totals":{"percent_covered":91.5,"num_branches":8,"covered_lines":91}}"#;
946 let report = parse_report(json).expect("valid coverage.py json");
947 assert_eq!(report.totals.percent_covered, 91.5);
948 assert_eq!(report.totals.num_branches, 8);
949 }
950
951 #[test]
952 fn parses_the_per_file_block_for_patch_coverage() {
953 let json = r#"{
956 "files": {
957 "widget.py": {
958 "executed_lines": [1, 2, 3, 4, 6],
959 "summary": {"percent_covered": 85.0},
960 "missing_lines": [5],
961 "excluded_lines": [],
962 "missing_branches": [[4, 5]]
963 }
964 },
965 "totals": {"percent_covered": 85.0, "num_branches": 4}
966 }"#;
967 let report = parse_report(json).expect("valid coverage.py json with files");
968 let widget = report.files.get("widget.py").expect("widget.py is present");
969 assert_eq!(widget.missing_lines, vec![5]);
970 assert_eq!(widget.missing_branches, vec![vec![4, 5]]);
971 assert_eq!(report.totals.percent_covered, 85.0);
973 }
974
975 #[test]
976 fn a_report_without_a_files_block_parses_with_an_empty_map() {
977 let report = parse_report(r#"{"totals":{"percent_covered":100.0,"num_branches":2}}"#)
979 .expect("valid coverage.py json");
980 assert!(report.files.is_empty());
981 }
982
983 #[test]
984 fn omit_is_the_test_and_support_globs_when_nothing_is_exempt() {
985 assert_eq!(build_omit(&[]), "*_test.py,*conftest.py");
986 }
987
988 #[test]
989 fn omit_folds_in_the_exempt_paths_after_the_test_glob() {
990 let exempt = vec!["pkg/gen.py".to_string(), "shim.py".to_string()];
992 assert_eq!(
993 build_omit(&exempt),
994 "*_test.py,*conftest.py,pkg/gen.py,shim.py"
995 );
996 }
997
998 fn metric(pct: f64) -> VitestMetric {
1001 VitestMetric {
1002 pct: Some(pct),
1003 total: 10,
1004 }
1005 }
1006
1007 fn ts_report(lines: f64, branches: f64, functions: f64, statements: f64) -> VitestReport {
1008 VitestReport {
1009 total: VitestTotals {
1010 lines: metric(lines),
1011 branches: metric(branches),
1012 functions: metric(functions),
1013 statements: metric(statements),
1014 },
1015 }
1016 }
1017
1018 const TS_FULL: TypeScriptThresholds = TypeScriptThresholds {
1019 lines: 100,
1020 branches: 100,
1021 functions: 100,
1022 statements: 100,
1023 };
1024 const TS_MID: TypeScriptThresholds = TypeScriptThresholds {
1025 lines: 80,
1026 branches: 75,
1027 functions: 80,
1028 statements: 80,
1029 };
1030
1031 #[test]
1032 fn typescript_passes_when_every_metric_meets_its_floor() {
1033 assert_eq!(
1034 evaluate_typescript(&ts_report(100.0, 100.0, 100.0, 100.0), TS_FULL),
1035 Outcome::Pass
1036 );
1037 }
1038
1039 #[test]
1040 fn typescript_fails_on_the_one_metric_below_its_floor() {
1041 let outcome = evaluate_typescript(&ts_report(100.0, 66.66, 100.0, 100.0), TS_MID);
1045 assert!(
1046 matches!(&outcome, Outcome::Fail(message) if message.contains("branches") && !message.contains("lines")),
1047 "got: {outcome:?}"
1048 );
1049 }
1050
1051 #[test]
1052 fn typescript_fail_message_names_every_metric_below() {
1053 let outcome = evaluate_typescript(&ts_report(70.0, 70.0, 70.0, 70.0), TS_MID);
1054 assert!(
1055 matches!(&outcome, Outcome::Fail(message)
1056 if message.contains("lines")
1057 && message.contains("branches")
1058 && message.contains("functions")
1059 && message.contains("statements")),
1060 "got: {outcome:?}"
1061 );
1062 }
1063
1064 #[test]
1065 fn typescript_tolerates_float_noise_at_the_floor() {
1066 assert_eq!(
1068 evaluate_typescript(&ts_report(99.999_999_999, 100.0, 100.0, 100.0), TS_FULL),
1069 Outcome::Pass
1070 );
1071 }
1072
1073 #[test]
1074 fn typescript_empty_denominator_metric_is_vacuously_satisfied() {
1075 let report = VitestReport {
1078 total: VitestTotals {
1079 lines: metric(100.0),
1080 branches: VitestMetric {
1081 pct: None,
1082 total: 0,
1083 },
1084 functions: metric(100.0),
1085 statements: metric(100.0),
1086 },
1087 };
1088 assert_eq!(evaluate_typescript(&report, TS_FULL), Outcome::Pass);
1089 }
1090
1091 #[test]
1092 fn typescript_fails_a_vacuous_run_that_measured_no_code() {
1093 let nothing = VitestMetric {
1096 pct: None,
1097 total: 0,
1098 };
1099 let report = VitestReport {
1100 total: VitestTotals {
1101 lines: nothing,
1102 branches: nothing,
1103 functions: nothing,
1104 statements: nothing,
1105 },
1106 };
1107 let outcome = evaluate_typescript(&report, TS_MID);
1108 assert!(
1109 matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1110 "got: {outcome:?}"
1111 );
1112 }
1113
1114 #[test]
1115 fn parses_a_vitest_summary_report() {
1116 let json = r#"{
1119 "total": {
1120 "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1121 "statements": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
1122 "functions": {"total": 2, "covered": 2, "skipped": 0, "pct": 100},
1123 "branches": {"total": 3, "covered": 2, "skipped": 0, "pct": 66.66},
1124 "branchesTrue": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1125 },
1126 "/abs/widget.ts": {
1127 "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80}
1128 }
1129 }"#;
1130 let report = parse_vitest_report(json).expect("valid vitest json-summary");
1131 assert_eq!(report.total.lines.pct, Some(80.0));
1133 assert_eq!(report.total.branches.pct, Some(66.66));
1134 assert_eq!(report.total.functions.total, 2);
1135 }
1136
1137 #[test]
1138 fn parses_an_unknown_pct_as_unmeasured() {
1139 let json = r#"{"total": {
1140 "lines": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1141 "statements": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1142 "functions": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1143 "branches": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1144 }}"#;
1145 let report = parse_vitest_report(json).expect("valid vitest json-summary");
1146 assert_eq!(report.total.lines.pct, None);
1147 assert_eq!(report.total.lines.total, 0);
1148 }
1149
1150 #[test]
1151 fn a_pct_that_is_neither_number_nor_string_is_a_parse_error() {
1152 let json = r#"{"total":{
1155 "lines": {"total": 1, "covered": 1, "skipped": 0, "pct": true},
1156 "statements": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1157 "functions": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1158 "branches": {"total": 1, "covered": 1, "skipped": 0, "pct": 100}
1159 }}"#;
1160 assert!(parse_vitest_report(json).is_err());
1161 }
1162
1163 #[test]
1166 fn istanbul_flags_an_unexecuted_statement() {
1167 let json = r#"{"/abs/widget.ts":{
1169 "statementMap":{"0":{"start":{"line":1,"column":0},"end":{"line":1,"column":40}},
1170 "1":{"start":{"line":2,"column":2},"end":{"line":2,"column":20}}},
1171 "s":{"0":1,"1":0},
1172 "branchMap":{},"b":{}
1173 }}"#;
1174 let out = uncovered_istanbul_lines(json).unwrap();
1175 assert_eq!(out["/abs/widget.ts"], BTreeSet::from([2]));
1176 }
1177
1178 #[test]
1179 fn istanbul_flags_an_untaken_branch_source() {
1180 let json = r#"{"/abs/widget.ts":{
1183 "statementMap":{"0":{"start":{"line":3,"column":2},"end":{"line":3,"column":20}}},
1184 "s":{"0":5},
1185 "branchMap":{"0":{"loc":{"start":{"line":3,"column":2},"end":{"line":3,"column":40}}}},
1186 "b":{"0":[4,0]}
1187 }}"#;
1188 let out = uncovered_istanbul_lines(json).unwrap();
1189 assert_eq!(out["/abs/widget.ts"], BTreeSet::from([3]));
1190 }
1191
1192 #[test]
1193 fn istanbul_v8_single_arm_branch_counts_as_uncovered() {
1194 let json = r#"{"/abs/widget.ts":{
1197 "statementMap":{},"s":{},
1198 "branchMap":{"0":{"loc":{"start":{"line":7,"column":0},"end":{"line":7,"column":3}}}},
1199 "b":{"0":[0]}
1200 }}"#;
1201 let out = uncovered_istanbul_lines(json).unwrap();
1202 assert_eq!(out["/abs/widget.ts"], BTreeSet::from([7]));
1203 }
1204
1205 #[test]
1206 fn istanbul_spans_every_line_of_an_unexecuted_multiline_statement() {
1207 let json = r#"{"/abs/widget.ts":{
1209 "statementMap":{"0":{"start":{"line":4,"column":2},"end":{"line":6,"column":3}}},
1210 "s":{"0":0},
1211 "branchMap":{},"b":{}
1212 }}"#;
1213 let out = uncovered_istanbul_lines(json).unwrap();
1214 assert_eq!(out["/abs/widget.ts"], BTreeSet::from([4, 5, 6]));
1215 }
1216
1217 #[test]
1218 fn istanbul_fully_covered_file_has_no_uncovered_lines() {
1219 let json = r#"{"/abs/widget.ts":{
1220 "statementMap":{"0":{"start":{"line":1,"column":0},"end":{"line":1,"column":40}}},
1221 "s":{"0":3},
1222 "branchMap":{"0":{"loc":{"start":{"line":1,"column":0},"end":{"line":1,"column":40}}}},
1223 "b":{"0":[2,1]}
1224 }}"#;
1225 let out = uncovered_istanbul_lines(json).unwrap();
1226 assert!(out["/abs/widget.ts"].is_empty());
1227 }
1228
1229 #[test]
1230 fn istanbul_widget_report_flags_statement_and_branch_lines() {
1231 let json = r#"{"/abs/widget.ts":{
1235 "statementMap":{
1236 "0":{"start":{"line":1,"column":0},"end":{"line":1,"column":43}},
1237 "1":{"start":{"line":2,"column":2},"end":{"line":2,"column":25}},
1238 "2":{"start":{"line":3,"column":2},"end":{"line":3,"column":16}},
1239 "3":{"start":{"line":4,"column":4},"end":{"line":4,"column":20}},
1240 "4":{"start":{"line":5,"column":2},"end":{"line":5,"column":3}},
1241 "5":{"start":{"line":6,"column":2},"end":{"line":6,"column":15}}
1242 },
1243 "s":{"0":1,"1":2,"2":2,"3":0,"4":0,"5":1},
1244 "branchMap":{
1245 "0":{"loc":{"start":{"line":2,"column":2},"end":{"line":2,"column":25}}},
1246 "1":{"loc":{"start":{"line":3,"column":2},"end":{"line":3,"column":16}}}
1247 },
1248 "b":{"0":[2],"1":[0]}
1249 }}"#;
1250 let out = uncovered_istanbul_lines(json).unwrap();
1251 assert_eq!(out["/abs/widget.ts"], BTreeSet::from([3, 4, 5]));
1252 }
1253
1254 #[test]
1255 fn istanbul_malformed_json_is_an_error() {
1256 assert!(uncovered_istanbul_lines("{ not json").is_err());
1257 }
1258
1259 fn rust_metric(percent: f64) -> LlvmCovMetric {
1262 LlvmCovMetric {
1263 count: 10,
1264 covered: 10,
1265 percent,
1266 }
1267 }
1268
1269 fn rust_report(regions: f64, lines: f64) -> LlvmCovReport {
1270 LlvmCovReport {
1271 data: vec![LlvmCovData {
1272 totals: LlvmCovTotals {
1273 regions: rust_metric(regions),
1274 lines: rust_metric(lines),
1275 },
1276 }],
1277 }
1278 }
1279
1280 const RUST_FULL: RustThresholds = RustThresholds {
1281 regions: 100,
1282 lines: 100,
1283 };
1284 const RUST_MID: RustThresholds = RustThresholds {
1285 regions: 80,
1286 lines: 85,
1287 };
1288
1289 #[test]
1290 fn rust_passes_when_both_metrics_meet_their_floor() {
1291 assert_eq!(
1292 evaluate_rust(&rust_report(100.0, 100.0), RUST_FULL),
1293 Outcome::Pass
1294 );
1295 }
1296
1297 #[test]
1298 fn rust_fails_on_the_one_metric_below_its_floor() {
1299 let outcome = evaluate_rust(&rust_report(70.0, 100.0), RUST_MID);
1303 assert!(
1304 matches!(&outcome, Outcome::Fail(message) if message.contains("regions") && !message.contains("lines")),
1305 "got: {outcome:?}"
1306 );
1307 }
1308
1309 #[test]
1310 fn rust_fail_message_names_every_metric_below() {
1311 let outcome = evaluate_rust(&rust_report(50.0, 50.0), RUST_MID);
1312 assert!(
1313 matches!(&outcome, Outcome::Fail(message)
1314 if message.contains("regions") && message.contains("lines")),
1315 "got: {outcome:?}"
1316 );
1317 }
1318
1319 #[test]
1320 fn rust_tolerates_float_noise_at_the_floor() {
1321 assert_eq!(
1323 evaluate_rust(&rust_report(99.999_999_999, 100.0), RUST_FULL),
1324 Outcome::Pass
1325 );
1326 }
1327
1328 #[test]
1329 fn rust_fails_a_vacuous_run_that_measured_no_code() {
1330 let nothing = LlvmCovMetric {
1333 count: 0,
1334 covered: 0,
1335 percent: 0.0,
1336 };
1337 let report = LlvmCovReport {
1338 data: vec![LlvmCovData {
1339 totals: LlvmCovTotals {
1340 regions: nothing,
1341 lines: nothing,
1342 },
1343 }],
1344 };
1345 let outcome = evaluate_rust(&report, RUST_MID);
1346 assert!(
1347 matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
1348 "got: {outcome:?}"
1349 );
1350 }
1351
1352 #[test]
1353 fn rust_fails_an_export_with_no_data() {
1354 let report = LlvmCovReport { data: vec![] };
1357 assert!(matches!(evaluate_rust(&report, RUST_MID), Outcome::Fail(_)));
1358 }
1359
1360 #[test]
1361 fn parses_a_cargo_llvm_cov_report() {
1362 let json = r#"{
1365 "data": [{"totals": {
1366 "regions": {"count": 12, "covered": 9, "notcovered": 3, "percent": 75.0},
1367 "lines": {"count": 20, "covered": 18, "percent": 90.0},
1368 "functions": {"count": 3, "covered": 3, "percent": 100.0}
1369 }}],
1370 "type": "llvm.coverage.json.export",
1371 "version": "2.0.1"
1372 }"#;
1373 let report = parse_llvm_cov_report(json).expect("valid llvm-cov json");
1374 assert_eq!(report.data[0].totals.regions.percent, 75.0);
1375 assert_eq!(report.data[0].totals.lines.count, 20);
1376 }
1377
1378 #[test]
1379 fn rust_ignore_regex_is_none_when_nothing_is_exempt() {
1380 assert_eq!(ignore_filename_regex(&[]), None);
1381 }
1382
1383 #[test]
1384 fn rust_ignore_regex_escapes_and_joins_exempt_paths() {
1385 let exempt = vec!["src/shim.rs".to_string(), "src/gen.rs".to_string()];
1388 assert_eq!(
1389 ignore_filename_regex(&exempt).as_deref(),
1390 Some(r"src/shim\.rs|src/gen\.rs")
1391 );
1392 }
1393
1394 #[test]
1397 fn lcov_flags_an_unexecuted_line() {
1398 let lcov = "SF:/abs/grade.rs\nDA:6,1\nDA:7,1\nDA:8,1\nDA:10,0\nDA:12,1\nend_of_record\n";
1400 let out = uncovered_lcov_lines(lcov);
1401 assert_eq!(out["/abs/grade.rs"], BTreeSet::from([10]));
1402 }
1403
1404 #[test]
1405 fn lcov_a_fully_covered_file_maps_to_an_empty_set() {
1406 let lcov = "SF:/abs/widget.rs\nDA:1,2\nDA:2,1\nend_of_record\n";
1407 let out = uncovered_lcov_lines(lcov);
1408 assert!(out["/abs/widget.rs"].is_empty());
1409 }
1410
1411 #[test]
1412 fn lcov_groups_uncovered_lines_by_source_file() {
1413 let lcov =
1414 "SF:/abs/a.rs\nDA:3,0\nend_of_record\nSF:/abs/b.rs\nDA:5,1\nDA:6,0\nend_of_record\n";
1415 let out = uncovered_lcov_lines(lcov);
1416 assert_eq!(out["/abs/a.rs"], BTreeSet::from([3]));
1417 assert_eq!(out["/abs/b.rs"], BTreeSet::from([6]));
1418 }
1419
1420 #[test]
1421 fn lcov_a_da_record_outside_a_file_is_ignored() {
1422 let lcov = "DA:9,0\nSF:/abs/a.rs\nDA:1,1\nend_of_record\nDA:2,0\n";
1425 let out = uncovered_lcov_lines(lcov);
1426 assert_eq!(out.len(), 1);
1427 assert!(out["/abs/a.rs"].is_empty());
1428 }
1429
1430 #[test]
1431 fn lcov_a_checksummed_da_record_parses() {
1432 let lcov = "SF:/abs/a.rs\nDA:4,0,abc123\nend_of_record\n";
1434 let out = uncovered_lcov_lines(lcov);
1435 assert_eq!(out["/abs/a.rs"], BTreeSet::from([4]));
1436 }
1437}