Skip to main content

libverify_core/
test_coverage.rs

1//! Test coverage heuristics for change request diffs.
2//!
3//! This module maps changed source files to likely companion test files
4//! using path conventions and strict semantic matching guards.
5
6use std::collections::HashSet;
7
8use crate::scope::{FileRole, classify_file_role, semantic_path_tokens};
9
10/// A source file that appears to have no matching changed test file.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct UncoveredSource {
13    pub source_path: String,
14    pub suggested_test_paths: Vec<String>,
15}
16
17/// Generate likely companion test file paths for a source file.
18pub fn find_test_pair(source_path: &str) -> Vec<String> {
19    if classify_file_role(source_path) != FileRole::Source {
20        return vec![];
21    }
22
23    let file = source_path.rsplit('/').next().unwrap_or(source_path);
24    let (stem, ext) = split_stem_ext(file);
25    if stem.is_empty() {
26        return vec![];
27    }
28
29    let ext_suffix = if ext.is_empty() {
30        String::new()
31    } else {
32        format!(".{ext}")
33    };
34    let source_parent = parent_dir(source_path);
35
36    let mut out = Vec::new();
37    push_unique(
38        &mut out,
39        join_path(source_parent, &format!("{stem}_test{ext_suffix}")),
40    );
41    push_unique(
42        &mut out,
43        join_path(source_parent, &format!("test_{stem}{ext_suffix}")),
44    );
45
46    if let Some((prefix, rel)) = split_src_root(source_path) {
47        let rel_parent = parent_dir(rel);
48        let tests_root = if prefix.is_empty() {
49            "tests".to_string()
50        } else {
51            format!("{prefix}/tests")
52        };
53        let src_tests_root = if prefix.is_empty() {
54            "src/tests".to_string()
55        } else {
56            format!("{prefix}/src/tests")
57        };
58
59        push_unique(
60            &mut out,
61            join_path(
62                &join_path(&tests_root, rel_parent),
63                &format!("{stem}_test{ext_suffix}"),
64            ),
65        );
66        push_unique(
67            &mut out,
68            join_path(
69                &join_path(&tests_root, rel_parent),
70                &format!("test_{stem}{ext_suffix}"),
71            ),
72        );
73        push_unique(
74            &mut out,
75            join_path(
76                &join_path(&src_tests_root, rel_parent),
77                &format!("{stem}{ext_suffix}"),
78            ),
79        );
80    }
81
82    out
83}
84
85/// Return uncovered source files by checking changed test files.
86pub fn has_test_coverage(source_files: &[&str], test_files: &[&str]) -> Vec<UncoveredSource> {
87    let normalized_tests: HashSet<String> = test_files
88        .iter()
89        .map(|p| normalize_path_for_match(p))
90        .collect();
91
92    let mut uncovered = Vec::new();
93
94    for source in source_files {
95        if classify_file_role(source) != FileRole::Source {
96            continue;
97        }
98
99        let suggestions = find_test_pair(source);
100        let covered_by_convention = suggestions
101            .iter()
102            .any(|candidate| normalized_tests.contains(&normalize_path_for_match(candidate)));
103
104        if covered_by_convention {
105            continue;
106        }
107
108        let covered_by_semantics = test_files
109            .iter()
110            .any(|test| is_semantically_matching_test(source, test));
111        if covered_by_semantics {
112            continue;
113        }
114
115        uncovered.push(UncoveredSource {
116            source_path: (*source).to_string(),
117            suggested_test_paths: suggestions,
118        });
119    }
120
121    uncovered
122}
123
124fn split_stem_ext(file: &str) -> (&str, &str) {
125    if let Some((stem, ext)) = file.rsplit_once('.') {
126        (stem, ext)
127    } else {
128        (file, "")
129    }
130}
131
132fn split_src_root(path: &str) -> Option<(String, &str)> {
133    if let Some(rest) = path.strip_prefix("src/") {
134        return Some((String::new(), rest));
135    }
136    path.split_once("/src/")
137        .map(|(prefix, rest)| (prefix.to_string(), rest))
138}
139
140fn parent_dir(path: &str) -> &str {
141    path.rsplit_once('/').map(|(p, _)| p).unwrap_or("")
142}
143
144fn join_path(parent: &str, child: &str) -> String {
145    if parent.is_empty() {
146        return child.to_string();
147    }
148    if child.is_empty() {
149        return parent.to_string();
150    }
151    format!("{parent}/{child}")
152}
153
154fn push_unique(out: &mut Vec<String>, value: String) {
155    if !out.contains(&value) {
156        out.push(value);
157    }
158}
159
160fn normalize_path_for_match(path: &str) -> String {
161    path.to_ascii_lowercase()
162}
163
164fn normalized_file_stem(path: &str) -> String {
165    let file = path.rsplit('/').next().unwrap_or(path);
166    let (stem, _) = split_stem_ext(file);
167    stem.chars()
168        .filter(|c| c.is_ascii_alphanumeric())
169        .flat_map(char::to_lowercase)
170        .collect()
171}
172
173fn is_semantically_matching_test(source_path: &str, test_path: &str) -> bool {
174    if classify_file_role(test_path) != FileRole::Test {
175        return false;
176    }
177
178    let source_stem = normalized_file_stem(source_path);
179    if source_stem.len() >= 5
180        && !is_generic_token(&source_stem)
181        && normalize_path_for_match(test_path).contains(&source_stem)
182    {
183        return true;
184    }
185
186    let source_tokens = semantic_path_tokens(source_path);
187    let test_tokens: HashSet<String> = semantic_path_tokens(test_path).into_iter().collect();
188
189    source_tokens
190        .iter()
191        .any(|token| token.len() >= 5 && !is_generic_token(token) && test_tokens.contains(token))
192}
193
194fn is_generic_token(token: &str) -> bool {
195    matches!(
196        token,
197        "test"
198            | "tests"
199            | "spec"
200            | "fixture"
201            | "fixtures"
202            | "runtime"
203            | "source"
204            | "types"
205            | "type"
206            | "index"
207            | "core"
208            | "src"
209            | "lib"
210            | "util"
211            | "utils"
212            | "package"
213            | "packages"
214            | "private"
215    )
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn find_test_pair_for_src_file() {
224        let pairs = find_test_pair("src/foo.rs");
225        assert!(pairs.contains(&"tests/foo_test.rs".to_string()));
226        assert!(pairs.contains(&"src/foo_test.rs".to_string()));
227        assert!(pairs.contains(&"tests/test_foo.rs".to_string()));
228        assert!(pairs.contains(&"src/tests/foo.rs".to_string()));
229    }
230
231    #[test]
232    fn find_test_pair_for_nested_workspace_source() {
233        let pairs = find_test_pair("crates/core/src/scope.rs");
234        assert!(pairs.contains(&"crates/core/tests/scope_test.rs".to_string()));
235        assert!(pairs.contains(&"crates/core/src/scope_test.rs".to_string()));
236        assert!(pairs.contains(&"crates/core/tests/test_scope.rs".to_string()));
237        assert!(pairs.contains(&"crates/core/src/tests/scope.rs".to_string()));
238    }
239
240    #[test]
241    fn has_test_coverage_passes_when_pair_exists() {
242        let sources = vec!["src/foo.rs"];
243        let tests = vec!["tests/foo_test.rs"];
244        let uncovered = has_test_coverage(&sources, &tests);
245        assert!(uncovered.is_empty());
246    }
247
248    #[test]
249    fn has_test_coverage_warns_missing_source_pair() {
250        let sources = vec!["src/foo.rs", "src/bar.rs"];
251        let tests = vec!["tests/foo_test.rs"];
252        let uncovered = has_test_coverage(&sources, &tests);
253        assert_eq!(uncovered.len(), 1);
254        assert_eq!(uncovered[0].source_path, "src/bar.rs");
255    }
256
257    #[test]
258    fn semantic_fallback_matches_named_test() {
259        let sources = vec!["packages/runtime-core/src/apiDefineComponent.ts"];
260        let tests = vec!["packages/runtime-core/__tests__/apiDefineComponent.spec.ts"];
261        let uncovered = has_test_coverage(&sources, &tests);
262        assert!(uncovered.is_empty());
263    }
264
265    #[test]
266    fn semantic_fallback_rejects_generic_test_name() {
267        let sources = vec!["src/auth.rs"];
268        let tests = vec!["tests/index_test.rs"];
269        let uncovered = has_test_coverage(&sources, &tests);
270        assert_eq!(uncovered.len(), 1);
271    }
272}