libverify_core/controls/
test_coverage.rs1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState, GovernedChange};
3use crate::scope::{FileRole, classify_file_role, is_non_code_file};
4use crate::test_coverage::has_test_coverage;
5
6pub struct TestCoverageControl;
8
9impl Control for TestCoverageControl {
10 fn id(&self) -> ControlId {
11 builtin::id(builtin::TEST_COVERAGE)
12 }
13
14 fn description(&self) -> &'static str {
15 "Source changes must include matching test updates"
16 }
17
18 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
19 if evidence.change_requests.is_empty() {
20 return vec![ControlFinding::not_applicable(
21 self.id(),
22 "No change requests found",
23 )];
24 }
25
26 evidence
27 .change_requests
28 .iter()
29 .map(|cr| evaluate_change(self.id(), cr))
30 .collect()
31 }
32}
33
34fn evaluate_change(id: ControlId, cr: &GovernedChange) -> ControlFinding {
35 let cr_subject = cr.id.to_string();
36
37 let assets = match &cr.changed_assets {
38 EvidenceState::NotApplicable => {
39 return ControlFinding::not_applicable(id, "Changed assets not applicable");
40 }
41 EvidenceState::Missing { gaps } => {
42 return ControlFinding::indeterminate(
43 id,
44 "Changed assets evidence could not be collected",
45 Vec::new(),
46 gaps.clone(),
47 );
48 }
49 EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => value,
50 };
51
52 let code_files: Vec<&str> = assets
54 .iter()
55 .filter(|a| a.status != "removed" && !is_non_code_file(&a.path))
56 .map(|a| a.path.as_str())
57 .collect();
58
59 let source_files: Vec<&str> = code_files
60 .iter()
61 .copied()
62 .filter(|p| classify_file_role(p) == FileRole::Source)
63 .collect();
64
65 let test_files: Vec<&str> = code_files
66 .iter()
67 .copied()
68 .filter(|p| classify_file_role(p) == FileRole::Test)
69 .collect();
70
71 if source_files.is_empty() {
72 return ControlFinding::satisfied(
73 id,
74 format!("{cr_subject}: no source files changed; test coverage not required"),
75 Vec::new(),
76 );
77 }
78
79 let uncovered = has_test_coverage(&source_files, &test_files);
80
81 if uncovered.is_empty() {
82 ControlFinding::satisfied(
83 id,
84 format!(
85 "{cr_subject}: all {} source file(s) have matching test updates",
86 source_files.len()
87 ),
88 source_files.iter().map(|s| s.to_string()).collect(),
89 )
90 } else {
91 let uncovered_paths: Vec<String> =
92 uncovered.iter().map(|u| u.source_path.clone()).collect();
93 ControlFinding::violated(
94 id,
95 format!(
96 "{cr_subject}: {} source file(s) changed without matching test updates: {}",
97 uncovered.len(),
98 uncovered_paths.join(", ")
99 ),
100 uncovered_paths,
101 )
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use crate::control::ControlStatus;
109 use crate::evidence::{ChangeRequestId, ChangedAsset, EvidenceGap};
110
111 fn asset(path: &str) -> ChangedAsset {
112 ChangedAsset {
113 path: path.to_string(),
114 diff_available: true,
115 additions: 1,
116 deletions: 0,
117 status: "modified".to_string(),
118 diff: None,
119 }
120 }
121
122 fn bundle_with(assets: Vec<ChangedAsset>) -> EvidenceBundle {
123 EvidenceBundle {
124 change_requests: vec![GovernedChange {
125 id: ChangeRequestId::new("test", "owner/repo#1"),
126 title: "test".to_string(),
127 summary: None,
128 submitted_by: None,
129 changed_assets: EvidenceState::complete(assets),
130 approval_decisions: EvidenceState::not_applicable(),
131 source_revisions: EvidenceState::not_applicable(),
132 work_item_refs: EvidenceState::not_applicable(),
133 }],
134 ..Default::default()
135 }
136 }
137
138 #[test]
139 fn not_applicable_when_no_changes() {
140 let findings = TestCoverageControl.evaluate(&EvidenceBundle::default());
141 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
142 }
143
144 #[test]
145 fn satisfied_when_test_pair_exists() {
146 let bundle = bundle_with(vec![asset("src/foo.rs"), asset("tests/foo_test.rs")]);
147 let findings = TestCoverageControl.evaluate(&bundle);
148 assert_eq!(findings[0].status, ControlStatus::Satisfied);
149 }
150
151 #[test]
152 fn violated_when_source_has_no_test() {
153 let bundle = bundle_with(vec![asset("src/bar.rs")]);
154 let findings = TestCoverageControl.evaluate(&bundle);
155 assert_eq!(findings[0].status, ControlStatus::Violated);
156 assert!(findings[0].subjects.contains(&"src/bar.rs".to_string()));
157 }
158
159 #[test]
160 fn satisfied_for_test_only_pr() {
161 let bundle = bundle_with(vec![asset("tests/foo_test.rs")]);
162 let findings = TestCoverageControl.evaluate(&bundle);
163 assert_eq!(findings[0].status, ControlStatus::Satisfied);
164 }
165
166 #[test]
167 fn satisfied_for_non_code_only_pr() {
168 let bundle = bundle_with(vec![asset("README.md"), asset("docs/guide.md")]);
169 let findings = TestCoverageControl.evaluate(&bundle);
170 assert_eq!(findings[0].status, ControlStatus::Satisfied);
171 }
172
173 #[test]
174 fn indeterminate_when_evidence_missing() {
175 let bundle = EvidenceBundle {
176 change_requests: vec![GovernedChange {
177 id: ChangeRequestId::new("test", "owner/repo#1"),
178 title: "test".to_string(),
179 summary: None,
180 submitted_by: None,
181 changed_assets: EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
182 source: "github".to_string(),
183 subject: "files".to_string(),
184 detail: "API error".to_string(),
185 }]),
186 approval_decisions: EvidenceState::not_applicable(),
187 source_revisions: EvidenceState::not_applicable(),
188 work_item_refs: EvidenceState::not_applicable(),
189 }],
190 ..Default::default()
191 };
192 let findings = TestCoverageControl.evaluate(&bundle);
193 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
194 }
195}