Skip to main content

libverify_core/controls/
test_coverage.rs

1use 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
6/// Verifies that source file changes include corresponding test updates.
7pub 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    // Filter to code files that are not removed
53    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}