lean_ctx/core/code_health/
score.rs1use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct Hotspot {
15 pub file: String,
16 pub symbol: String,
17 pub line: usize,
19 pub cognitive: u32,
20}
21
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct NavigabilityScore {
25 pub score: u32,
27 pub total_functions: usize,
28 pub over_threshold: usize,
29 pub worst_cognitive: u32,
30 pub import_cycles: usize,
31 pub estimated_waste_usd: f64,
33 pub hotspots: Vec<Hotspot>,
35}
36
37#[derive(Debug, Clone, Copy)]
42pub struct NavigabilityInputs<'a> {
43 pub functions_total: usize,
44 pub over_threshold: usize,
45 pub worst_cognitive: u32,
46 pub import_cycles: usize,
47 pub wasted_tokens: u64,
48 pub input_price_per_m: f64,
49 pub hotspots: &'a [Hotspot],
50 pub top_n: usize,
51}
52
53pub fn navigability(inputs: NavigabilityInputs) -> NavigabilityScore {
55 let density = if inputs.functions_total == 0 {
56 0.0
57 } else {
58 inputs.over_threshold as f64 / inputs.functions_total as f64
59 };
60
61 let density_penalty = (density * 60.0).min(60.0);
62 let cycle_penalty = (inputs.import_cycles as f64 * 4.0).min(25.0);
63 let severity_penalty = if inputs.worst_cognitive > 15 {
64 (f64::from(inputs.worst_cognitive - 15) * 0.8).min(15.0)
65 } else {
66 0.0
67 };
68
69 let raw = 100.0 - density_penalty - cycle_penalty - severity_penalty;
70 let score = raw.clamp(0.0, 100.0).round() as u32;
71
72 let estimated_waste_usd = inputs.wasted_tokens as f64 / 1_000_000.0 * inputs.input_price_per_m;
73
74 let mut hotspots = inputs.hotspots.to_vec();
75 hotspots.sort_by(|a, b| {
76 b.cognitive
77 .cmp(&a.cognitive)
78 .then_with(|| a.file.cmp(&b.file))
79 .then_with(|| a.line.cmp(&b.line))
80 });
81 hotspots.truncate(inputs.top_n);
82
83 NavigabilityScore {
84 score,
85 total_functions: inputs.functions_total,
86 over_threshold: inputs.over_threshold,
87 worst_cognitive: inputs.worst_cognitive,
88 import_cycles: inputs.import_cycles,
89 estimated_waste_usd,
90 hotspots,
91 }
92}
93
94pub fn grade(score: u32) -> char {
96 match score {
97 90..=100 => 'A',
98 75..=89 => 'B',
99 60..=74 => 'C',
100 40..=59 => 'D',
101 _ => 'F',
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 fn inputs<'a>(total: usize, over: usize, worst: u32, cycles: usize) -> NavigabilityInputs<'a> {
110 NavigabilityInputs {
111 functions_total: total,
112 over_threshold: over,
113 worst_cognitive: worst,
114 import_cycles: cycles,
115 wasted_tokens: 0,
116 input_price_per_m: 0.0,
117 hotspots: &[],
118 top_n: 5,
119 }
120 }
121
122 #[test]
123 fn clean_project_scores_100() {
124 let s = navigability(inputs(50, 0, 8, 0));
125 assert_eq!(s.score, 100);
126 assert_eq!(grade(s.score), 'A');
127 }
128
129 #[test]
130 fn heavy_complexity_lowers_score() {
131 let clean = navigability(inputs(10, 0, 10, 0)).score;
132 let messy = navigability(inputs(10, 8, 40, 3)).score;
133 assert!(messy < clean);
134 assert!(messy < 60);
135 }
136
137 #[test]
138 fn empty_project_is_not_negative() {
139 let s = navigability(inputs(0, 0, 0, 0));
140 assert_eq!(s.score, 100);
141 }
142
143 #[test]
144 fn waste_usd_uses_input_price() {
145 let mut inp = inputs(10, 2, 20, 0);
146 inp.wasted_tokens = 2_000_000;
147 inp.input_price_per_m = 5.0;
148 let s = navigability(inp);
149 assert!((s.estimated_waste_usd - 10.0).abs() < 1e-9);
150 }
151
152 #[test]
153 fn hotspots_sorted_and_truncated() {
154 let hs = vec![
155 Hotspot {
156 file: "a.rs".into(),
157 symbol: "low".into(),
158 line: 1,
159 cognitive: 16,
160 },
161 Hotspot {
162 file: "b.rs".into(),
163 symbol: "high".into(),
164 line: 2,
165 cognitive: 40,
166 },
167 ];
168 let mut inp = inputs(10, 2, 40, 0);
169 inp.hotspots = &hs;
170 inp.top_n = 1;
171 let s = navigability(inp);
172 assert_eq!(s.hotspots.len(), 1);
173 assert_eq!(s.hotspots[0].symbol, "high");
174 }
175
176 #[test]
177 fn grade_boundaries() {
178 assert_eq!(grade(100), 'A');
179 assert_eq!(grade(89), 'B');
180 assert_eq!(grade(60), 'C');
181 assert_eq!(grade(40), 'D');
182 assert_eq!(grade(0), 'F');
183 }
184}