1use crate::config::TestGapConfig;
2use crate::test_mapper;
3use crate::types::{ExtractedFunction, GapSeverity, TestGap};
4use std::collections::HashSet;
5
6pub 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 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 if should_skip(func) {
27 continue;
28 }
29
30 let severity = classify_severity(func);
31
32 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 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 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 "__init__",
102 "__str__",
103 "__repr__",
104 "__eq__",
105 "__hash__",
106 "String",
108 "Error",
109 ];
110
111 if trivial_names.contains(&name.as_str()) {
112 return true;
113 }
114
115 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 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 assert_eq!(gaps.len(), 3, "expected 3 gaps, got {}", gaps.len());
236
237 assert_eq!(gaps[0].severity, GapSeverity::Critical);
239 assert_eq!(gaps[0].function.name, "uncovered_public_complex");
240
241 assert_eq!(gaps[1].severity, GapSeverity::Warning);
243 assert_eq!(gaps[1].function.name, "uncovered_public_simple");
244
245 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}