codelens_core/insight/scoring/
mod.rs1pub 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 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
151fn 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 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 let mut depths = vec![3; 18];
298 depths.extend_from_slice(&[15, 15]);
299 let mut depths = vec![3; 19];
303 depths.push(15);
304 assert_eq!(super::percentile_90(&mut depths), 3);
306 }
307
308 #[test]
309 fn test_percentile_90_gradual() {
310 let mut depths: Vec<usize> = (1..=10).collect();
313 assert_eq!(super::percentile_90(&mut depths), 10);
314
315 let mut depths: Vec<usize> = (1..=20).collect();
318 assert_eq!(super::percentile_90(&mut depths), 19);
319 }
320}