1use crate::analyzer::{CodeIssue, Severity};
2use 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, pub hall_of_shame: Vec<ShameEntry>, }
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 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 self.update_pattern_stats(&issue.rule_name, &issue.severity, &file_path);
70 }
71
72 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 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 let hall_of_shame = sorted_entries.into_iter().take(10).collect();
114
115 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}