libverify_core/controls/
coverage_threshold.rs1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState};
3
4const DEFAULT_THRESHOLD: f64 = 60.0;
6
7pub 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}