Skip to main content

testgap_core/
gap_detector.rs

1use crate::config::TestGapConfig;
2use crate::test_mapper;
3use crate::types::{ExtractedFunction, GapSeverity, TestGap};
4use std::collections::HashSet;
5
6/// Detect test gaps by comparing source functions against test coverage mapping.
7pub fn detect_gaps(
8    functions: &[ExtractedFunction],
9    covered: &HashSet<String>,
10    config: &TestGapConfig,
11) -> Vec<TestGap> {
12    let mut gaps = Vec::new();
13
14    for func in functions {
15        // Skip test functions themselves
16        if func.is_test {
17            continue;
18        }
19
20        let key = test_mapper::function_key(func);
21        if covered.contains(&key) {
22            continue;
23        }
24
25        // Skip trivial functions
26        if should_skip(func) {
27            continue;
28        }
29
30        let severity = classify_severity(func);
31
32        // Apply minimum severity filter
33        if severity < config.min_severity {
34            continue;
35        }
36
37        let reason = build_reason(func, severity);
38
39        gaps.push(TestGap {
40            function: func.clone(),
41            severity,
42            reason,
43            ai_analysis: None,
44        });
45    }
46
47    // Sort by severity (critical first), then by file path
48    gaps.sort_by(|a, b| {
49        b.severity
50            .cmp(&a.severity)
51            .then_with(|| a.function.file_path.cmp(&b.function.file_path))
52            .then_with(|| a.function.line_start.cmp(&b.function.line_start))
53    });
54
55    gaps
56}
57
58fn classify_severity(func: &ExtractedFunction) -> GapSeverity {
59    let is_complex = func.complexity >= 5;
60
61    match (func.is_public, is_complex) {
62        (true, true) => GapSeverity::Critical,
63        (true, false) => GapSeverity::Warning,
64        (false, _) => GapSeverity::Info,
65    }
66}
67
68fn build_reason(func: &ExtractedFunction, severity: GapSeverity) -> String {
69    match severity {
70        GapSeverity::Critical => {
71            format!(
72                "Public function with high complexity ({}) and no test coverage",
73                func.complexity
74            )
75        }
76        GapSeverity::Warning => "Public function with no test coverage".to_string(),
77        GapSeverity::Info => "Private function with no test coverage".to_string(),
78    }
79}
80
81fn should_skip(func: &ExtractedFunction) -> bool {
82    let name = &func.name;
83
84    // Skip common boilerplate / trivial functions
85    let trivial_names = [
86        "main",
87        "new",
88        "default",
89        "fmt",
90        "from",
91        "into",
92        "as_ref",
93        "deref",
94        "drop",
95        "clone",
96        "eq",
97        "hash",
98        "partial_cmp",
99        "cmp",
100        // Python dunder methods
101        "__init__",
102        "__str__",
103        "__repr__",
104        "__eq__",
105        "__hash__",
106        // Go String() / Error()
107        "String",
108        "Error",
109    ];
110
111    if trivial_names.contains(&name.as_str()) {
112        return true;
113    }
114
115    // Skip very short functions (getters, simple returns)
116    if func.body.lines().count() <= 3 {
117        return true;
118    }
119
120    false
121}
122
123#[cfg(test)]
124mod tests {
125    use crate::config::TestGapConfig;
126    use crate::gap_detector::detect_gaps;
127    use crate::test_mapper;
128    use crate::types::*;
129    use std::collections::HashSet;
130    use std::path::PathBuf;
131
132    fn make_func(
133        name: &str,
134        is_public: bool,
135        complexity: u32,
136        body_lines: usize,
137    ) -> ExtractedFunction {
138        let body = (0..body_lines)
139            .map(|i| format!("    line {i}"))
140            .collect::<Vec<_>>()
141            .join("\n");
142        ExtractedFunction {
143            name: name.to_string(),
144            file_path: PathBuf::from("src/lib.rs"),
145            line_start: 1,
146            line_end: body_lines,
147            signature: format!("fn {name}()"),
148            body,
149            language: Language::Rust,
150            is_public,
151            is_test: false,
152            complexity,
153        }
154    }
155
156    #[test]
157    fn severity_critical_for_public_complex() {
158        let func = make_func("process_data", true, 5, 10);
159        let covered = HashSet::new();
160        let config = TestGapConfig::default();
161
162        let gaps = detect_gaps(&[func], &covered, &config);
163        assert_eq!(gaps.len(), 1);
164        assert_eq!(gaps[0].severity, GapSeverity::Critical);
165    }
166
167    #[test]
168    fn severity_warning_for_public_simple() {
169        let func = make_func("get_value", true, 2, 10);
170        let covered = HashSet::new();
171        let config = TestGapConfig::default();
172
173        let gaps = detect_gaps(&[func], &covered, &config);
174        assert_eq!(gaps.len(), 1);
175        assert_eq!(gaps[0].severity, GapSeverity::Warning);
176    }
177
178    #[test]
179    fn severity_info_for_private() {
180        let func = make_func("helper_internal", false, 10, 10);
181        let covered = HashSet::new();
182        let config = TestGapConfig::default();
183
184        let gaps = detect_gaps(&[func], &covered, &config);
185        assert_eq!(gaps.len(), 1);
186        assert_eq!(gaps[0].severity, GapSeverity::Info);
187    }
188
189    #[test]
190    fn should_skip_trivial_names() {
191        let trivial_names = [
192            "main", "new", "default", "fmt", "from", "__init__", "String", "Error",
193        ];
194        let config = TestGapConfig::default();
195        let covered = HashSet::new();
196
197        for name in &trivial_names {
198            let func = make_func(name, true, 3, 10);
199            let gaps = detect_gaps(&[func], &covered, &config);
200            assert!(gaps.is_empty(), "function '{}' should be skipped", name);
201        }
202    }
203
204    #[test]
205    fn should_skip_short_body() {
206        // Functions with body <= 3 lines should be skipped
207        let func = make_func("short_func", true, 3, 3);
208        let config = TestGapConfig::default();
209        let covered = HashSet::new();
210
211        let gaps = detect_gaps(&[func], &covered, &config);
212        assert!(
213            gaps.is_empty(),
214            "function with body <= 3 lines should be skipped"
215        );
216    }
217
218    #[test]
219    fn detect_gaps_end_to_end() {
220        let funcs = vec![
221            make_func("uncovered_public_complex", true, 7, 15),
222            make_func("uncovered_public_simple", true, 2, 8),
223            make_func("uncovered_private", false, 3, 6),
224            make_func("covered_func", true, 3, 10),
225        ];
226
227        let covered_key = test_mapper::function_key(&funcs[3]);
228        let mut covered = HashSet::new();
229        covered.insert(covered_key);
230
231        let config = TestGapConfig::default();
232        let gaps = detect_gaps(&funcs, &covered, &config);
233
234        // Should have 3 gaps (the uncovered ones)
235        assert_eq!(gaps.len(), 3, "expected 3 gaps, got {}", gaps.len());
236
237        // First gap should be Critical (sorted by severity descending)
238        assert_eq!(gaps[0].severity, GapSeverity::Critical);
239        assert_eq!(gaps[0].function.name, "uncovered_public_complex");
240
241        // Second should be Warning
242        assert_eq!(gaps[1].severity, GapSeverity::Warning);
243        assert_eq!(gaps[1].function.name, "uncovered_public_simple");
244
245        // Third should be Info
246        assert_eq!(gaps[2].severity, GapSeverity::Info);
247        assert_eq!(gaps[2].function.name, "uncovered_private");
248    }
249
250    #[test]
251    fn test_functions_are_excluded() {
252        let mut func = make_func("test_something", true, 3, 10);
253        func.is_test = true;
254        let config = TestGapConfig::default();
255        let covered = HashSet::new();
256
257        let gaps = detect_gaps(&[func], &covered, &config);
258        assert!(gaps.is_empty(), "test functions should not appear as gaps");
259    }
260}