Skip to main content

libverify_core/controls/
coverage_threshold.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState};
3
4/// Default minimum line coverage percentage.
5const DEFAULT_THRESHOLD: f64 = 60.0;
6
7/// Verifies that code coverage meets a minimum threshold.
8pub struct CoverageThresholdControl;
9
10impl Control for CoverageThresholdControl {
11    fn id(&self) -> ControlId {
12        builtin::id(builtin::COVERAGE_THRESHOLD)
13    }
14
15    fn description(&self) -> &'static str {
16        "Code coverage must meet the minimum threshold (requires --coverage-report)"
17    }
18
19    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
20        let id = self.id();
21
22        let report = match &evidence.coverage_report {
23            EvidenceState::NotApplicable => {
24                return vec![ControlFinding::not_applicable(
25                    id,
26                    "Coverage report evidence is not applicable",
27                )];
28            }
29            EvidenceState::Missing { gaps } => {
30                return vec![ControlFinding::indeterminate(
31                    id,
32                    "Coverage report evidence is unavailable",
33                    vec![],
34                    gaps.clone(),
35                )];
36            }
37            EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => value,
38        };
39
40        let pct = report.line_coverage_pct;
41
42        if pct >= DEFAULT_THRESHOLD {
43            vec![ControlFinding::satisfied(
44                id,
45                format!("Line coverage {pct:.1}% meets threshold {DEFAULT_THRESHOLD:.0}%"),
46                vec![format!("{pct:.1}%")],
47            )]
48        } else {
49            vec![ControlFinding::violated(
50                id,
51                format!("Line coverage {pct:.1}% is below threshold {DEFAULT_THRESHOLD:.0}%"),
52                vec![format!("{pct:.1}%")],
53            )]
54        }
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use crate::control::ControlStatus;
62    use crate::evidence::{CoverageReport, EvidenceGap};
63
64    fn make_bundle(pct: f64) -> EvidenceBundle {
65        EvidenceBundle {
66            coverage_report: EvidenceState::complete(CoverageReport {
67                line_coverage_pct: pct,
68                lines_total: 1000,
69                lines_covered: (pct * 10.0) as u32,
70                branch_coverage_pct: None,
71                source_format: None,
72            }),
73            ..Default::default()
74        }
75    }
76
77    #[test]
78    fn above_threshold_is_satisfied() {
79        let findings = CoverageThresholdControl.evaluate(&make_bundle(75.0));
80        assert_eq!(findings.len(), 1);
81        assert_eq!(findings[0].status, ControlStatus::Satisfied);
82        assert!(findings[0].rationale.contains("75.0%"));
83    }
84
85    #[test]
86    fn at_threshold_is_satisfied() {
87        let findings = CoverageThresholdControl.evaluate(&make_bundle(60.0));
88        assert_eq!(findings.len(), 1);
89        assert_eq!(findings[0].status, ControlStatus::Satisfied);
90    }
91
92    #[test]
93    fn below_threshold_is_violated() {
94        let findings = CoverageThresholdControl.evaluate(&make_bundle(45.5));
95        assert_eq!(findings.len(), 1);
96        assert_eq!(findings[0].status, ControlStatus::Violated);
97        assert!(findings[0].rationale.contains("45.5%"));
98        assert!(findings[0].rationale.contains("below"));
99    }
100
101    #[test]
102    fn zero_coverage_is_violated() {
103        let findings = CoverageThresholdControl.evaluate(&make_bundle(0.0));
104        assert_eq!(findings.len(), 1);
105        assert_eq!(findings[0].status, ControlStatus::Violated);
106    }
107
108    #[test]
109    fn missing_evidence_is_indeterminate() {
110        let bundle = EvidenceBundle {
111            coverage_report: EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
112                source: "ci".to_string(),
113                subject: "coverage".to_string(),
114                detail: "no report".to_string(),
115            }]),
116            ..Default::default()
117        };
118        let findings = CoverageThresholdControl.evaluate(&bundle);
119        assert_eq!(findings.len(), 1);
120        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
121        assert_eq!(findings[0].evidence_gaps.len(), 1);
122    }
123
124    #[test]
125    fn not_applicable_when_evidence_not_applicable() {
126        let bundle = EvidenceBundle {
127            coverage_report: EvidenceState::not_applicable(),
128            ..Default::default()
129        };
130        let findings = CoverageThresholdControl.evaluate(&bundle);
131        assert_eq!(findings.len(), 1);
132        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
133    }
134
135    #[test]
136    fn control_id_is_coverage_threshold() {
137        assert_eq!(
138            CoverageThresholdControl.id(),
139            builtin::id(builtin::COVERAGE_THRESHOLD)
140        );
141    }
142
143    #[test]
144    fn partial_evidence_still_evaluates() {
145        let bundle = EvidenceBundle {
146            coverage_report: EvidenceState::partial(
147                CoverageReport {
148                    line_coverage_pct: 80.0,
149                    lines_total: 100,
150                    lines_covered: 80,
151                    branch_coverage_pct: None,
152                    source_format: None,
153                },
154                vec![EvidenceGap::Truncated {
155                    source: "ci".to_string(),
156                    subject: "coverage".to_string(),
157                }],
158            ),
159            ..Default::default()
160        };
161        let findings = CoverageThresholdControl.evaluate(&bundle);
162        assert_eq!(findings.len(), 1);
163        assert_eq!(findings[0].status, ControlStatus::Satisfied);
164    }
165}