1use std::collections::{BTreeMap, BTreeSet};
13
14use serde::Serialize;
15
16use crate::coverage_analysis::CoverageSummary;
17use crate::coverage_report::{CoverageView, ReportError, coverage_summary_for_file};
18
19pub const RUN_VIEW_SCHEMA_VERSION: u32 = 1;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub enum Metric {
29 Lines,
30 Statements,
31 Functions,
32 Branches,
33 Mcdc,
34}
35
36impl Metric {
37 pub const ALL: [Metric; 5] = [
38 Metric::Lines,
39 Metric::Statements,
40 Metric::Functions,
41 Metric::Branches,
42 Metric::Mcdc,
43 ];
44
45 pub fn name(self) -> &'static str {
46 match self {
47 Metric::Lines => "lines",
48 Metric::Statements => "statements",
49 Metric::Functions => "functions",
50 Metric::Branches => "branches",
51 Metric::Mcdc => "mcdc",
52 }
53 }
54
55 pub fn parse(value: &str) -> Option<Self> {
56 Metric::ALL
57 .into_iter()
58 .find(|metric| metric.name() == value.to_ascii_lowercase())
59 }
60
61 pub fn flag(self) -> &'static str {
64 match self {
65 Metric::Lines => "--min-lines",
66 Metric::Statements => "--min-statements",
67 Metric::Functions => "--min-functions",
68 Metric::Branches => "--min-branches",
69 Metric::Mcdc => "--min-mcdc",
70 }
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76#[serde(rename_all = "camelCase", tag = "state")]
77pub enum Applicability {
78 Measured,
80 NotApplicable,
84 Incomplete { unmeasured: usize },
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
91#[serde(rename_all = "camelCase")]
92pub struct MetricView {
93 pub metric: Metric,
94 pub covered: usize,
95 pub eligible: usize,
96 #[serde(flatten)]
97 pub applicability: Applicability,
98}
99
100impl MetricView {
101 pub fn meets(&self, floor_ppm: u64) -> bool {
107 u128::from(self.covered as u64) * 1_000_000
108 >= u128::from(floor_ppm) * u128::from(self.eligible as u64)
109 }
110
111 pub fn percentage(&self) -> Option<f64> {
113 (self.eligible > 0).then(|| self.covered as f64 * 100.0 / self.eligible as f64)
114 }
115}
116
117fn metric_counts(summary: &CoverageSummary, metric: Metric) -> (usize, usize) {
118 match metric {
119 Metric::Lines => (summary.lines.covered, summary.lines.total),
120 Metric::Statements => (summary.statements.covered, summary.statements.total),
121 Metric::Functions => (summary.functions.covered, summary.functions.total),
122 Metric::Branches => (summary.branches.covered, summary.branches.total),
123 Metric::Mcdc => (summary.covered_conditions, summary.conditions),
124 }
125}
126
127fn metrics_of(summary: &CoverageSummary) -> Vec<MetricView> {
128 let unmeasured = summary.unmeasured_obligations.unwrap_or(0);
129 Metric::ALL
130 .into_iter()
131 .map(|metric| {
132 let (covered, eligible) = metric_counts(summary, metric);
133 let applicability = if eligible == 0 {
134 Applicability::NotApplicable
135 } else if unmeasured > 0 {
136 Applicability::Incomplete { unmeasured }
137 } else {
138 Applicability::Measured
139 };
140 MetricView {
141 metric,
142 covered,
143 eligible,
144 applicability,
145 }
146 })
147 .collect()
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
151#[serde(rename_all = "camelCase")]
152pub struct Location {
153 pub line: usize,
154 pub column: usize,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
158#[serde(rename_all = "camelCase")]
159pub struct FunctionRecord {
160 pub line: usize,
161 pub name: String,
162 pub covered: bool,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
168#[serde(rename_all = "camelCase")]
169pub struct BranchRecord {
170 pub line: usize,
171 pub block: usize,
172 pub index: usize,
173 pub taken: bool,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
177#[serde(rename_all = "camelCase")]
178pub struct FileView {
179 pub file: String,
180 pub metrics: Vec<MetricView>,
181 pub measured_lines: Vec<usize>,
186 pub uncovered_lines: Vec<usize>,
188 pub missing_branches: Vec<Location>,
189 pub missing_conditions: Vec<Location>,
190 pub functions: Vec<FunctionRecord>,
191 pub branches: Vec<BranchRecord>,
192}
193
194impl FileView {
195 pub fn metric(&self, metric: Metric) -> Option<&MetricView> {
196 self.metrics.iter().find(|view| view.metric == metric)
197 }
198
199 pub fn line_hits(&self) -> impl Iterator<Item = (usize, bool)> + '_ {
204 self.measured_lines
205 .iter()
206 .map(|line| (*line, self.uncovered_lines.binary_search(line).is_err()))
207 }
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
212#[serde(rename_all = "camelCase")]
213pub struct Blocker {
214 pub reason: String,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
218#[serde(rename_all = "camelCase")]
219pub struct RunView {
220 pub schema_version: u32,
221 pub run: String,
222 pub generated_at: String,
223 pub suite_passed: bool,
226 pub stale: bool,
227 pub stale_reasons: Vec<String>,
228 pub complete: bool,
229 pub limitations: Vec<String>,
230 pub totals: Vec<MetricView>,
231 pub files: Vec<FileView>,
232 pub source_neighbourhoods: BTreeSet<(String, String)>,
240}
241
242impl RunView {
243 pub fn metric(&self, metric: Metric) -> Option<&MetricView> {
244 self.totals.iter().find(|view| view.metric == metric)
245 }
246
247 pub fn file(&self, path: &str) -> Option<&FileView> {
248 self.files.iter().find(|file| file.file == path)
249 }
250
251 pub fn measured_line(&self, path: &str, line: usize) -> bool {
253 self.file(path)
254 .is_some_and(|file| file.measured_lines.binary_search(&line).is_ok())
255 }
256
257 pub fn looks_like_source(&self, path: &str) -> bool {
262 neighbourhood(path).is_some_and(|key| self.source_neighbourhoods.contains(&key))
263 }
264
265 pub fn blockers(&self) -> Vec<Blocker> {
267 let mut blockers = Vec::new();
268 if !self.suite_passed {
269 blockers.push(Blocker {
270 reason: "the wrapped test command did not pass; a gate over a failed suite cannot report success".into(),
271 });
272 }
273 if self.stale {
274 let detail = if self.stale_reasons.is_empty() {
275 "the run no longer matches the current checkout".to_owned()
276 } else {
277 format!(
278 "the run no longer matches the current checkout: {}",
279 self.stale_reasons.join(", ")
280 )
281 };
282 blockers.push(Blocker { reason: detail });
283 }
284 blockers
285 }
286}
287
288fn neighbourhood(path: &str) -> Option<(String, String)> {
291 let (directory, name) = path.rsplit_once('/').unwrap_or(("", path));
292 let (_, extension) = name.rsplit_once('.')?;
293 Some((directory.to_owned(), extension.to_owned()))
294}
295
296pub fn build(
298 run: &str,
299 generated_at: &str,
300 view: &CoverageView,
301 suite_passed: bool,
302 stale: bool,
303 stale_reasons: Vec<String>,
304) -> Result<RunView, ReportError> {
305 let mut files = BTreeMap::<String, FileView>::new();
306 for line in &view.lines {
307 files
308 .entry(line.file.clone())
309 .or_insert_with(|| FileView {
310 file: line.file.clone(),
311 metrics: Vec::new(),
312 measured_lines: Vec::new(),
313 uncovered_lines: Vec::new(),
314 missing_branches: Vec::new(),
315 missing_conditions: Vec::new(),
316 functions: Vec::new(),
317 branches: Vec::new(),
318 })
319 .measured_lines
320 .extend(line.measured.then_some(line.line));
321 if line.measured
322 && !line.covered
323 && let Some(file) = files.get_mut(&line.file)
324 {
325 file.uncovered_lines.push(line.line);
326 }
327 }
328 for branch in &view.branches {
329 let entry = files
330 .entry(branch.meta.file.clone())
331 .or_insert_with(|| FileView {
332 file: branch.meta.file.clone(),
333 metrics: Vec::new(),
334 measured_lines: Vec::new(),
335 uncovered_lines: Vec::new(),
336 missing_branches: Vec::new(),
337 missing_conditions: Vec::new(),
338 functions: Vec::new(),
339 branches: Vec::new(),
340 });
341 for alternative in branch.alternatives.iter().filter(|a| !a.covered) {
342 entry.missing_branches.push(Location {
343 line: branch.meta.line,
344 column: branch.meta.column,
345 });
346 let _ = alternative;
347 }
348 }
349 for point in &view.points {
350 if point.meta.kind != crate::coverage_analysis::PointKind::Function {
351 continue;
352 }
353 if let Some(file) = files.get_mut(&point.meta.file) {
354 file.functions.push(FunctionRecord {
355 line: point.meta.line,
356 name: point
357 .meta
358 .label
359 .clone()
360 .unwrap_or_else(|| format!("{}:{}", point.meta.line, point.meta.column)),
361 covered: point.covered,
362 });
363 }
364 }
365 for (block, branch) in view.branches.iter().enumerate() {
366 if let Some(file) = files.get_mut(&branch.meta.file) {
367 for (index, alternative) in branch.alternatives.iter().enumerate() {
368 file.branches.push(BranchRecord {
369 line: branch.meta.line,
370 block,
371 index,
372 taken: alternative.covered,
373 });
374 }
375 }
376 }
377 for decision in &view.decisions {
378 let entry = files
379 .entry(decision.meta.file.clone())
380 .or_insert_with(|| FileView {
381 file: decision.meta.file.clone(),
382 metrics: Vec::new(),
383 measured_lines: Vec::new(),
384 uncovered_lines: Vec::new(),
385 missing_branches: Vec::new(),
386 missing_conditions: Vec::new(),
387 functions: Vec::new(),
388 branches: Vec::new(),
389 });
390 for _ in decision.conditions.iter().filter(|c| !c.covered) {
391 entry.missing_conditions.push(Location {
392 line: decision.meta.line,
393 column: decision.meta.column,
394 });
395 }
396 }
397 let mut built = Vec::new();
398 for (path, mut file) in files {
399 file.metrics = metrics_of(&coverage_summary_for_file(view, &path)?);
400 file.measured_lines.sort_unstable();
401 file.measured_lines.dedup();
402 file.uncovered_lines.sort_unstable();
403 file.uncovered_lines.dedup();
404 file.missing_branches.sort_by_key(|at| (at.line, at.column));
405 file.missing_conditions
406 .sort_by_key(|at| (at.line, at.column));
407 file.functions
408 .sort_by(|a, b| (a.line, &a.name).cmp(&(b.line, &b.name)));
409 file.branches
410 .sort_by_key(|record| (record.line, record.block, record.index));
411 built.push(file);
412 }
413 let source_neighbourhoods = built
414 .iter()
415 .filter_map(|file| neighbourhood(&file.file))
416 .collect::<BTreeSet<_>>();
417 Ok(RunView {
418 schema_version: RUN_VIEW_SCHEMA_VERSION,
419 run: run.to_owned(),
420 generated_at: generated_at.to_owned(),
421 suite_passed,
422 stale,
423 stale_reasons,
424 complete: view.summary.coverage_complete,
425 limitations: view
426 .limitations
427 .iter()
428 .map(|limitation| limitation.to_string())
429 .collect(),
430 totals: metrics_of(&view.summary),
431 files: built,
432 source_neighbourhoods,
433 })
434}
435
436#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
439#[serde(rename_all = "camelCase")]
440pub struct Floor {
441 pub metric: Metric,
442 pub ppm: u64,
443}
444
445pub fn parse_percentage(value: &str) -> Result<u64, String> {
448 let text = value.trim();
449 let (whole, fraction) = match text.split_once('.') {
450 Some((whole, fraction)) => (whole, fraction),
451 None => (text, ""),
452 };
453 if whole.is_empty() && fraction.is_empty() {
454 return Err(format!("{value:?} is not a percentage"));
455 }
456 if !whole.chars().all(|c| c.is_ascii_digit())
457 || !fraction.chars().all(|c| c.is_ascii_digit())
458 || fraction.len() > 4
459 {
460 return Err(format!(
461 "{value:?} is not a percentage between 0 and 100 with at most four decimal places"
462 ));
463 }
464 let whole: u64 = if whole.is_empty() {
465 0
466 } else {
467 whole
468 .parse()
469 .map_err(|_| format!("{value:?} is too large"))?
470 };
471 let scaled: u64 = if fraction.is_empty() {
472 0
473 } else {
474 format!("{fraction:0<4}")
475 .parse()
476 .map_err(|_| format!("{value:?} is not a percentage"))?
477 };
478 let ppm = whole
479 .checked_mul(10_000)
480 .and_then(|whole| whole.checked_add(scaled))
481 .ok_or_else(|| format!("{value:?} is too large"))?;
482 (ppm <= 1_000_000)
483 .then_some(ppm)
484 .ok_or_else(|| format!("{value:?} is above 100"))
485}
486
487#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
488#[serde(rename_all = "camelCase")]
489pub struct Violation {
490 #[serde(skip_serializing_if = "Option::is_none")]
492 pub file: Option<String>,
493 pub metric: Metric,
494 pub covered: usize,
495 pub eligible: usize,
496 pub floor_ppm: u64,
497 pub uncovered_lines: Vec<usize>,
498}
499
500#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
501#[serde(rename_all = "camelCase", tag = "result")]
502pub enum Outcome {
503 Pass,
504 Fail {
505 violations: Vec<Violation>,
506 },
507 Error {
510 reasons: Vec<String>,
511 },
512}
513
514impl Outcome {
515 pub fn exit_code(&self) -> u8 {
516 match self {
517 Outcome::Pass => 0,
518 Outcome::Fail { .. } => 1,
519 Outcome::Error { .. } => 2,
520 }
521 }
522}
523
524pub fn check(view: &RunView, floors: &[Floor], per_file: bool) -> Outcome {
531 let mut reasons = view
532 .blockers()
533 .into_iter()
534 .map(|blocker| blocker.reason)
535 .collect::<Vec<_>>();
536 if floors.is_empty() {
537 reasons.push("no floor requested; give at least one --min-<metric>".into());
538 }
539 for floor in floors {
540 match view.metric(floor.metric) {
541 None => reasons.push(format!(
542 "{} is not measured by this run's language adapter; remove {}",
543 floor.metric.name(),
544 floor.metric.flag()
545 )),
546 Some(metric) => match &metric.applicability {
547 Applicability::NotApplicable => reasons.push(format!(
548 "{} has nothing eligible in this run, which is not the same as complete; remove {} or widen the scope",
549 floor.metric.name(),
550 floor.metric.flag()
551 )),
552 Applicability::Incomplete { unmeasured } => reasons.push(format!(
553 "{} left {unmeasured} obligation(s) unmeasured, so {} cannot be judged exactly",
554 floor.metric.name(),
555 floor.metric.flag()
556 )),
557 Applicability::Measured => {}
558 },
559 }
560 }
561 if !reasons.is_empty() {
562 return Outcome::Error { reasons };
563 }
564
565 let mut violations = Vec::new();
566 for floor in floors {
567 let Some(metric) = view.metric(floor.metric) else {
568 continue;
569 };
570 if !metric.meets(floor.ppm) {
571 violations.push(Violation {
572 file: None,
573 metric: floor.metric,
574 covered: metric.covered,
575 eligible: metric.eligible,
576 floor_ppm: floor.ppm,
577 uncovered_lines: Vec::new(),
578 });
579 }
580 if !per_file {
581 continue;
582 }
583 for file in &view.files {
584 let Some(counts) = file.metric(floor.metric) else {
585 continue;
586 };
587 if counts.eligible == 0 || counts.meets(floor.ppm) {
590 continue;
591 }
592 violations.push(Violation {
593 file: Some(file.file.clone()),
594 metric: floor.metric,
595 covered: counts.covered,
596 eligible: counts.eligible,
597 floor_ppm: floor.ppm,
598 uncovered_lines: file.uncovered_lines.clone(),
599 });
600 }
601 }
602 if violations.is_empty() {
603 Outcome::Pass
604 } else {
605 Outcome::Fail { violations }
606 }
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612
613 fn metric(covered: usize, eligible: usize) -> MetricView {
614 MetricView {
615 metric: Metric::Lines,
616 covered,
617 eligible,
618 applicability: if eligible == 0 {
619 Applicability::NotApplicable
620 } else {
621 Applicability::Measured
622 },
623 }
624 }
625
626 fn view(totals: Vec<MetricView>, files: Vec<FileView>) -> RunView {
627 RunView {
628 schema_version: RUN_VIEW_SCHEMA_VERSION,
629 run: "run_1".into(),
630 generated_at: "now".into(),
631 suite_passed: true,
632 stale: false,
633 stale_reasons: Vec::new(),
634 complete: true,
635 limitations: Vec::new(),
636 totals,
637 files,
638 source_neighbourhoods: BTreeSet::new(),
639 }
640 }
641
642 #[test]
643 fn a_floor_is_compared_against_counts_and_never_a_rounded_percentage() {
644 let almost = metric(9_999, 10_000);
648 assert_eq!(format!("{:.2}", almost.percentage().unwrap()), "99.99");
649 assert!(!almost.meets(1_000_000));
650 assert!(almost.meets(999_000));
651 assert!(metric(10_000, 10_000).meets(1_000_000));
652
653 assert!(metric(995, 1_000).meets(parse_percentage("99.5").unwrap()));
655 assert!(!metric(994, 1_000).meets(parse_percentage("99.5").unwrap()));
656 }
657
658 #[test]
659 fn nothing_eligible_is_not_complete_coverage() {
660 let empty = metric(0, 0);
663 assert!(empty.meets(1_000_000));
664 assert_eq!(empty.applicability, Applicability::NotApplicable);
665 let outcome = check(
666 &view(vec![empty], Vec::new()),
667 &[Floor {
668 metric: Metric::Lines,
669 ppm: 1_000_000,
670 }],
671 false,
672 );
673 assert!(matches!(outcome, Outcome::Error { .. }), "{outcome:?}");
674 assert_eq!(outcome.exit_code(), 2);
675 }
676
677 #[test]
678 fn evidence_that_cannot_answer_the_question_never_passes() {
679 let floors = [Floor {
680 metric: Metric::Lines,
681 ppm: 500_000,
682 }];
683 let mut failed = view(vec![metric(10, 10)], Vec::new());
686 failed.suite_passed = false;
687 assert_eq!(check(&failed, &floors, false).exit_code(), 2);
688
689 let mut stale = view(vec![metric(10, 10)], Vec::new());
691 stale.stale = true;
692 stale.stale_reasons = vec!["instrumented source changed".into()];
693 let outcome = check(&stale, &floors, false);
694 assert_eq!(outcome.exit_code(), 2);
695 let Outcome::Error { reasons } = outcome else {
696 panic!("expected an error");
697 };
698 assert!(
699 reasons[0].contains("instrumented source changed"),
700 "{reasons:?}"
701 );
702
703 let mut partial = view(vec![metric(10, 10)], Vec::new());
705 partial.totals[0].applicability = Applicability::Incomplete { unmeasured: 3 };
706 assert_eq!(check(&partial, &floors, false).exit_code(), 2);
707
708 assert_eq!(
710 check(
711 &view(vec![metric(10, 10)], Vec::new()),
712 &[Floor {
713 metric: Metric::Mcdc,
714 ppm: 500_000
715 }],
716 false
717 )
718 .exit_code(),
719 2
720 );
721 }
722
723 #[test]
724 fn every_violation_is_reported_with_the_counts_behind_it() {
725 let files = vec![
728 FileView {
729 file: "src/a.ts".into(),
730 metrics: vec![MetricView {
731 metric: Metric::Lines,
732 covered: 1,
733 eligible: 4,
734 applicability: Applicability::Measured,
735 }],
736 measured_lines: vec![1, 2, 3, 4],
737 uncovered_lines: vec![2, 3, 4],
738 missing_branches: Vec::new(),
739 missing_conditions: Vec::new(),
740 functions: Vec::new(),
741 branches: Vec::new(),
742 },
743 FileView {
744 file: "src/b.ts".into(),
745 metrics: vec![MetricView {
746 metric: Metric::Lines,
747 covered: 6,
748 eligible: 6,
749 applicability: Applicability::Measured,
750 }],
751 measured_lines: vec![1, 2, 3, 4, 5, 6],
752 uncovered_lines: Vec::new(),
753 missing_branches: Vec::new(),
754 missing_conditions: Vec::new(),
755 functions: Vec::new(),
756 branches: Vec::new(),
757 },
758 ];
759 let outcome = check(
760 &view(vec![metric(7, 10)], files),
761 &[Floor {
762 metric: Metric::Lines,
763 ppm: 900_000,
764 }],
765 true,
766 );
767 let Outcome::Fail { violations } = outcome else {
768 panic!("expected a policy failure");
769 };
770 assert_eq!(violations.len(), 2, "{violations:?}");
771 assert_eq!(violations[0].file, None);
772 assert_eq!((violations[0].covered, violations[0].eligible), (7, 10));
773 assert_eq!(violations[1].file.as_deref(), Some("src/a.ts"));
774 assert_eq!(violations[1].uncovered_lines, [2, 3, 4]);
775 }
776
777 #[test]
778 fn a_percentage_is_read_exactly_or_refused() {
779 assert_eq!(parse_percentage("90").unwrap(), 900_000);
780 assert_eq!(parse_percentage("99.5").unwrap(), 995_000);
781 assert_eq!(parse_percentage("100").unwrap(), 1_000_000);
782 assert_eq!(parse_percentage("0").unwrap(), 0);
783 assert_eq!(parse_percentage(" 87.6543 ").unwrap(), 876_543);
784 for refused in ["101", "-1", "abc", "", "1e2", "50.123456", "100.0001"] {
785 assert!(parse_percentage(refused).is_err(), "{refused} was accepted");
786 }
787 }
788}