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)]
179pub struct ConditionRecord {
180 pub line: usize,
181 pub column: usize,
182 pub index: usize,
183 pub covered: bool,
184 pub source: String,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
188#[serde(rename_all = "camelCase")]
189pub struct FileView {
190 pub file: String,
191 pub metrics: Vec<MetricView>,
192 pub measured_lines: Vec<usize>,
197 pub uncovered_lines: Vec<usize>,
199 pub missing_branches: Vec<Location>,
200 pub missing_conditions: Vec<Location>,
201 pub functions: Vec<FunctionRecord>,
202 pub branches: Vec<BranchRecord>,
203 #[serde(skip)]
207 pub conditions: Vec<ConditionRecord>,
208}
209
210impl FileView {
211 pub fn metric(&self, metric: Metric) -> Option<&MetricView> {
212 self.metrics.iter().find(|view| view.metric == metric)
213 }
214
215 pub fn line_hits(&self) -> impl Iterator<Item = (usize, bool)> + '_ {
220 self.measured_lines
221 .iter()
222 .map(|line| (*line, self.uncovered_lines.binary_search(line).is_err()))
223 }
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
228#[serde(rename_all = "camelCase")]
229pub struct Blocker {
230 pub reason: String,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
234#[serde(rename_all = "camelCase")]
235pub struct RunView {
236 pub schema_version: u32,
237 pub run: String,
238 pub generated_at: String,
239 pub suite_passed: bool,
242 pub stale: bool,
243 pub stale_reasons: Vec<String>,
244 pub complete: bool,
245 pub limitations: Vec<String>,
246 pub totals: Vec<MetricView>,
247 pub files: Vec<FileView>,
248 pub source_neighbourhoods: BTreeSet<(String, String)>,
256}
257
258impl RunView {
259 pub fn metric(&self, metric: Metric) -> Option<&MetricView> {
260 self.totals.iter().find(|view| view.metric == metric)
261 }
262
263 pub fn file(&self, path: &str) -> Option<&FileView> {
264 self.files.iter().find(|file| file.file == path)
265 }
266
267 pub fn measured_line(&self, path: &str, line: usize) -> bool {
269 self.file(path)
270 .is_some_and(|file| file.measured_lines.binary_search(&line).is_ok())
271 }
272
273 pub fn looks_like_source(&self, path: &str) -> bool {
278 neighbourhood(path).is_some_and(|key| self.source_neighbourhoods.contains(&key))
279 }
280
281 pub fn blockers(&self) -> Vec<Blocker> {
283 let mut blockers = Vec::new();
284 if !self.suite_passed {
285 blockers.push(Blocker {
286 reason: "the wrapped test command did not pass; a gate over a failed suite cannot report success".into(),
287 });
288 }
289 if self.stale {
290 let detail = if self.stale_reasons.is_empty() {
291 "the run no longer matches the current checkout".to_owned()
292 } else {
293 format!(
294 "the run no longer matches the current checkout: {}",
295 self.stale_reasons.join(", ")
296 )
297 };
298 blockers.push(Blocker { reason: detail });
299 }
300 blockers
301 }
302}
303
304fn neighbourhood(path: &str) -> Option<(String, String)> {
307 let (directory, name) = path.rsplit_once('/').unwrap_or(("", path));
308 let (_, extension) = name.rsplit_once('.')?;
309 Some((directory.to_owned(), extension.to_owned()))
310}
311
312pub fn build(
314 run: &str,
315 generated_at: &str,
316 view: &CoverageView,
317 suite_passed: bool,
318 stale: bool,
319 stale_reasons: Vec<String>,
320) -> Result<RunView, ReportError> {
321 let mut files = BTreeMap::<String, FileView>::new();
322 for line in &view.lines {
323 files
324 .entry(line.file.clone())
325 .or_insert_with(|| FileView {
326 file: line.file.clone(),
327 metrics: Vec::new(),
328 measured_lines: Vec::new(),
329 uncovered_lines: Vec::new(),
330 missing_branches: Vec::new(),
331 missing_conditions: Vec::new(),
332 functions: Vec::new(),
333 branches: Vec::new(),
334 conditions: Vec::new(),
335 })
336 .measured_lines
337 .extend(line.measured.then_some(line.line));
338 if line.measured
339 && !line.covered
340 && let Some(file) = files.get_mut(&line.file)
341 {
342 file.uncovered_lines.push(line.line);
343 }
344 }
345 for branch in &view.branches {
346 let entry = files
347 .entry(branch.meta.file.clone())
348 .or_insert_with(|| FileView {
349 file: branch.meta.file.clone(),
350 metrics: Vec::new(),
351 measured_lines: Vec::new(),
352 uncovered_lines: Vec::new(),
353 missing_branches: Vec::new(),
354 missing_conditions: Vec::new(),
355 functions: Vec::new(),
356 branches: Vec::new(),
357 conditions: Vec::new(),
358 });
359 for alternative in branch.alternatives.iter().filter(|a| !a.covered) {
360 entry.missing_branches.push(Location {
361 line: branch.meta.line,
362 column: branch.meta.column,
363 });
364 let _ = alternative;
365 }
366 }
367 for point in &view.points {
368 if point.meta.kind != crate::coverage_analysis::PointKind::Function {
369 continue;
370 }
371 if let Some(file) = files.get_mut(&point.meta.file) {
372 file.functions.push(FunctionRecord {
373 line: point.meta.line,
374 name: point
375 .meta
376 .label
377 .clone()
378 .unwrap_or_else(|| format!("{}:{}", point.meta.line, point.meta.column)),
379 covered: point.covered,
380 });
381 }
382 }
383 for (block, branch) in view.branches.iter().enumerate() {
384 if let Some(file) = files.get_mut(&branch.meta.file) {
385 for (index, alternative) in branch.alternatives.iter().enumerate() {
386 file.branches.push(BranchRecord {
387 line: branch.meta.line,
388 block,
389 index,
390 taken: alternative.covered,
391 });
392 }
393 }
394 }
395 for decision in &view.decisions {
396 let entry = files
397 .entry(decision.meta.file.clone())
398 .or_insert_with(|| FileView {
399 file: decision.meta.file.clone(),
400 metrics: Vec::new(),
401 measured_lines: Vec::new(),
402 uncovered_lines: Vec::new(),
403 missing_branches: Vec::new(),
404 missing_conditions: Vec::new(),
405 functions: Vec::new(),
406 branches: Vec::new(),
407 conditions: Vec::new(),
408 });
409 for _ in decision.conditions.iter().filter(|c| !c.covered) {
410 entry.missing_conditions.push(Location {
411 line: decision.meta.line,
412 column: decision.meta.column,
413 });
414 }
415 for condition in &decision.conditions {
416 entry.conditions.push(ConditionRecord {
417 line: decision.meta.line,
418 column: decision.meta.column,
419 index: condition.index,
420 covered: condition.covered,
421 source: condition.source.clone(),
422 });
423 }
424 }
425 let mut built = Vec::new();
426 for (path, mut file) in files {
427 file.metrics = metrics_of(&coverage_summary_for_file(view, &path)?);
428 file.measured_lines.sort_unstable();
429 file.measured_lines.dedup();
430 file.uncovered_lines.sort_unstable();
431 file.uncovered_lines.dedup();
432 file.missing_branches.sort_by_key(|at| (at.line, at.column));
433 file.missing_conditions
434 .sort_by_key(|at| (at.line, at.column));
435 file.functions
436 .sort_by(|a, b| (a.line, &a.name).cmp(&(b.line, &b.name)));
437 file.branches
438 .sort_by_key(|record| (record.line, record.block, record.index));
439 file.conditions
440 .sort_by_key(|record| (record.line, record.column, record.index));
441 built.push(file);
442 }
443 let source_neighbourhoods = built
444 .iter()
445 .filter_map(|file| neighbourhood(&file.file))
446 .collect::<BTreeSet<_>>();
447 Ok(RunView {
448 schema_version: RUN_VIEW_SCHEMA_VERSION,
449 run: run.to_owned(),
450 generated_at: generated_at.to_owned(),
451 suite_passed,
452 stale,
453 stale_reasons,
454 complete: view.summary.coverage_complete,
455 limitations: view
456 .limitations
457 .iter()
458 .map(|limitation| limitation.to_string())
459 .collect(),
460 totals: metrics_of(&view.summary),
461 files: built,
462 source_neighbourhoods,
463 })
464}
465
466#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
469#[serde(rename_all = "camelCase")]
470pub struct Floor {
471 pub metric: Metric,
472 pub ppm: u64,
473}
474
475pub fn parse_percentage(value: &str) -> Result<u64, String> {
478 let text = value.trim();
479 let (whole, fraction) = match text.split_once('.') {
480 Some((whole, fraction)) => (whole, fraction),
481 None => (text, ""),
482 };
483 if whole.is_empty() && fraction.is_empty() {
484 return Err(format!("{value:?} is not a percentage"));
485 }
486 if !whole.chars().all(|c| c.is_ascii_digit())
487 || !fraction.chars().all(|c| c.is_ascii_digit())
488 || fraction.len() > 4
489 {
490 return Err(format!(
491 "{value:?} is not a percentage between 0 and 100 with at most four decimal places"
492 ));
493 }
494 let whole: u64 = if whole.is_empty() {
495 0
496 } else {
497 whole
498 .parse()
499 .map_err(|_| format!("{value:?} is too large"))?
500 };
501 let scaled: u64 = if fraction.is_empty() {
502 0
503 } else {
504 format!("{fraction:0<4}")
505 .parse()
506 .map_err(|_| format!("{value:?} is not a percentage"))?
507 };
508 let ppm = whole
509 .checked_mul(10_000)
510 .and_then(|whole| whole.checked_add(scaled))
511 .ok_or_else(|| format!("{value:?} is too large"))?;
512 (ppm <= 1_000_000)
513 .then_some(ppm)
514 .ok_or_else(|| format!("{value:?} is above 100"))
515}
516
517#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
518#[serde(rename_all = "camelCase")]
519pub struct Violation {
520 #[serde(skip_serializing_if = "Option::is_none")]
522 pub file: Option<String>,
523 pub metric: Metric,
524 pub covered: usize,
525 pub eligible: usize,
526 pub floor_ppm: u64,
527 pub uncovered_lines: Vec<usize>,
528}
529
530#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
531#[serde(rename_all = "camelCase", tag = "result")]
532pub enum Outcome {
533 Pass,
534 Fail {
535 violations: Vec<Violation>,
536 },
537 Error {
540 reasons: Vec<String>,
541 },
542}
543
544impl Outcome {
545 pub fn exit_code(&self) -> u8 {
546 match self {
547 Outcome::Pass => 0,
548 Outcome::Fail { .. } => 1,
549 Outcome::Error { .. } => 2,
550 }
551 }
552}
553
554pub fn check(view: &RunView, floors: &[Floor], per_file: bool) -> Outcome {
561 let mut reasons = view
562 .blockers()
563 .into_iter()
564 .map(|blocker| blocker.reason)
565 .collect::<Vec<_>>();
566 if floors.is_empty() {
567 reasons.push("no floor requested; give at least one --min-<metric>".into());
568 }
569 for floor in floors {
570 match view.metric(floor.metric) {
571 None => reasons.push(format!(
572 "{} is not measured by this run's language adapter; remove {}",
573 floor.metric.name(),
574 floor.metric.flag()
575 )),
576 Some(metric) => match &metric.applicability {
577 Applicability::NotApplicable => reasons.push(format!(
578 "{} has nothing eligible in this run, which is not the same as complete; remove {} or widen the scope",
579 floor.metric.name(),
580 floor.metric.flag()
581 )),
582 Applicability::Incomplete { unmeasured } => reasons.push(format!(
583 "{} left {unmeasured} obligation(s) unmeasured, so {} cannot be judged exactly",
584 floor.metric.name(),
585 floor.metric.flag()
586 )),
587 Applicability::Measured => {}
588 },
589 }
590 }
591 if !reasons.is_empty() {
592 return Outcome::Error { reasons };
593 }
594
595 let mut violations = Vec::new();
596 for floor in floors {
597 let Some(metric) = view.metric(floor.metric) else {
598 continue;
599 };
600 if !metric.meets(floor.ppm) {
601 violations.push(Violation {
602 file: None,
603 metric: floor.metric,
604 covered: metric.covered,
605 eligible: metric.eligible,
606 floor_ppm: floor.ppm,
607 uncovered_lines: Vec::new(),
608 });
609 }
610 if !per_file {
611 continue;
612 }
613 for file in &view.files {
614 let Some(counts) = file.metric(floor.metric) else {
615 continue;
616 };
617 if counts.eligible == 0 || counts.meets(floor.ppm) {
620 continue;
621 }
622 violations.push(Violation {
623 file: Some(file.file.clone()),
624 metric: floor.metric,
625 covered: counts.covered,
626 eligible: counts.eligible,
627 floor_ppm: floor.ppm,
628 uncovered_lines: file.uncovered_lines.clone(),
629 });
630 }
631 }
632 if violations.is_empty() {
633 Outcome::Pass
634 } else {
635 Outcome::Fail { violations }
636 }
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642
643 fn metric(covered: usize, eligible: usize) -> MetricView {
644 MetricView {
645 metric: Metric::Lines,
646 covered,
647 eligible,
648 applicability: if eligible == 0 {
649 Applicability::NotApplicable
650 } else {
651 Applicability::Measured
652 },
653 }
654 }
655
656 fn view(totals: Vec<MetricView>, files: Vec<FileView>) -> RunView {
657 RunView {
658 schema_version: RUN_VIEW_SCHEMA_VERSION,
659 run: "run_1".into(),
660 generated_at: "now".into(),
661 suite_passed: true,
662 stale: false,
663 stale_reasons: Vec::new(),
664 complete: true,
665 limitations: Vec::new(),
666 totals,
667 files,
668 source_neighbourhoods: BTreeSet::new(),
669 }
670 }
671
672 #[test]
673 fn a_floor_is_compared_against_counts_and_never_a_rounded_percentage() {
674 let almost = metric(9_999, 10_000);
678 assert_eq!(format!("{:.2}", almost.percentage().unwrap()), "99.99");
679 assert!(!almost.meets(1_000_000));
680 assert!(almost.meets(999_000));
681 assert!(metric(10_000, 10_000).meets(1_000_000));
682
683 assert!(metric(995, 1_000).meets(parse_percentage("99.5").unwrap()));
685 assert!(!metric(994, 1_000).meets(parse_percentage("99.5").unwrap()));
686 }
687
688 #[test]
689 fn nothing_eligible_is_not_complete_coverage() {
690 let empty = metric(0, 0);
693 assert!(empty.meets(1_000_000));
694 assert_eq!(empty.applicability, Applicability::NotApplicable);
695 let outcome = check(
696 &view(vec![empty], Vec::new()),
697 &[Floor {
698 metric: Metric::Lines,
699 ppm: 1_000_000,
700 }],
701 false,
702 );
703 assert!(matches!(outcome, Outcome::Error { .. }), "{outcome:?}");
704 assert_eq!(outcome.exit_code(), 2);
705 }
706
707 #[test]
708 fn evidence_that_cannot_answer_the_question_never_passes() {
709 let floors = [Floor {
710 metric: Metric::Lines,
711 ppm: 500_000,
712 }];
713 let mut failed = view(vec![metric(10, 10)], Vec::new());
716 failed.suite_passed = false;
717 assert_eq!(check(&failed, &floors, false).exit_code(), 2);
718
719 let mut stale = view(vec![metric(10, 10)], Vec::new());
721 stale.stale = true;
722 stale.stale_reasons = vec!["instrumented source changed".into()];
723 let outcome = check(&stale, &floors, false);
724 assert_eq!(outcome.exit_code(), 2);
725 let Outcome::Error { reasons } = outcome else {
726 panic!("expected an error");
727 };
728 assert!(
729 reasons[0].contains("instrumented source changed"),
730 "{reasons:?}"
731 );
732
733 let mut partial = view(vec![metric(10, 10)], Vec::new());
735 partial.totals[0].applicability = Applicability::Incomplete { unmeasured: 3 };
736 assert_eq!(check(&partial, &floors, false).exit_code(), 2);
737
738 assert_eq!(
740 check(
741 &view(vec![metric(10, 10)], Vec::new()),
742 &[Floor {
743 metric: Metric::Mcdc,
744 ppm: 500_000
745 }],
746 false
747 )
748 .exit_code(),
749 2
750 );
751 }
752
753 #[test]
754 fn every_violation_is_reported_with_the_counts_behind_it() {
755 let files = vec![
758 FileView {
759 file: "src/a.ts".into(),
760 metrics: vec![MetricView {
761 metric: Metric::Lines,
762 covered: 1,
763 eligible: 4,
764 applicability: Applicability::Measured,
765 }],
766 measured_lines: vec![1, 2, 3, 4],
767 uncovered_lines: vec![2, 3, 4],
768 missing_branches: Vec::new(),
769 missing_conditions: Vec::new(),
770 functions: Vec::new(),
771 branches: Vec::new(),
772 conditions: Vec::new(),
773 },
774 FileView {
775 file: "src/b.ts".into(),
776 metrics: vec![MetricView {
777 metric: Metric::Lines,
778 covered: 6,
779 eligible: 6,
780 applicability: Applicability::Measured,
781 }],
782 measured_lines: vec![1, 2, 3, 4, 5, 6],
783 uncovered_lines: Vec::new(),
784 missing_branches: Vec::new(),
785 missing_conditions: Vec::new(),
786 functions: Vec::new(),
787 branches: Vec::new(),
788 conditions: Vec::new(),
789 },
790 ];
791 let outcome = check(
792 &view(vec![metric(7, 10)], files),
793 &[Floor {
794 metric: Metric::Lines,
795 ppm: 900_000,
796 }],
797 true,
798 );
799 let Outcome::Fail { violations } = outcome else {
800 panic!("expected a policy failure");
801 };
802 assert_eq!(violations.len(), 2, "{violations:?}");
803 assert_eq!(violations[0].file, None);
804 assert_eq!((violations[0].covered, violations[0].eligible), (7, 10));
805 assert_eq!(violations[1].file.as_deref(), Some("src/a.ts"));
806 assert_eq!(violations[1].uncovered_lines, [2, 3, 4]);
807 }
808
809 #[test]
810 fn a_percentage_is_read_exactly_or_refused() {
811 assert_eq!(parse_percentage("90").unwrap(), 900_000);
812 assert_eq!(parse_percentage("99.5").unwrap(), 995_000);
813 assert_eq!(parse_percentage("100").unwrap(), 1_000_000);
814 assert_eq!(parse_percentage("0").unwrap(), 0);
815 assert_eq!(parse_percentage(" 87.6543 ").unwrap(), 876_543);
816 for refused in ["101", "-1", "abc", "", "1e2", "50.123456", "100.0001"] {
817 assert!(parse_percentage(refused).is_err(), "{refused} was accepted");
818 }
819 }
820}