1use crate::config::TestGapConfig;
2use crate::language_registry;
3use crate::types::{ExtractedFunction, Language};
4use crate::Result;
5use ignore::WalkBuilder;
6use std::collections::{HashMap, HashSet};
7use std::path::{Path, PathBuf};
8
9#[derive(Debug)]
10pub struct SourceFile {
11 pub path: PathBuf,
12 pub language: Language,
13 pub is_test: bool,
14}
15
16#[derive(Debug)]
17pub struct ScannedFiles {
18 pub source_files: Vec<SourceFile>,
19 pub test_files: Vec<SourceFile>,
20}
21
22pub fn scan_directory(root: &Path, config: &TestGapConfig) -> Result<ScannedFiles> {
24 let exclude_patterns: Vec<glob::Pattern> = config
25 .exclude
26 .iter()
27 .filter_map(|p| glob::Pattern::new(p).ok())
28 .collect();
29
30 let allowed_languages: Option<HashSet<Language>> = config
31 .languages
32 .as_ref()
33 .map(|v| v.iter().copied().collect());
34
35 let mut source_files = Vec::new();
36 let mut test_files = Vec::new();
37
38 for entry in WalkBuilder::new(root)
39 .follow_links(false)
40 .hidden(true)
41 .build()
42 .filter_map(|e| e.ok())
43 {
44 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
45 continue;
46 }
47
48 let path = entry.path();
49 let relative = path.strip_prefix(root).unwrap_or(path);
50 let relative_str = relative.to_string_lossy();
51
52 if exclude_patterns.iter().any(|p| p.matches(&relative_str)) {
54 continue;
55 }
56
57 let Some(lang) = language_registry::detect_language(path) else {
59 continue;
60 };
61
62 if let Some(ref allowed) = allowed_languages {
64 if !allowed.contains(&lang) {
65 continue;
66 }
67 }
68
69 let is_test = language_registry::is_test_file(
70 relative,
71 &config.test_patterns.test_dirs,
72 &config.test_patterns.test_file_suffixes,
73 &config.test_patterns.test_file_prefixes,
74 );
75
76 let file = SourceFile {
77 path: path.to_path_buf(),
78 language: lang,
79 is_test,
80 };
81
82 if is_test {
83 test_files.push(file);
84 } else {
85 source_files.push(file);
86 }
87 }
88
89 tracing::info!(
90 "Found {} source files and {} test files",
91 source_files.len(),
92 test_files.len()
93 );
94
95 Ok(ScannedFiles {
96 source_files,
97 test_files,
98 })
99}
100
101pub fn map_tests_to_functions(
104 source_functions: &[ExtractedFunction],
105 test_functions: &[ExtractedFunction],
106) -> HashSet<String> {
107 let mut covered = HashSet::new();
108
109 let source_by_name: HashMap<&str, Vec<&ExtractedFunction>> = {
111 let mut map = HashMap::new();
112 for f in source_functions {
113 if !f.is_test {
114 map.entry(f.name.as_str()).or_insert_with(Vec::new).push(f);
115 }
116 }
117 map
118 };
119
120 for test_fn in test_functions {
121 let test_name = &test_fn.name;
122
123 if let Some(target) = test_name.strip_prefix("test_") {
126 if source_by_name.contains_key(target) {
127 covered.insert(make_key(target, source_by_name[target][0]));
128 }
129 }
130
131 if let Some(target) = test_name.strip_prefix("Test") {
133 let lower = to_snake_case(target);
134 if source_by_name.contains_key(lower.as_str()) {
135 covered.insert(make_key(&lower, source_by_name[lower.as_str()][0]));
136 }
137 if source_by_name.contains_key(target) {
139 covered.insert(make_key(target, source_by_name[target][0]));
140 }
141 }
142
143 let body_lower = test_fn.body.to_lowercase();
145 for (name, funcs) in &source_by_name {
146 if body_lower.contains(&name.to_lowercase()) {
147 covered.insert(make_key(name, funcs[0]));
148 }
149 }
150
151 if let Some(test_stem) = test_fn.file_path.file_stem().and_then(|s| s.to_str()) {
153 let candidate = test_stem
154 .strip_prefix("test_")
155 .or_else(|| test_stem.strip_suffix("_test"))
156 .or_else(|| test_stem.strip_suffix(".test"))
157 .or_else(|| test_stem.strip_suffix(".spec"))
158 .or_else(|| test_stem.strip_suffix("_spec"));
159
160 if let Some(source_stem) = candidate {
161 for (name, funcs) in &source_by_name {
162 if funcs.iter().any(|f| {
163 f.file_path.file_stem().and_then(|s| s.to_str()) == Some(source_stem)
164 }) {
165 covered.insert(make_key(name, funcs[0]));
166 }
167 }
168 }
169 }
170 }
171
172 covered
173}
174
175fn make_key(name: &str, func: &ExtractedFunction) -> String {
176 format!("{}::{}", func.file_path.display(), name)
177}
178
179pub fn function_key(func: &ExtractedFunction) -> String {
180 make_key(&func.name, func)
181}
182
183fn to_snake_case(s: &str) -> String {
184 let mut result = String::new();
185 for (i, c) in s.chars().enumerate() {
186 if c.is_uppercase() && i > 0 {
187 result.push('_');
188 }
189 result.push(c.to_ascii_lowercase());
190 }
191 result
192}
193
194#[cfg(test)]
195mod tests {
196 use crate::test_mapper::{function_key, map_tests_to_functions};
197 use crate::types::*;
198 use std::path::PathBuf;
199
200 fn make_func(name: &str, path: &str, is_test: bool, body: &str) -> ExtractedFunction {
201 ExtractedFunction {
202 name: name.to_string(),
203 file_path: PathBuf::from(path),
204 line_start: 1,
205 line_end: 10,
206 signature: format!("fn {name}()"),
207 body: body.to_string(),
208 language: Language::Rust,
209 is_public: true,
210 is_test,
211 complexity: 1,
212 }
213 }
214
215 #[test]
216 fn name_matching_test_prefix() {
217 let source_funcs = vec![make_func("foo", "src/lib.rs", false, "fn foo() {}")];
218 let test_funcs = vec![make_func(
219 "test_foo",
220 "tests/lib_test.rs",
221 true,
222 "fn test_foo() { foo(); }",
223 )];
224
225 let covered = map_tests_to_functions(&source_funcs, &test_funcs);
226 let key = function_key(&source_funcs[0]);
227 assert!(
228 covered.contains(&key),
229 "test_foo should cover foo, covered set: {:?}",
230 covered
231 );
232 }
233
234 #[test]
235 fn go_style_test_prefix() {
236 let source_funcs = vec![make_func(
237 "calculate",
238 "src/math.rs",
239 false,
240 "fn calculate() {}",
241 )];
242 let test_funcs = vec![make_func(
243 "TestCalculate",
244 "tests/math_test.rs",
245 true,
246 "fn TestCalculate() {}",
247 )];
248
249 let covered = map_tests_to_functions(&source_funcs, &test_funcs);
250 let key = function_key(&source_funcs[0]);
251 assert!(
252 covered.contains(&key),
253 "TestCalculate should cover calculate via snake_case conversion, covered set: {:?}",
254 covered
255 );
256 }
257
258 #[test]
259 fn body_matching() {
260 let source_funcs = vec![make_func(
261 "process_data",
262 "src/processor.rs",
263 false,
264 "fn process_data() { /* impl */ }",
265 )];
266 let test_funcs = vec![make_func(
267 "test_integration",
268 "tests/integration.rs",
269 true,
270 "fn test_integration() { process_data(input); assert!(result); }",
271 )];
272
273 let covered = map_tests_to_functions(&source_funcs, &test_funcs);
274 let key = function_key(&source_funcs[0]);
275 assert!(
276 covered.contains(&key),
277 "test body containing 'process_data' should cover it, covered set: {:?}",
278 covered
279 );
280 }
281
282 #[test]
283 fn function_key_format() {
284 let func = make_func("my_func", "src/lib.rs", false, "fn my_func() {}");
285 let key = function_key(&func);
286 assert_eq!(key, "src/lib.rs::my_func");
287 }
288
289 #[test]
290 fn uncovered_function_not_in_set() {
291 let source_funcs = vec![
292 make_func("covered_fn", "src/lib.rs", false, "fn covered_fn() {}"),
293 make_func("uncovered_fn", "src/lib.rs", false, "fn uncovered_fn() {}"),
294 ];
295 let test_funcs = vec![make_func(
296 "test_covered_fn",
297 "tests/test.rs",
298 true,
299 "fn test_covered_fn() {}",
300 )];
301
302 let covered = map_tests_to_functions(&source_funcs, &test_funcs);
303 let covered_key = function_key(&source_funcs[0]);
304 let uncovered_key = function_key(&source_funcs[1]);
305
306 assert!(
307 covered.contains(&covered_key),
308 "covered_fn should be covered"
309 );
310 assert!(
311 !covered.contains(&uncovered_key),
312 "uncovered_fn should NOT be covered"
313 );
314 }
315}