libverify_core/
test_coverage.rs1use std::collections::HashSet;
7
8use crate::scope::{FileRole, classify_file_role, semantic_path_tokens};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct UncoveredSource {
13 pub source_path: String,
14 pub suggested_test_paths: Vec<String>,
15}
16
17pub 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
85pub 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}