1use std::path::{Path, PathBuf};
24use std::process::Command;
25use std::sync::atomic::{AtomicU64, Ordering};
26
27use anyhow::{bail, Context, Result};
28use serde::Deserialize;
29
30const TEST_OMIT: &str = "*_test.py";
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct Thresholds {
37 pub fail_under: u8,
39 pub branch: bool,
41}
42
43#[derive(Debug, Clone, Deserialize)]
46pub struct CoverageReport {
47 pub totals: Totals,
48}
49
50#[derive(Debug, Clone, Deserialize)]
52pub struct Totals {
53 pub percent_covered: f64,
55 #[serde(default)]
57 pub num_branches: u64,
58}
59
60#[derive(Debug, Clone, PartialEq)]
62pub enum Outcome {
63 Pass,
65 Fail(String),
67}
68
69pub fn parse_report(json: &str) -> Result<CoverageReport> {
71 serde_json::from_str(json).context("parsing coverage.py JSON report")
72}
73
74pub fn evaluate(report: &CoverageReport, thresholds: Thresholds) -> Outcome {
79 if thresholds.branch && report.totals.num_branches == 0 {
80 return Outcome::Fail(
81 "branch coverage is required but the report measured no branches".to_string(),
82 );
83 }
84 let actual = report.totals.percent_covered;
85 let required = f64::from(thresholds.fail_under);
86 if actual + 1e-9 >= required {
89 Outcome::Pass
90 } else {
91 Outcome::Fail(format!(
92 "coverage {actual:.2}% is below the required {}%",
93 thresholds.fail_under
94 ))
95 }
96}
97
98pub fn measure(root: &Path, thresholds: Thresholds, omit: &[String]) -> Result<Outcome> {
107 let report = run_coverage(root, omit)?;
108 Ok(evaluate(&report, thresholds))
109}
110
111struct DataFile(PathBuf);
115
116impl DataFile {
117 fn new() -> Self {
118 static COUNTER: AtomicU64 = AtomicU64::new(0);
119 let name = format!(
120 "testing-conventions-{}-{}.coverage",
121 std::process::id(),
122 COUNTER.fetch_add(1, Ordering::Relaxed),
123 );
124 DataFile(std::env::temp_dir().join(name))
125 }
126}
127
128impl Drop for DataFile {
129 fn drop(&mut self) {
130 let _ = std::fs::remove_file(&self.0);
131 }
132}
133
134fn run_coverage(root: &Path, omit: &[String]) -> Result<CoverageReport> {
136 let data = DataFile::new();
137 let omit = build_omit(omit);
138
139 let run = Command::new("coverage")
143 .current_dir(root)
144 .args(["run", "--branch"])
145 .arg(format!("--omit={omit}"))
146 .args(["-m", "pytest", "-q", "-p", "no:cacheprovider", "."])
147 .env("COVERAGE_FILE", &data.0)
148 .env("PYTHONDONTWRITEBYTECODE", "1")
149 .output()
150 .context("running `coverage run -m pytest` (is coverage.py installed?)")?;
151 if !run.status.success() {
152 bail!(
153 "the unit suite did not run cleanly under coverage in `{}`:\n{}{}",
154 root.display(),
155 String::from_utf8_lossy(&run.stdout),
156 String::from_utf8_lossy(&run.stderr),
157 );
158 }
159
160 let json = Command::new("coverage")
162 .current_dir(root)
163 .args(["json", "-o", "-"])
164 .env("COVERAGE_FILE", &data.0)
165 .output()
166 .context("running `coverage json`")?;
167 if !json.status.success() {
168 bail!(
169 "`coverage json` failed:\n{}",
170 String::from_utf8_lossy(&json.stderr),
171 );
172 }
173
174 parse_report(&String::from_utf8_lossy(&json.stdout))
175}
176
177fn build_omit(omit: &[String]) -> String {
183 std::iter::once(TEST_OMIT.to_string())
184 .chain(omit.iter().cloned())
185 .collect::<Vec<_>>()
186 .join(",")
187}
188
189const TS_INCLUDE: &str = "**/*.{ts,tsx,mts,cts}";
203const TS_TEST_EXCLUDE: &str = "**/*.test.*";
207const TS_DECL_EXCLUDE: &str = "**/*.d.{ts,mts,cts}";
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub struct TypeScriptThresholds {
213 pub lines: u8,
214 pub branches: u8,
215 pub functions: u8,
216 pub statements: u8,
217}
218
219#[derive(Debug, Clone, Copy, Deserialize)]
222pub struct VitestReport {
223 pub total: VitestTotals,
224}
225
226#[derive(Debug, Clone, Copy, Deserialize)]
229pub struct VitestTotals {
230 pub lines: VitestMetric,
231 pub branches: VitestMetric,
232 pub functions: VitestMetric,
233 pub statements: VitestMetric,
234}
235
236#[derive(Debug, Clone, Copy, Deserialize)]
239pub struct VitestMetric {
240 #[serde(deserialize_with = "deserialize_pct")]
243 pub pct: Option<f64>,
244 pub total: u64,
246}
247
248fn deserialize_pct<'de, D>(deserializer: D) -> std::result::Result<Option<f64>, D::Error>
252where
253 D: serde::Deserializer<'de>,
254{
255 struct PctVisitor;
256 impl serde::de::Visitor<'_> for PctVisitor {
257 type Value = Option<f64>;
258
259 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
260 f.write_str("a coverage percent number or the string \"Unknown\"")
261 }
262
263 fn visit_f64<E>(self, value: f64) -> std::result::Result<Self::Value, E> {
264 Ok(Some(value))
265 }
266
267 fn visit_u64<E>(self, value: u64) -> std::result::Result<Self::Value, E> {
270 Ok(Some(value as f64))
271 }
272
273 fn visit_str<E>(self, _value: &str) -> std::result::Result<Self::Value, E> {
276 Ok(None)
277 }
278 }
279 deserializer.deserialize_any(PctVisitor)
280}
281
282pub fn parse_vitest_report(json: &str) -> Result<VitestReport> {
284 serde_json::from_str(json).context("parsing vitest coverage-summary JSON report")
285}
286
287pub fn evaluate_typescript(report: &VitestReport, thresholds: TypeScriptThresholds) -> Outcome {
296 let total = &report.total;
297 if total.lines.total == 0 {
301 return Outcome::Fail(
302 "the unit suite measured no code — check the path and that the suite runs".to_string(),
303 );
304 }
305 let checks = [
306 ("lines", total.lines, thresholds.lines),
307 ("branches", total.branches, thresholds.branches),
308 ("functions", total.functions, thresholds.functions),
309 ("statements", total.statements, thresholds.statements),
310 ];
311 let mut shortfalls = Vec::new();
312 for (name, metric, required) in checks {
313 let actual = metric.pct.unwrap_or(100.0);
316 if actual + 1e-9 < f64::from(required) {
319 shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
320 }
321 }
322 if shortfalls.is_empty() {
323 Outcome::Pass
324 } else {
325 Outcome::Fail(format!(
326 "coverage below thresholds: {}",
327 shortfalls.join(", ")
328 ))
329 }
330}
331
332pub fn measure_typescript(
342 root: &Path,
343 thresholds: TypeScriptThresholds,
344 exclude: &[String],
345) -> Result<Outcome> {
346 let report = run_vitest(root, exclude)?;
347 Ok(evaluate_typescript(&report, thresholds))
348}
349
350struct ReportDir(PathBuf);
354
355impl ReportDir {
356 fn new() -> Self {
357 static COUNTER: AtomicU64 = AtomicU64::new(0);
358 let name = format!(
359 "testing-conventions-vitest-{}-{}",
360 std::process::id(),
361 COUNTER.fetch_add(1, Ordering::Relaxed),
362 );
363 ReportDir(std::env::temp_dir().join(name))
364 }
365
366 fn summary(&self) -> PathBuf {
368 self.0.join("coverage-summary.json")
369 }
370}
371
372impl Drop for ReportDir {
373 fn drop(&mut self) {
374 let _ = std::fs::remove_dir_all(&self.0);
375 }
376}
377
378fn run_vitest(root: &Path, exclude: &[String]) -> Result<VitestReport> {
380 let reports = ReportDir::new();
381
382 let mut command = Command::new("npx");
389 command
390 .current_dir(root)
391 .args(["--yes", "vitest", "run", "--no-cache"])
392 .args([
393 "--coverage.enabled",
394 "--coverage.provider=v8",
395 "--coverage.reporter=json-summary",
396 "--coverage.all=true",
397 ])
398 .arg(format!(
399 "--coverage.reportsDirectory={}",
400 reports.0.display()
401 ))
402 .arg(format!("--coverage.include={TS_INCLUDE}"))
403 .arg(format!("--coverage.exclude={TS_TEST_EXCLUDE}"))
404 .arg(format!("--coverage.exclude={TS_DECL_EXCLUDE}"));
405 for path in exclude {
406 command.arg(format!("--coverage.exclude={path}"));
407 }
408 let run = command.env("CI", "1").output().context(
410 "running `npx vitest run --coverage` (are vitest and @vitest/coverage-v8 installed?)",
411 )?;
412 if !run.status.success() {
413 bail!(
414 "the unit suite did not run cleanly under vitest in `{}`:\n{}{}",
415 root.display(),
416 String::from_utf8_lossy(&run.stdout),
417 String::from_utf8_lossy(&run.stderr),
418 );
419 }
420
421 let summary = reports.summary();
422 let json = std::fs::read_to_string(&summary).with_context(|| {
423 format!(
424 "reading vitest coverage summary `{}` (did the run produce a json-summary report?)",
425 summary.display()
426 )
427 })?;
428 parse_vitest_report(&json)
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 fn report(percent_covered: f64, num_branches: u64) -> CoverageReport {
436 CoverageReport {
437 totals: Totals {
438 percent_covered,
439 num_branches,
440 },
441 }
442 }
443
444 #[test]
445 fn passes_when_total_meets_the_floor() {
446 assert_eq!(
447 evaluate(
448 &report(100.0, 12),
449 Thresholds {
450 fail_under: 100,
451 branch: true
452 }
453 ),
454 Outcome::Pass
455 );
456 }
457
458 #[test]
459 fn fails_when_total_is_below_the_floor() {
460 assert!(matches!(
461 evaluate(
462 &report(80.0, 12),
463 Thresholds {
464 fail_under: 100,
465 branch: true
466 }
467 ),
468 Outcome::Fail(_)
469 ));
470 }
471
472 #[test]
473 fn fails_when_branch_required_but_unmeasured() {
474 assert!(matches!(
476 evaluate(
477 &report(100.0, 0),
478 Thresholds {
479 fail_under: 90,
480 branch: true
481 }
482 ),
483 Outcome::Fail(_)
484 ));
485 }
486
487 #[test]
488 fn parses_a_coverage_py_report() {
489 let json = r#"{"totals":{"percent_covered":91.5,"num_branches":8,"covered_lines":91}}"#;
490 let report = parse_report(json).expect("valid coverage.py json");
491 assert_eq!(report.totals.percent_covered, 91.5);
492 assert_eq!(report.totals.num_branches, 8);
493 }
494
495 #[test]
496 fn omit_is_just_the_test_glob_when_nothing_is_exempt() {
497 assert_eq!(build_omit(&[]), "*_test.py");
498 }
499
500 #[test]
501 fn omit_folds_in_the_exempt_paths_after_the_test_glob() {
502 let exempt = vec!["pkg/gen.py".to_string(), "shim.py".to_string()];
504 assert_eq!(build_omit(&exempt), "*_test.py,pkg/gen.py,shim.py");
505 }
506
507 fn metric(pct: f64) -> VitestMetric {
510 VitestMetric {
511 pct: Some(pct),
512 total: 10,
513 }
514 }
515
516 fn ts_report(lines: f64, branches: f64, functions: f64, statements: f64) -> VitestReport {
517 VitestReport {
518 total: VitestTotals {
519 lines: metric(lines),
520 branches: metric(branches),
521 functions: metric(functions),
522 statements: metric(statements),
523 },
524 }
525 }
526
527 const TS_FULL: TypeScriptThresholds = TypeScriptThresholds {
528 lines: 100,
529 branches: 100,
530 functions: 100,
531 statements: 100,
532 };
533 const TS_MID: TypeScriptThresholds = TypeScriptThresholds {
534 lines: 80,
535 branches: 75,
536 functions: 80,
537 statements: 80,
538 };
539
540 #[test]
541 fn typescript_passes_when_every_metric_meets_its_floor() {
542 assert_eq!(
543 evaluate_typescript(&ts_report(100.0, 100.0, 100.0, 100.0), TS_FULL),
544 Outcome::Pass
545 );
546 }
547
548 #[test]
549 fn typescript_fails_on_the_one_metric_below_its_floor() {
550 let outcome = evaluate_typescript(&ts_report(100.0, 66.66, 100.0, 100.0), TS_MID);
554 assert!(
555 matches!(&outcome, Outcome::Fail(message) if message.contains("branches") && !message.contains("lines")),
556 "got: {outcome:?}"
557 );
558 }
559
560 #[test]
561 fn typescript_fail_message_names_every_metric_below() {
562 let outcome = evaluate_typescript(&ts_report(70.0, 70.0, 70.0, 70.0), TS_MID);
563 assert!(
564 matches!(&outcome, Outcome::Fail(message)
565 if message.contains("lines")
566 && message.contains("branches")
567 && message.contains("functions")
568 && message.contains("statements")),
569 "got: {outcome:?}"
570 );
571 }
572
573 #[test]
574 fn typescript_tolerates_float_noise_at_the_floor() {
575 assert_eq!(
577 evaluate_typescript(&ts_report(99.999_999_999, 100.0, 100.0, 100.0), TS_FULL),
578 Outcome::Pass
579 );
580 }
581
582 #[test]
583 fn typescript_empty_denominator_metric_is_vacuously_satisfied() {
584 let report = VitestReport {
587 total: VitestTotals {
588 lines: metric(100.0),
589 branches: VitestMetric {
590 pct: None,
591 total: 0,
592 },
593 functions: metric(100.0),
594 statements: metric(100.0),
595 },
596 };
597 assert_eq!(evaluate_typescript(&report, TS_FULL), Outcome::Pass);
598 }
599
600 #[test]
601 fn typescript_fails_a_vacuous_run_that_measured_no_code() {
602 let nothing = VitestMetric {
605 pct: None,
606 total: 0,
607 };
608 let report = VitestReport {
609 total: VitestTotals {
610 lines: nothing,
611 branches: nothing,
612 functions: nothing,
613 statements: nothing,
614 },
615 };
616 let outcome = evaluate_typescript(&report, TS_MID);
617 assert!(
618 matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
619 "got: {outcome:?}"
620 );
621 }
622
623 #[test]
624 fn parses_a_vitest_summary_report() {
625 let json = r#"{
628 "total": {
629 "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
630 "statements": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
631 "functions": {"total": 2, "covered": 2, "skipped": 0, "pct": 100},
632 "branches": {"total": 3, "covered": 2, "skipped": 0, "pct": 66.66},
633 "branchesTrue": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
634 },
635 "/abs/widget.ts": {
636 "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80}
637 }
638 }"#;
639 let report = parse_vitest_report(json).expect("valid vitest json-summary");
640 assert_eq!(report.total.lines.pct, Some(80.0));
642 assert_eq!(report.total.branches.pct, Some(66.66));
643 assert_eq!(report.total.functions.total, 2);
644 }
645
646 #[test]
647 fn parses_an_unknown_pct_as_unmeasured() {
648 let json = r#"{"total": {
649 "lines": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
650 "statements": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
651 "functions": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
652 "branches": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
653 }}"#;
654 let report = parse_vitest_report(json).expect("valid vitest json-summary");
655 assert_eq!(report.total.lines.pct, None);
656 assert_eq!(report.total.lines.total, 0);
657 }
658
659 #[test]
660 fn a_pct_that_is_neither_number_nor_string_is_a_parse_error() {
661 let json = r#"{"total":{
664 "lines": {"total": 1, "covered": 1, "skipped": 0, "pct": true},
665 "statements": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
666 "functions": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
667 "branches": {"total": 1, "covered": 1, "skipped": 0, "pct": 100}
668 }}"#;
669 assert!(parse_vitest_report(json).is_err());
670 }
671}