Skip to main content

garbage_code_hunter/
scoring.rs

1use crate::analyzer::{CodeIssue, Severity};
2use crate::signals::{classify_rule, compute_signal_scores, StyleSignal};
3use std::collections::HashMap;
4
5/// Code quality rating system — two-tier log model.
6/// Score starts at 0 (best). Higher score = worse code quality.
7/// 0-20: Excellent  |  21-40: Good  |  41-60: Average  |  61-80: Poor  |  81+: Terrible
8///
9/// Tier 1: Nuclear issues (high confidence) → log-scaled absolute count, cap 40
10/// Tier 2: Spicy + Mild issues (noisy) → log-scaled density per 1k lines, cap 60
11#[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, // 0-20
35    Good,      // 21-40
36    Average,   // 41-60
37    Poor,      // 61-80
38    Terrible,  // 81+
39}
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    /// Accumulation model: start at 0, each issue adds points.
89    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        // Category breakdown: log-scaled density per category (informational only)
117        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        // Two-tier log scoring (0-100)
142        //
143        // Tier 1: Nuclear — absolute count, log-scaled.
144        //   Nuclear issues are high-confidence (deep nesting, god function, bare except).
145        //   Even 1 Nuclear is meaningful. Log prevents large counts from dominating.
146        //   log2(1 + n) * 8: 0→0, 1→8, 2→12.7, 5→20.7, 10→27.7, 30→39.6
147        //   Cap at 40.
148        //
149        // Tier 2: Noisy density — Spicy + Mild combined, density-normalized, log-scaled.
150        //   Non-Nuclear issues are noisy (magic-number, naming, println are often FPs).
151        //   Must use density (per 1k lines) to be fair across project sizes.
152        //   Spicy counts 1.5x vs Mild 1x (slightly more reliable, but still noisy).
153        //   log2(1 + d) * 6: d=0→0, d=1→6, d=7→18, d=31→30, d=127→42
154        //   Cap at 60.
155        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    // ── QualityLevel ─────────────────────────────────────────────
261
262    /// Objective: Verify from_score returns the correct level for each tier.
263    /// Invariants: Boundaries (20, 40, 60, 80) belong to the lower tier.
264    #[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    /// Objective: Verify QualityLevel::description returns correct English strings.
319    #[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    /// Objective: Verify QualityLevel::description returns correct Chinese strings.
334    #[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    // ── CodeScorer — empty input ──────────────────────────────────
349
350    /// Objective: Verify that empty issues result in zero score with Excellent level.
351    /// Invariants: total_score, n_score, d_score are all 0 when issues is empty.
352    #[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    // ── CodeScorer — severity distribution ─────────────────────────
372
373    /// Objective: Verify severity distribution counts each severity bucket correctly.
374    /// Invariants: The sum of all counts equals the total number of issues.
375    #[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    // ── CodeScorer — two-tier log scoring ──────────────────────────
396
397    /// Objective: Verify n_score grows monotonically with nuclear issue count.
398    /// Invariants: More nuclear issues => strictly larger n_score.
399    #[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    /// Objective: Verify n_score is capped at 40 (log2 cap).
420    /// Invariants: Even with 100 nuclear issues, n_score never exceeds 40.
421    #[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    /// Objective: Verify d_score increases when issue density is higher
436    ///            (same issues in fewer lines).
437    /// Invariants: Denser project produces strictly higher d_score.
438    #[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    /// Objective: Verify d_score is capped at 60 (log-scaled density cap).
454    #[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    /// Objective: Verify total_score = n_score + d_score always.
469    /// Invariants: total_score must equal the sum of its two components.
470    #[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    /// Objective: Verify zero total_lines does not produce NaN or crash.
491    /// Invariants: d_score = log2(1)*6 = 0 when density denominator is 0.
492    ///             n_score is unaffected.
493    #[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    // ── CodeScorer — category scores ───────────────────────────────
511
512    /// Objective: Verify that all five expected category keys exist in the result.
513    /// Invariants: The category map always contains naming/complexity/duplication/code-smells/student-code.
514    #[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    /// Objective: Verify category score > 0 when at least one rule in that category matches.
534    #[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    /// Objective: Verify category score is 0 when no rules in that category fire.
557    #[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    /// Objective: Verify category scores across different categories are independent.
572    /// Invariants: Rules in category A only affect category A's score, not category B's.
573    #[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}