1use crate::analyzer::{CodeIssue, Severity};
2use crate::signals::{classify_rule, compute_signal_scores, StyleSignal};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone)]
12pub struct CodeQualityScore {
13 pub total_score: f64,
14 pub n_score: f64,
15 pub d_score: f64,
16 pub category_scores: HashMap<String, f64>,
17 pub signal_scores: HashMap<StyleSignal, f64>,
18 pub file_count: usize,
19 pub total_lines: usize,
20 pub issue_density: f64,
21 pub severity_distribution: SeverityDistribution,
22 pub quality_level: QualityLevel,
23}
24
25#[derive(Debug, Clone)]
26pub struct SeverityDistribution {
27 pub nuclear: usize,
28 pub spicy: usize,
29 pub mild: usize,
30}
31
32#[derive(Debug, Clone, PartialEq)]
33pub enum QualityLevel {
34 Excellent, Good, Average, Poor, Terrible, }
40
41impl QualityLevel {
42 pub fn from_score(score: f64) -> Self {
43 if !score.is_finite() || score < 0.0 {
44 return QualityLevel::Terrible;
45 }
46 match score as u32 {
47 0..=20 => QualityLevel::Excellent,
48 21..=40 => QualityLevel::Good,
49 41..=60 => QualityLevel::Average,
50 61..=80 => QualityLevel::Poor,
51 _ => QualityLevel::Terrible,
52 }
53 }
54
55 pub fn description(&self, lang: &str) -> &'static str {
56 match (self, lang) {
57 (QualityLevel::Excellent, "zh-CN") => "优秀",
58 (QualityLevel::Good, "zh-CN") => "良好",
59 (QualityLevel::Average, "zh-CN") => "一般",
60 (QualityLevel::Poor, "zh-CN") => "较差",
61 (QualityLevel::Terrible, "zh-CN") => "糟糕",
62 (QualityLevel::Excellent, _) => "Excellent",
63 (QualityLevel::Good, _) => "Good",
64 (QualityLevel::Average, _) => "Average",
65 (QualityLevel::Poor, _) => "Poor",
66 (QualityLevel::Terrible, _) => "Terrible",
67 }
68 }
69
70 pub fn emoji(&self) -> &'static str {
71 match self {
72 QualityLevel::Excellent => "🏆",
73 QualityLevel::Good => "👍",
74 QualityLevel::Average => "😐",
75 QualityLevel::Poor => "😞",
76 QualityLevel::Terrible => "💀",
77 }
78 }
79}
80
81pub struct CodeScorer;
82
83impl CodeScorer {
84 pub fn new() -> Self {
85 Self
86 }
87
88 pub fn calculate_score(
90 &self,
91 issues: &[CodeIssue],
92 file_count: usize,
93 total_lines: usize,
94 ) -> CodeQualityScore {
95 if issues.is_empty() {
96 return CodeQualityScore {
97 total_score: 0.0,
98 n_score: 0.0,
99 d_score: 0.0,
100 category_scores: HashMap::new(),
101 signal_scores: HashMap::new(),
102 file_count,
103 total_lines,
104 issue_density: 0.0,
105 severity_distribution: SeverityDistribution {
106 nuclear: 0,
107 spicy: 0,
108 mild: 0,
109 },
110 quality_level: QualityLevel::Excellent,
111 };
112 }
113
114 let severity_distribution = self.calculate_severity_distribution(issues);
115
116 let k_lines = total_lines as f64 / 1000.0;
118 let mut category_counts: HashMap<&str, usize> = HashMap::new();
119 for issue in issues {
120 let cat = legacy_category_name(classify_rule(&issue.rule_name));
121 *category_counts.entry(cat).or_insert(0) += 1;
122 }
123 let mut category_scores = HashMap::new();
124 for &cat_name in &[
125 "naming",
126 "complexity",
127 "duplication",
128 "code-smells",
129 "student-code",
130 ] {
131 let cat_count = category_counts.get(cat_name).copied().unwrap_or(0);
132 let cat_density = if k_lines > 0.0 {
133 cat_count as f64 / k_lines
134 } else {
135 0.0
136 };
137 let cat_score = ((cat_density + 1.0).log2() * 6.0).min(20.0);
138 category_scores.insert(cat_name.to_string(), cat_score);
139 }
140
141 let n_score = (severity_distribution.nuclear as f64 + 1.0).log2() * 8.0;
156 let n_score = n_score.min(40.0);
157
158 let noisy_density = if k_lines > 0.0 {
159 (severity_distribution.spicy as f64 * 1.5 + severity_distribution.mild as f64) / k_lines
160 } else {
161 0.0
162 };
163 let d_score = (noisy_density + 1.0).log2() * 6.0;
164 let d_score = d_score.min(60.0);
165
166 let total_score = n_score + d_score;
167
168 let issue_density = if total_lines > 0 {
169 issues.len() as f64 / total_lines as f64 * 1000.0
170 } else {
171 0.0
172 };
173
174 let signal_scores = compute_signal_scores(issues, total_lines);
175
176 CodeQualityScore {
177 total_score,
178 n_score,
179 d_score,
180 category_scores,
181 signal_scores,
182 file_count,
183 total_lines,
184 issue_density,
185 severity_distribution,
186 quality_level: QualityLevel::from_score(total_score),
187 }
188 }
189
190 pub fn calculate_score_with_direct(
191 &self,
192 issues: &[CodeIssue],
193 file_count: usize,
194 total_lines: usize,
195 direct_scores: HashMap<StyleSignal, f64>,
196 ) -> CodeQualityScore {
197 let mut score = self.calculate_score(issues, file_count, total_lines);
198 for (signal, direct_score) in direct_scores {
199 let entry = score.signal_scores.entry(signal).or_insert(0.0);
200 *entry = (*entry).max(direct_score);
201 }
202 score
203 }
204
205 fn calculate_severity_distribution(&self, issues: &[CodeIssue]) -> SeverityDistribution {
206 let mut nuclear = 0;
207 let mut spicy = 0;
208 let mut mild = 0;
209 for issue in issues {
210 match issue.severity {
211 Severity::Nuclear => nuclear += 1,
212 Severity::Spicy => spicy += 1,
213 Severity::Mild => mild += 1,
214 }
215 }
216 SeverityDistribution {
217 nuclear,
218 spicy,
219 mild,
220 }
221 }
222}
223
224fn legacy_category_name(signal: StyleSignal) -> &'static str {
225 match signal {
226 StyleSignal::NamingChaos => "naming",
227 StyleSignal::NestedHell => "complexity",
228 StyleSignal::Duplication => "duplication",
229 StyleSignal::PanicAddiction | StyleSignal::HotfixCulture => "student-code",
230 StyleSignal::OverEngineering | StyleSignal::CodeSmells => "code-smells",
231 StyleSignal::LegacyCode => "code-smells",
232 StyleSignal::TodoMountain => "student-code",
233 StyleSignal::LineCountSmell => "complexity",
234 }
235}
236
237impl Default for CodeScorer {
238 fn default() -> Self {
239 Self::new()
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use crate::analyzer::Severity;
247 use std::path::PathBuf;
248
249 fn issue(rule: &str, sev: Severity) -> CodeIssue {
250 CodeIssue {
251 file_path: PathBuf::from("test.rs"),
252 line: 1,
253 column: 1,
254 rule_name: rule.to_string(),
255 message: String::new(),
256 severity: sev,
257 }
258 }
259
260 #[test]
265 fn test_quality_level_tier_boundaries() {
266 assert_eq!(
267 QualityLevel::from_score(0.0),
268 QualityLevel::Excellent,
269 "score 0 should be Excellent"
270 );
271 assert_eq!(
272 QualityLevel::from_score(20.0),
273 QualityLevel::Excellent,
274 "score 20 should still be Excellent (lower bound)"
275 );
276 assert_eq!(
277 QualityLevel::from_score(21.0),
278 QualityLevel::Good,
279 "score 21 transitions to Good"
280 );
281 assert_eq!(
282 QualityLevel::from_score(40.0),
283 QualityLevel::Good,
284 "score 40 should still be Good"
285 );
286 assert_eq!(
287 QualityLevel::from_score(41.0),
288 QualityLevel::Average,
289 "score 41 transitions to Average"
290 );
291 assert_eq!(
292 QualityLevel::from_score(60.0),
293 QualityLevel::Average,
294 "score 60 should still be Average"
295 );
296 assert_eq!(
297 QualityLevel::from_score(61.0),
298 QualityLevel::Poor,
299 "score 61 transitions to Poor"
300 );
301 assert_eq!(
302 QualityLevel::from_score(80.0),
303 QualityLevel::Poor,
304 "score 80 should still be Poor"
305 );
306 assert_eq!(
307 QualityLevel::from_score(81.0),
308 QualityLevel::Terrible,
309 "score 81 transitions to Terrible"
310 );
311 assert_eq!(
312 QualityLevel::from_score(100.0),
313 QualityLevel::Terrible,
314 "score 100 is Terrible"
315 );
316 }
317
318 #[test]
320 fn test_quality_level_description_english() {
321 assert_eq!(
322 QualityLevel::Excellent.description("en"),
323 "Excellent",
324 "English description for Excellent"
325 );
326 assert_eq!(
327 QualityLevel::Terrible.description("en"),
328 "Terrible",
329 "English description for Terrible"
330 );
331 }
332
333 #[test]
335 fn test_quality_level_description_chinese() {
336 assert_eq!(
337 QualityLevel::Excellent.description("zh-CN"),
338 "优秀",
339 "Chinese description for Excellent"
340 );
341 assert_eq!(
342 QualityLevel::Terrible.description("zh-CN"),
343 "糟糕",
344 "Chinese description for Terrible"
345 );
346 }
347
348 #[test]
353 fn test_empty_issues_score_zero() {
354 let scorer = CodeScorer::new();
355 let score = scorer.calculate_score(&[], 5, 1000);
356 assert_eq!(
357 score.total_score, 0.0,
358 "empty issues => total_score 0, got {}",
359 score.total_score
360 );
361 assert_eq!(
362 score.quality_level,
363 QualityLevel::Excellent,
364 "empty issues => Excellent quality"
365 );
366 assert_eq!(score.issue_density, 0.0, "empty issues => density 0");
367 assert_eq!(score.n_score, 0.0, "empty issues => n_score 0");
368 assert_eq!(score.d_score, 0.0, "empty issues => d_score 0");
369 }
370
371 #[test]
376 fn test_severity_distribution_counts() {
377 let scorer = CodeScorer::new();
378 let issues = vec![
379 issue("a", Severity::Nuclear),
380 issue("b", Severity::Spicy),
381 issue("c", Severity::Mild),
382 issue("d", Severity::Nuclear),
383 ];
384 let dist = scorer.calculate_severity_distribution(&issues);
385 assert_eq!(dist.nuclear, 2, "should count 2 nuclear issues");
386 assert_eq!(dist.spicy, 1, "should count 1 spicy issue");
387 assert_eq!(dist.mild, 1, "should count 1 mild issue");
388 assert_eq!(
389 dist.nuclear + dist.spicy + dist.mild,
390 issues.len(),
391 "severity counts must sum to total issue count"
392 );
393 }
394
395 #[test]
400 fn test_n_score_monotonic_with_nuclear_count() {
401 let scorer = CodeScorer::new();
402 let one_nuke = scorer.calculate_score(&[issue("n1", Severity::Nuclear)], 1, 1000);
403 let two_nukes = scorer.calculate_score(
404 &[
405 issue("n1", Severity::Nuclear),
406 issue("n2", Severity::Nuclear),
407 ],
408 1,
409 1000,
410 );
411 assert!(
412 two_nukes.n_score > one_nuke.n_score,
413 "n_score should increase from {} to {} with more nuclear issues",
414 one_nuke.n_score,
415 two_nukes.n_score
416 );
417 }
418
419 #[test]
422 fn test_n_score_capped_at_40() {
423 let scorer = CodeScorer::new();
424 let issues: Vec<CodeIssue> = (0..100)
425 .map(|i| issue(&format!("x{i}"), Severity::Nuclear))
426 .collect();
427 let score = scorer.calculate_score(&issues, 1, 1000);
428 assert!(
429 score.n_score <= 40.0,
430 "n_score cap is 40, got {}",
431 score.n_score
432 );
433 }
434
435 #[test]
439 fn test_d_score_higher_with_denser_code() {
440 let scorer = CodeScorer::new();
441 let issues: Vec<CodeIssue> = (0..50)
442 .map(|i| issue(&format!("m{i}"), Severity::Mild))
443 .collect();
444 let sparse = scorer.calculate_score(&issues, 1, 10000);
445 let dense = scorer.calculate_score(&issues, 1, 500);
446 assert!(
447 dense.d_score > sparse.d_score,
448 "dense (50 issues / 500 lines) should score higher d_score than sparse (50 / 10000), got {} vs {}",
449 dense.d_score, sparse.d_score
450 );
451 }
452
453 #[test]
455 fn test_d_score_capped_at_60() {
456 let scorer = CodeScorer::new();
457 let issues: Vec<CodeIssue> = (0..5000)
458 .map(|i| issue(&format!("m{i}"), Severity::Mild))
459 .collect();
460 let score = scorer.calculate_score(&issues, 1, 100);
461 assert!(
462 score.d_score <= 60.0,
463 "d_score cap is 60, got {}",
464 score.d_score
465 );
466 }
467
468 #[test]
471 fn test_total_score_is_n_plus_d() {
472 let scorer = CodeScorer::new();
473 let issues = vec![
474 issue("n", Severity::Nuclear),
475 issue("s", Severity::Spicy),
476 issue("m", Severity::Mild),
477 ];
478 let score = scorer.calculate_score(&issues, 1, 1000);
479 let expected = score.n_score + score.d_score;
480 assert!(
481 (score.total_score - expected).abs() < 1e-6,
482 "total_score ({}) should equal n_score ({}) + d_score ({}) = {}",
483 score.total_score,
484 score.n_score,
485 score.d_score,
486 expected
487 );
488 }
489
490 #[test]
494 fn test_zero_lines_does_not_produce_nan() {
495 let scorer = CodeScorer::new();
496 let score = scorer.calculate_score(&[issue("x", Severity::Nuclear)], 1, 0);
497 assert!(
498 score.total_score.is_finite(),
499 "total_score must be finite, got {}",
500 score.total_score
501 );
502 assert!(score.n_score.is_finite(), "n_score must be finite");
503 assert!(score.d_score.is_finite(), "d_score must be finite");
504 assert!(
505 score.total_score > 0.0,
506 "with a nuclear issue, total_score should be > 0"
507 );
508 }
509
510 #[test]
515 fn test_all_category_keys_present() {
516 let scorer = CodeScorer::new();
517 let score = scorer.calculate_score(&[issue("terrible-naming", Severity::Mild)], 1, 1000);
518 for cat in &[
519 "naming",
520 "complexity",
521 "duplication",
522 "code-smells",
523 "student-code",
524 ] {
525 assert!(
526 score.category_scores.contains_key(*cat),
527 "category '{}' should exist in scores",
528 cat
529 );
530 }
531 }
532
533 #[test]
535 fn test_category_score_positive_when_rule_matches() {
536 let scorer = CodeScorer::new();
537 let score = scorer.calculate_score(
538 &[
539 issue("terrible-naming", Severity::Nuclear),
540 issue("single-letter-variable", Severity::Spicy),
541 ],
542 1,
543 1000,
544 );
545 let naming_score = score
546 .category_scores
547 .get("naming")
548 .expect("naming category should exist");
549 assert!(
550 *naming_score > 0.0,
551 "naming category should have non-zero score when naming rules fire, got {}",
552 naming_score
553 );
554 }
555
556 #[test]
558 fn test_category_score_zero_when_no_matching_rules() {
559 let scorer = CodeScorer::new();
560 let score = scorer.calculate_score(&[issue("unwrap-abuse", Severity::Mild)], 1, 1000);
561 let naming_score = score
562 .category_scores
563 .get("naming")
564 .expect("naming category should exist");
565 assert_eq!(
566 *naming_score, 0.0,
567 "naming category should be 0 when no naming rules fire"
568 );
569 }
570
571 #[test]
574 fn test_categories_are_independent() {
575 let scorer = CodeScorer::new();
576 let score = scorer.calculate_score(
577 &[
578 issue("terrible-naming", Severity::Nuclear),
579 issue("deep-nesting", Severity::Spicy),
580 ],
581 1,
582 1000,
583 );
584 let naming = *score
585 .category_scores
586 .get("naming")
587 .expect("naming category");
588 let complexity = *score
589 .category_scores
590 .get("complexity")
591 .expect("complexity category");
592 assert!(
593 naming > 0.0 && complexity > 0.0,
594 "both naming ({naming}) and complexity ({complexity}) should be > 0 when their rules fire"
595 );
596 let duplication = *score
597 .category_scores
598 .get("duplication")
599 .expect("duplication category");
600 assert_eq!(
601 duplication, 0.0,
602 "duplication category should be 0 since no duplication rule fired"
603 );
604 }
605}