Skip to main content

testgap_core/
test_mapper.rs

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
22/// Scan a directory for source and test files, respecting config excludes.
23pub 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        // Check excludes
53        if exclude_patterns.iter().any(|p| p.matches(&relative_str)) {
54            continue;
55        }
56
57        // Detect language
58        let Some(lang) = language_registry::detect_language(path) else {
59            continue;
60        };
61
62        // Filter by allowed languages
63        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
101/// Map test functions to source functions they likely cover.
102/// Returns a set of source function identifiers that have test coverage.
103pub fn map_tests_to_functions(
104    source_functions: &[ExtractedFunction],
105    test_functions: &[ExtractedFunction],
106) -> HashSet<String> {
107    let mut covered = HashSet::new();
108
109    // Build a lookup by function name
110    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        // Strategy 1: Direct name matching
124        // test_foo → foo, test_Foo → Foo
125        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        // Strategy 2: Go-style Test prefix
132        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            // Also try the PascalCase name directly
138            if source_by_name.contains_key(target) {
139                covered.insert(make_key(target, source_by_name[target][0]));
140            }
141        }
142
143        // Strategy 3: Body references — scan test body for source function names
144        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        // Strategy 4: File-based mapping — test_foo.rs tests foo.rs
152        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}