Skip to main content

codelens_core/insight/scoring/
mod.rs

1//! Scoring model abstraction for code health analysis.
2
3pub mod default;
4
5use serde::Serialize;
6
7use crate::analyzer::stats::FileStats;
8use crate::insight::Grade;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
11pub enum HealthDimension {
12    Complexity,
13    FuncSize,
14    CommentRatio,
15    FileSize,
16    NestingDepth,
17}
18
19impl std::fmt::Display for HealthDimension {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            Self::Complexity => write!(f, "Complexity"),
23            Self::FuncSize => write!(f, "Func Size"),
24            Self::CommentRatio => write!(f, "Comment %"),
25            Self::FileSize => write!(f, "File Size"),
26            Self::NestingDepth => write!(f, "Nesting"),
27        }
28    }
29}
30
31pub struct DimensionWeight {
32    pub dimension: HealthDimension,
33    pub weight: f64,
34}
35
36#[derive(Debug, Clone, Default)]
37pub struct RawMetrics {
38    pub avg_cyclomatic: f64,
39    pub avg_func_lines: f64,
40    pub comment_ratio: f64,
41    /// For single file: the file's max nesting depth.
42    /// For multiple files: P90 percentile of all files' max depths (robust against outliers).
43    pub depth: usize,
44    pub avg_file_lines: f64,
45    pub total_files: usize,
46}
47
48impl RawMetrics {
49    pub fn from_file(file: &FileStats) -> Self {
50        let avg_cyclomatic = if file.complexity.functions > 0 {
51            file.complexity.cyclomatic as f64 / file.complexity.functions as f64
52        } else {
53            0.0
54        };
55        let comment_ratio = if file.lines.code > 0 {
56            file.lines.comment as f64 / file.lines.code as f64
57        } else {
58            0.0
59        };
60        Self {
61            avg_cyclomatic,
62            avg_func_lines: file.complexity.avg_func_lines,
63            comment_ratio,
64            depth: file.complexity.max_depth,
65            avg_file_lines: file.lines.total as f64,
66            total_files: 1,
67        }
68    }
69
70    pub fn from_file_refs(files: &[&FileStats]) -> Self {
71        if files.is_empty() {
72            return Self::default();
73        }
74        let total_functions: usize = files.iter().map(|f| f.complexity.functions).sum();
75        let total_cyclomatic: usize = files.iter().map(|f| f.complexity.cyclomatic).sum();
76        let total_code: usize = files.iter().map(|f| f.lines.code).sum();
77        let total_comment: usize = files.iter().map(|f| f.lines.comment).sum();
78        let total_lines: usize = files.iter().map(|f| f.lines.total).sum();
79
80        let mut depths: Vec<usize> = files.iter().map(|f| f.complexity.max_depth).collect();
81        let depth = percentile_90(&mut depths);
82
83        let avg_cyclomatic = if total_functions > 0 {
84            total_cyclomatic as f64 / total_functions as f64
85        } else {
86            0.0
87        };
88        let avg_func_lines = if total_functions > 0 {
89            total_code as f64 / total_functions as f64
90        } else {
91            0.0
92        };
93        let comment_ratio = if total_code > 0 {
94            total_comment as f64 / total_code as f64
95        } else {
96            0.0
97        };
98        let avg_file_lines = total_lines as f64 / files.len() as f64;
99
100        Self {
101            avg_cyclomatic,
102            avg_func_lines,
103            comment_ratio,
104            depth,
105            avg_file_lines,
106            total_files: files.len(),
107        }
108    }
109
110    pub fn from_files(files: &[FileStats]) -> Self {
111        if files.is_empty() {
112            return Self::default();
113        }
114        let total_functions: usize = files.iter().map(|f| f.complexity.functions).sum();
115        let total_cyclomatic: usize = files.iter().map(|f| f.complexity.cyclomatic).sum();
116        let total_code: usize = files.iter().map(|f| f.lines.code).sum();
117        let total_comment: usize = files.iter().map(|f| f.lines.comment).sum();
118        let total_lines: usize = files.iter().map(|f| f.lines.total).sum();
119
120        let mut depths: Vec<usize> = files.iter().map(|f| f.complexity.max_depth).collect();
121        let depth = percentile_90(&mut depths);
122
123        let avg_cyclomatic = if total_functions > 0 {
124            total_cyclomatic as f64 / total_functions as f64
125        } else {
126            0.0
127        };
128        let avg_func_lines = if total_functions > 0 {
129            total_code as f64 / total_functions as f64
130        } else {
131            0.0
132        };
133        let comment_ratio = if total_code > 0 {
134            total_comment as f64 / total_code as f64
135        } else {
136            0.0
137        };
138        let avg_file_lines = total_lines as f64 / files.len() as f64;
139
140        Self {
141            avg_cyclomatic,
142            avg_func_lines,
143            comment_ratio,
144            depth,
145            avg_file_lines,
146            total_files: files.len(),
147        }
148    }
149}
150
151/// Compute the P90 percentile of a mutable slice (sorts in place).
152/// For a single element, returns that element. For empty, returns 0.
153fn percentile_90(values: &mut [usize]) -> usize {
154    if values.is_empty() {
155        return 0;
156    }
157    values.sort_unstable();
158    let idx = ((values.len() as f64 - 1.0) * 0.9).ceil() as usize;
159    values[idx.min(values.len() - 1)]
160}
161
162pub trait ScoringModel: Send + Sync {
163    fn name(&self) -> &str;
164    fn dimensions(&self) -> &[DimensionWeight];
165    fn score_dimension(&self, dimension: HealthDimension, metrics: &RawMetrics) -> f64;
166
167    fn grade(&self, score: f64) -> Grade {
168        match score as u32 {
169            90..=100 => Grade::A,
170            80..=89 => Grade::B,
171            70..=79 => Grade::C,
172            60..=69 => Grade::D,
173            _ => Grade::F,
174        }
175    }
176
177    fn total_score(&self, metrics: &RawMetrics) -> f64 {
178        let dims = self.dimensions();
179        let total_weight: f64 = dims.iter().map(|d| d.weight).sum();
180        if total_weight == 0.0 {
181            return 0.0;
182        }
183        let weighted_sum: f64 = dims
184            .iter()
185            .map(|d| self.score_dimension(d.dimension, metrics) * d.weight)
186            .sum();
187        (weighted_sum / total_weight).clamp(0.0, 100.0)
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::analyzer::stats::{Complexity, LineStats};
195    use std::path::PathBuf;
196
197    #[test]
198    fn test_raw_metrics_from_file() {
199        let file = FileStats {
200            path: PathBuf::from("test.rs"),
201            language: "Rust".to_string(),
202            lines: LineStats {
203                total: 100,
204                code: 80,
205                comment: 10,
206                blank: 10,
207            },
208            size: 2000,
209            complexity: Complexity {
210                functions: 4,
211                cyclomatic: 12,
212                max_depth: 3,
213                avg_func_lines: 20.0,
214            },
215        };
216        let metrics = RawMetrics::from_file(&file);
217        assert!((metrics.avg_cyclomatic - 3.0).abs() < 0.01);
218        assert!((metrics.comment_ratio - 0.125).abs() < 0.01);
219        assert_eq!(metrics.depth, 3);
220    }
221
222    #[test]
223    fn test_raw_metrics_from_empty_files() {
224        let metrics = RawMetrics::from_files(&[]);
225        assert_eq!(metrics.total_files, 0);
226    }
227
228    #[test]
229    fn test_raw_metrics_from_multiple_files() {
230        let files = vec![
231            FileStats {
232                path: PathBuf::from("a.rs"),
233                language: "Rust".to_string(),
234                lines: LineStats {
235                    total: 100,
236                    code: 80,
237                    comment: 10,
238                    blank: 10,
239                },
240                size: 2000,
241                complexity: Complexity {
242                    functions: 4,
243                    cyclomatic: 12,
244                    max_depth: 3,
245                    avg_func_lines: 20.0,
246                },
247            },
248            FileStats {
249                path: PathBuf::from("b.rs"),
250                language: "Rust".to_string(),
251                lines: LineStats {
252                    total: 50,
253                    code: 40,
254                    comment: 5,
255                    blank: 5,
256                },
257                size: 1000,
258                complexity: Complexity {
259                    functions: 2,
260                    cyclomatic: 6,
261                    max_depth: 5,
262                    avg_func_lines: 20.0,
263                },
264            },
265        ];
266        let metrics = RawMetrics::from_files(&files);
267        assert_eq!(metrics.total_files, 2);
268        assert!((metrics.avg_cyclomatic - 3.0).abs() < 0.01);
269        // P90 of [3, 5] = 5 (only 2 elements, P90 picks the higher)
270        assert_eq!(metrics.depth, 5);
271        assert!((metrics.avg_file_lines - 75.0).abs() < 0.01);
272    }
273
274    #[test]
275    fn test_health_dimension_display() {
276        assert_eq!(HealthDimension::Complexity.to_string(), "Complexity");
277        assert_eq!(HealthDimension::CommentRatio.to_string(), "Comment %");
278    }
279
280    #[test]
281    fn test_percentile_90_empty() {
282        assert_eq!(super::percentile_90(&mut []), 0);
283    }
284
285    #[test]
286    fn test_percentile_90_single() {
287        assert_eq!(super::percentile_90(&mut [7]), 7);
288    }
289
290    #[test]
291    fn test_percentile_90_filters_outlier() {
292        // 10 files: 9 with depth 3, 1 outlier with depth 15
293        // P90 index = ceil(9 * 0.9) = 9 → sorted[9] = 15
294        // But with 10 elements: ceil((10-1)*0.9) = ceil(8.1) = 9 → sorted[9] = 15
295        // To actually filter: need more normal values.
296        // 20 files: 18 with depth 3, 2 outliers with depth 15
297        let mut depths = vec![3; 18];
298        depths.extend_from_slice(&[15, 15]);
299        // P90 index = ceil(19 * 0.9) = ceil(17.1) = 18 → sorted[18] = 15
300        // Still picks outlier. Need 90%+ to be normal.
301        // 20 files: 19 with depth 3, 1 outlier with depth 15
302        let mut depths = vec![3; 19];
303        depths.push(15);
304        // P90 index = ceil(19 * 0.9) = ceil(17.1) = 18 → sorted[18] = 3
305        assert_eq!(super::percentile_90(&mut depths), 3);
306    }
307
308    #[test]
309    fn test_percentile_90_gradual() {
310        // depths: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
311        // P90 index = ceil(9 * 0.9) = ceil(8.1) = 9 → sorted[9] = 10
312        let mut depths: Vec<usize> = (1..=10).collect();
313        assert_eq!(super::percentile_90(&mut depths), 10);
314
315        // depths: [1, 2, 3, ..., 20]
316        // P90 index = ceil(19 * 0.9) = ceil(17.1) = 18 → sorted[18] = 19
317        let mut depths: Vec<usize> = (1..=20).collect();
318        assert_eq!(super::percentile_90(&mut depths), 19);
319    }
320}