Skip to main content

garbage_code_hunter/
hall_of_shame.rs

1use crate::analyzer::{CodeIssue, Severity};
2/// Hall of Shame - tracks and ranks the worst code patterns and files
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone)]
7pub struct ShameEntry {
8    pub file_path: PathBuf,
9    pub total_issues: usize,
10    pub shame_score: f64,
11}
12
13#[derive(Debug, Clone)]
14pub struct PatternStats {
15    pub rule_name: String,
16    pub count: usize,
17    pub severity_distribution: HashMap<Severity, usize>,
18    pub example_files: Vec<PathBuf>,
19}
20
21#[derive(Debug, Clone)]
22pub struct ProjectShameStats {
23    pub total_files_analyzed: usize,
24    pub total_issues: usize,
25    pub garbage_density: f64,           // issues per 1000 lines of code
26    pub hall_of_shame: Vec<ShameEntry>, // worst files
27}
28
29pub struct HallOfShame {
30    entries: Vec<ShameEntry>,
31    pattern_stats: HashMap<String, PatternStats>,
32    total_lines: usize,
33}
34
35impl HallOfShame {
36    pub fn new() -> Self {
37        Self {
38            entries: Vec::new(),
39            pattern_stats: HashMap::new(),
40            total_lines: 0,
41        }
42    }
43
44    pub fn add_file_analysis(
45        &mut self,
46        file_path: PathBuf,
47        issues: &[CodeIssue],
48        file_lines: usize,
49    ) {
50        self.total_lines += file_lines;
51
52        if issues.is_empty() {
53            return;
54        }
55
56        let mut nuclear_count = 0;
57        let mut spicy_count = 0;
58        let mut mild_count = 0;
59
60        // Analyze issues for this file
61        for issue in issues {
62            match issue.severity {
63                Severity::Nuclear => nuclear_count += 1,
64                Severity::Spicy => spicy_count += 1,
65                Severity::Mild => mild_count += 1,
66            }
67
68            // Track pattern statistics
69            self.update_pattern_stats(&issue.rule_name, &issue.severity, &file_path);
70        }
71
72        // Calculate shame score (weighted by severity)
73        let shame_score =
74            (nuclear_count as f64 * 10.0) + (spicy_count as f64 * 3.0) + (mild_count as f64 * 1.0);
75
76        let entry = ShameEntry {
77            file_path,
78            total_issues: issues.len(),
79            shame_score,
80        };
81
82        self.entries.push(entry);
83    }
84
85    fn update_pattern_stats(&mut self, rule_name: &str, severity: &Severity, file_path: &PathBuf) {
86        let stats = self
87            .pattern_stats
88            .entry(rule_name.to_string())
89            .or_insert_with(|| PatternStats {
90                rule_name: rule_name.to_string(),
91                count: 0,
92                severity_distribution: HashMap::new(),
93                example_files: Vec::new(),
94            });
95
96        stats.count += 1;
97        *stats
98            .severity_distribution
99            .entry(severity.clone())
100            .or_insert(0) += 1;
101
102        // Add file to examples if not already present and we have less than 5 examples
103        if stats.example_files.len() < 5 && !stats.example_files.contains(file_path) {
104            stats.example_files.push(file_path.clone());
105        }
106    }
107
108    pub fn generate_shame_report(&self) -> ProjectShameStats {
109        let mut sorted_entries = self.entries.clone();
110        sorted_entries.sort_by(|a, b| b.shame_score.partial_cmp(&a.shame_score).unwrap());
111
112        // Take top 10 worst files
113        let hall_of_shame = sorted_entries.into_iter().take(10).collect();
114
115        // Calculate garbage density (issues per 1000 lines)
116        let total_issues: usize = self.entries.iter().map(|e| e.total_issues).sum();
117        let garbage_density = if self.total_lines > 0 {
118            (total_issues as f64 / self.total_lines as f64) * 1000.0
119        } else {
120            0.0
121        };
122
123        ProjectShameStats {
124            total_files_analyzed: self.entries.len(),
125            total_issues,
126            garbage_density,
127            hall_of_shame,
128        }
129    }
130}
131
132impl Default for HallOfShame {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::analyzer::Severity;
142    use std::path::PathBuf;
143
144    fn make_issue(rule: &str, sev: Severity) -> CodeIssue {
145        CodeIssue {
146            file_path: PathBuf::from("test.rs"),
147            line: 1,
148            column: 1,
149            rule_name: rule.to_string(),
150            message: String::new(),
151            severity: sev,
152        }
153    }
154
155    /// Objective: Verify that adding a file with zero issues does NOT create a ShameEntry.
156    /// Invariants: entries count should stay 0; total_lines still accumulates.
157    #[test]
158    fn test_empty_issues_produces_no_entry() {
159        let mut h = HallOfShame::new();
160        h.add_file_analysis(PathBuf::from("foo.rs"), &[], 100);
161        assert!(
162            h.entries.is_empty(),
163            "no entry should be added when issues is empty, got {} entries",
164            h.entries.len()
165        );
166        assert_eq!(h.total_lines, 100, "total_lines should still accumulate");
167    }
168
169    /// Objective: Verify that shame score weights are correctly applied:
170    ///            Nuclear=10, Spicy=3, Mild=1 per issue.
171    /// Invariants: Score is strictly sum(weight * count_for_severity).
172    #[test]
173    fn test_shame_score_weights_per_severity() {
174        let mut h = HallOfShame::new();
175        let issues = vec![
176            make_issue("nuc", Severity::Nuclear),
177            make_issue("spi", Severity::Spicy),
178            make_issue("mid", Severity::Mild),
179            make_issue("nuc2", Severity::Nuclear),
180        ];
181        let file_path = PathBuf::from("bad.rs");
182        h.add_file_analysis(file_path, &issues, 100);
183        let score = h.entries[0].shame_score;
184        assert_eq!(score, 24.0, "expected 10*2 + 3 + 1 = 24, got {score}");
185    }
186
187    /// Objective: Verify that multiple files with different line counts
188    ///            correctly accumulate total_lines.
189    #[test]
190    fn test_multiple_files_accumulate_lines() {
191        let mut h = HallOfShame::new();
192        h.add_file_analysis(
193            PathBuf::from("a.rs"),
194            &[make_issue("x", Severity::Nuclear)],
195            30,
196        );
197        h.add_file_analysis(
198            PathBuf::from("b.rs"),
199            &[make_issue("y", Severity::Mild)],
200            70,
201        );
202        assert_eq!(h.total_lines, 100, "30 + 70 should = 100");
203    }
204
205    /// Objective: Verify pattern_stats tracks per-rule count accurately
206    ///            when the same rule fires in multiple files.
207    #[test]
208    fn test_pattern_stats_tracks_rule_count_across_files() {
209        let mut h = HallOfShame::new();
210        h.add_file_analysis(
211            PathBuf::from("a.rs"),
212            &[make_issue("unwrap-abuse", Severity::Nuclear)],
213            10,
214        );
215        h.add_file_analysis(
216            PathBuf::from("b.rs"),
217            &[make_issue("unwrap-abuse", Severity::Nuclear)],
218            20,
219        );
220        let stats = h
221            .pattern_stats
222            .get("unwrap-abuse")
223            .expect("unwrap-abuse should have been tracked");
224        assert_eq!(
225            stats.count, 2,
226            "same rule in 2 files should count 2, got {}",
227            stats.count
228        );
229    }
230
231    /// Objective: Verify that severity distribution within a pattern is correct.
232    /// Invariants: The count for each severity must match the exact number of issues at that severity.
233    #[test]
234    fn test_pattern_stats_tracks_severity_distribution() {
235        let mut h = HallOfShame::new();
236        let issues = vec![
237            make_issue("x", Severity::Nuclear),
238            make_issue("x", Severity::Nuclear),
239            make_issue("x", Severity::Mild),
240        ];
241        h.add_file_analysis(PathBuf::from("bad.rs"), &issues, 100);
242        let stats = h.pattern_stats.get("x").expect("rule 'x' should exist");
243        assert_eq!(
244            stats.severity_distribution.get(&Severity::Nuclear),
245            Some(&2),
246            "expected 2 nuclear issues"
247        );
248        assert_eq!(
249            stats.severity_distribution.get(&Severity::Mild),
250            Some(&1),
251            "expected 1 mild issue"
252        );
253        assert_eq!(
254            stats.severity_distribution.get(&Severity::Spicy),
255            None,
256            "expected 0 spicy issues"
257        );
258    }
259
260    /// Objective: Verify example_files cap at 5 unique files per pattern.
261    /// Invariants: After 10 different files with the same rule, only 5 examples are stored.
262    #[test]
263    fn test_pattern_stats_example_files_capped_at_five() {
264        let mut h = HallOfShame::new();
265        let issue = make_issue("dup", Severity::Nuclear);
266        for i in 0..10 {
267            let path = PathBuf::from(format!("file_{i}.rs"));
268            h.add_file_analysis(path, std::slice::from_ref(&issue), 10);
269        }
270        let stats = h.pattern_stats.get("dup").expect("rule 'dup' should exist");
271        assert_eq!(
272            stats.example_files.len(),
273            5,
274            "max example files should be 5, got {}",
275            stats.example_files.len()
276        );
277    }
278
279    /// Objective: Verify generate_shame_report sorts entries by score descending.
280    /// Invariants: The highest-scoring file is first in the list.
281    #[test]
282    fn test_report_sorted_by_score_descending() {
283        let mut h = HallOfShame::new();
284        h.add_file_analysis(
285            PathBuf::from("low.rs"),
286            &[make_issue("x", Severity::Mild)],
287            10,
288        );
289        h.add_file_analysis(
290            PathBuf::from("high.rs"),
291            &[make_issue("x", Severity::Nuclear)],
292            10,
293        );
294        let report = h.generate_shame_report();
295        assert_eq!(
296            report.hall_of_shame[0].shame_score, 10.0,
297            "highest score (10) should be first"
298        );
299        assert_eq!(
300            report.hall_of_shame[1].shame_score, 1.0,
301            "lowest score (1) should be second"
302        );
303    }
304
305    /// Objective: Verify the report caps at 10 entries even when more files exist.
306    #[test]
307    fn test_report_limited_to_ten_entries() {
308        let mut h = HallOfShame::new();
309        for i in 0..20 {
310            let f = format!("f{i}.rs");
311            h.add_file_analysis(PathBuf::from(f), &[make_issue("x", Severity::Nuclear)], 10);
312        }
313        let report = h.generate_shame_report();
314        assert_eq!(
315            report.hall_of_shame.len(),
316            10,
317            "should contain at most 10 entries, got {}",
318            report.hall_of_shame.len()
319        );
320    }
321
322    /// Objective: Verify garbage_density = (total_issues / total_lines) * 1000.
323    /// Invariants: Density scales linearly with issues and inversely with lines.
324    #[test]
325    fn test_garbage_density_formula_correct() {
326        let mut h = HallOfShame::new();
327        h.add_file_analysis(
328            PathBuf::from("a.rs"),
329            &[make_issue("x", Severity::Nuclear)],
330            500,
331        );
332        h.add_file_analysis(
333            PathBuf::from("b.rs"),
334            &[make_issue("y", Severity::Mild)],
335            500,
336        );
337        let report = h.generate_shame_report();
338        assert!(
339            (report.garbage_density - 2.0).abs() < 1e-6,
340            "2 issues / 1000 lines = 2.0 per 1k, got {}",
341            report.garbage_density
342        );
343    }
344
345    /// Objective: Verify zero total_lines does not cause NaN or crash.
346    /// Invariants: garbage_density is 0.0 when total_lines is 0.
347    #[test]
348    fn test_zero_total_lines_does_not_crash() {
349        let h = HallOfShame::new();
350        let report = h.generate_shame_report();
351        assert_eq!(
352            report.garbage_density, 0.0,
353            "density should be 0 when no files added, got {}",
354            report.garbage_density
355        );
356    }
357
358    /// Objective: Verify that the same file added multiple times creates
359    ///            multiple ShameEntries (the system does NOT deduplicate).
360    #[test]
361    fn test_duplicate_file_path_creates_multiple_entries() {
362        let mut h = HallOfShame::new();
363        let fp = PathBuf::from("same.rs");
364        h.add_file_analysis(fp.clone(), &[make_issue("a", Severity::Mild)], 10);
365        h.add_file_analysis(fp, &[make_issue("b", Severity::Mild)], 10);
366        assert_eq!(
367            h.entries.len(),
368            2,
369            "same path added twice should create 2 entries, got {}",
370            h.entries.len()
371        );
372    }
373
374    /// Objective: Verify that pattern_stats with mixed severity distributions
375    ///            correctly counts each severity bucket independently.
376    #[test]
377    fn test_pattern_stats_mixed_severities() {
378        let mut h = HallOfShame::new();
379        let issues = vec![
380            make_issue("mix", Severity::Nuclear),
381            make_issue("mix", Severity::Spicy),
382            make_issue("mix", Severity::Mild),
383            make_issue("mix", Severity::Nuclear),
384            make_issue("mix", Severity::Spicy),
385        ];
386        h.add_file_analysis(PathBuf::from("mix.rs"), &issues, 50);
387        let stats = h.pattern_stats.get("mix").expect("rule 'mix' should exist");
388        assert_eq!(
389            stats.count, 5,
390            "total 5 issues for 'mix', got {}",
391            stats.count
392        );
393        assert_eq!(
394            stats.severity_distribution.get(&Severity::Nuclear),
395            Some(&2),
396            "expected 2 nuclear"
397        );
398        assert_eq!(
399            stats.severity_distribution.get(&Severity::Spicy),
400            Some(&2),
401            "expected 2 spicy"
402        );
403        assert_eq!(
404            stats.severity_distribution.get(&Severity::Mild),
405            Some(&1),
406            "expected 1 mild"
407        );
408    }
409}