Skip to main content

garbage_code_hunter/reporter/
mod.rs

1mod autopsy;
2mod display;
3mod translations;
4
5use colored::*;
6use std::collections::{BTreeMap, HashMap};
7#[cfg(test)]
8use std::path::Path;
9
10use crate::analyzer::{CodeIssue, Severity};
11use crate::friend::FriendFeedback;
12use crate::i18n::I18n;
13use crate::llm::{RoastMap, RoastProvider};
14use crate::reporter::autopsy::SpreadTarget;
15use crate::scoring::{CodeQualityScore, CodeScorer};
16use crate::signals::StyleSignal;
17use crate::style_ir::StyleIrSummary;
18
19pub struct Reporter {
20    harsh_mode: bool,
21    verbose: bool,
22    max_issues_per_file: usize,
23    summary_only: bool,
24    brief: bool,
25    markdown: bool,
26    i18n: I18n,
27    roast_provider: Box<dyn RoastProvider>,
28    direct_scores: HashMap<StyleSignal, f64>,
29    style_ir_summary: Option<StyleIrSummary>,
30    show_friend_feedback: bool,
31}
32
33impl Reporter {
34    #[allow(clippy::too_many_arguments)]
35    pub fn new(
36        harsh_mode: bool,
37        verbose: bool,
38        max_issues_per_file: usize,
39        summary_only: bool,
40        brief: bool,
41        markdown: bool,
42        lang: &str,
43        roast_provider: Box<dyn RoastProvider>,
44    ) -> Self {
45        Self {
46            harsh_mode,
47            verbose,
48            max_issues_per_file,
49            summary_only,
50            brief,
51            markdown,
52            i18n: I18n::new(lang),
53            roast_provider,
54            direct_scores: HashMap::new(),
55            style_ir_summary: None,
56            show_friend_feedback: false,
57        }
58    }
59
60    pub fn with_friend_feedback(mut self, show: bool) -> Self {
61        self.show_friend_feedback = show;
62        self
63    }
64
65    pub fn with_direct_scores(mut self, scores: HashMap<StyleSignal, f64>) -> Self {
66        self.direct_scores = scores;
67        self
68    }
69
70    pub fn with_style_ir_summary(mut self, summary: Option<StyleIrSummary>) -> Self {
71        self.style_ir_summary = summary;
72        self
73    }
74
75    #[cfg(test)]
76    fn is_test_path(path: &Path) -> bool {
77        let name = path.to_string_lossy();
78        name.contains("/tests/")
79            || name.contains("/test/")
80            || name.ends_with("_test.rs")
81            || name.ends_with("_tests.rs")
82            || name.ends_with("_test.go")
83            || name.ends_with("_test.py")
84            || name.ends_with("_test.js")
85            || name.ends_with("_test.ts")
86            || name.ends_with("_test.java")
87            || name.starts_with("test_")
88            || name.contains("/test-files/")
89            || name.contains("/fixtures/")
90            || name.contains("/mocks/")
91            || name.contains("/examples/")
92            || name.contains("/benches/")
93    }
94
95    pub fn report_with_metrics(
96        &self,
97        issues: Vec<CodeIssue>,
98        file_count: usize,
99        total_lines: usize,
100    ) {
101        self.report_with_spread(issues, file_count, total_lines, &HashMap::new())
102    }
103
104    pub fn report_with_spread(
105        &self,
106        mut issues: Vec<CodeIssue>,
107        file_count: usize,
108        total_lines: usize,
109        spread: &HashMap<String, Vec<SpreadTarget>>,
110    ) {
111        // Calculate separate scores (merge issue-derived + direct signal scores)
112        let scorer = CodeScorer::new();
113        let combined_score = if self.direct_scores.is_empty() {
114            scorer.calculate_score(&issues, file_count, total_lines)
115        } else {
116            scorer.calculate_score_with_direct(
117                &issues,
118                file_count,
119                total_lines,
120                self.direct_scores.clone(),
121            )
122        };
123
124        if issues.is_empty() {
125            self.print_clean_code_message_with_score(&combined_score);
126            return;
127        }
128
129        // Sort by severity
130        issues.sort_by(|a, b| {
131            let severity_order = |s: &Severity| match s {
132                Severity::Nuclear => 3,
133                Severity::Spicy => 2,
134                Severity::Mild => 1,
135            };
136            severity_order(&b.severity).cmp(&severity_order(&a.severity))
137        });
138
139        // Harsh mode: only show the most severe issues
140        if self.harsh_mode {
141            issues.retain(|issue| matches!(issue.severity, Severity::Nuclear | Severity::Spicy));
142        }
143
144        // Generate roasts
145        let roasts = self
146            .roast_provider
147            .generate_roasts(&issues, &self.i18n.lang);
148
149        if self.markdown {
150            self.print_markdown_report(&issues, &roasts, &combined_score, spread);
151        } else {
152            if self.show_friend_feedback && !issues.is_empty() {
153                let feedback = FriendFeedback::new(
154                    &issues,
155                    &combined_score,
156                    &self.direct_scores,
157                    &self.i18n.lang,
158                );
159                feedback.print(&self.i18n.lang);
160            }
161
162            let (personality, autopsy) =
163                autopsy::analyze(&issues, &combined_score, file_count, spread);
164            let corruption_pct = combined_score.total_score;
165
166            if !self.summary_only {
167                self.print_header();
168                self.print_personality(&personality, &combined_score, corruption_pct);
169                self.print_autopsy(&autopsy);
170                if !self.brief {
171                    self.print_boss_file(&issues);
172                }
173                self.print_behavior_distribution(&combined_score);
174
175                if self.verbose {
176                    self.print_symptoms(&issues);
177                }
178            }
179            self.print_final_summary(&combined_score, file_count, Some(personality.project_type));
180            self.print_style_ir_summary();
181        }
182    }
183
184    fn print_clean_code_message_with_score(&self, quality_score: &CodeQualityScore) {
185        if self.markdown {
186            println!("# {}", self.i18n.get("title"));
187            println!();
188            println!("## ๐Ÿ† ไปฃ็ ่ดจ้‡่ฏ„ๅˆ†");
189            println!();
190            println!(
191                "**่ฏ„ๅˆ†**: {:.1}/100 {}",
192                quality_score.total_score,
193                quality_score.quality_level.emoji()
194            );
195            println!(
196                "**็ญ‰็บง**: {}",
197                quality_score.quality_level.description(&self.i18n.lang)
198            );
199            println!();
200            println!("{}", self.i18n.get("clean_code"));
201            println!();
202            println!("{}", self.i18n.get("clean_code_warning"));
203        } else {
204            println!("{}", self.i18n.get("clean_code").bright_green().bold());
205            println!();
206            println!(
207                "{} ไปฃ็ ่ดจ้‡่ฏ„ๅˆ†: {:.1}/100 {}",
208                "๐Ÿ†".bright_yellow(),
209                quality_score.total_score.to_string().bright_green().bold(),
210                quality_score.quality_level.emoji()
211            );
212            println!(
213                "{} ่ดจ้‡็ญ‰็บง: {}",
214                "๐Ÿ“Š".bright_blue(),
215                quality_score
216                    .quality_level
217                    .description(&self.i18n.lang)
218                    .bright_green()
219                    .bold()
220            );
221            println!("{}", self.i18n.get("clean_code_warning").yellow());
222        }
223    }
224
225    fn print_markdown_report(
226        &self,
227        issues: &[CodeIssue],
228        roasts: &RoastMap,
229        combined_score: &CodeQualityScore,
230        spread: &HashMap<String, Vec<SpreadTarget>>,
231    ) {
232        let total = issues.len();
233        let nuclear = issues
234            .iter()
235            .filter(|i| matches!(i.severity, Severity::Nuclear))
236            .count();
237        let spicy = issues
238            .iter()
239            .filter(|i| matches!(i.severity, Severity::Spicy))
240            .count();
241        let mild = issues
242            .iter()
243            .filter(|i| matches!(i.severity, Severity::Mild))
244            .count();
245
246        println!("# {}", self.i18n.get("title"));
247        println!();
248        println!(
249            "**Score:** {:.1}/100 **{}**",
250            combined_score.total_score,
251            combined_score.quality_level.description(&self.i18n.lang)
252        );
253        println!();
254
255        // โ”€โ”€ Friend Feedback โ”€โ”€
256        if self.show_friend_feedback {
257            let is_zh = self.i18n.lang.starts_with("zh");
258            let feedback =
259                FriendFeedback::new(issues, combined_score, &self.direct_scores, &self.i18n.lang);
260            if is_zh {
261                println!("## ๐Ÿ’ฌ ๆœ‹ๅ‹็š„็œ‹ๆณ•");
262            } else {
263                println!("## ๐Ÿ’ฌ Friend's Take");
264            }
265            println!();
266            if is_zh {
267                println!(
268                    "**ๅฟƒๆƒ…:** {} {}",
269                    feedback.mood.emoji(),
270                    feedback.mood.vibe(&self.i18n.lang)
271                );
272            } else {
273                println!(
274                    "**Mood:** {} {}",
275                    feedback.mood.emoji(),
276                    feedback.mood.vibe(&self.i18n.lang)
277                );
278            }
279            if !feedback.patterns.is_empty() {
280                println!();
281                if is_zh {
282                    println!("**ๅ‘็Žฐ็š„้—ฎ้ข˜ๆจกๅผ:**");
283                } else {
284                    println!("**Patterns I noticed:**");
285                }
286                for p in &feedback.patterns {
287                    let sev = match p.severity {
288                        "major" | "ไธฅ้‡" => "๐Ÿ”ด",
289                        "moderate" | "ไธญ็ญ‰" => "๐ŸŸก",
290                        _ => "๐Ÿ”ต",
291                    };
292                    println!("- {} {} โ€” {}", sev, p.description, p.suggestion);
293                }
294            }
295            if !feedback.next_actions.is_empty() {
296                println!();
297                if is_zh {
298                    println!("**ๅฟซ้€Ÿไฟฎๅค (ๅ‰ 3 ้กน):**");
299                } else {
300                    println!("**Quick wins (top 3):**");
301                }
302                for a in &feedback.next_actions {
303                    println!(
304                        "- `{}:{}` โ€” {} _( {} )_",
305                        a.file, a.line, a.action, a.reason
306                    );
307                }
308            }
309            println!();
310        }
311
312        // โ”€โ”€ Personality โ”€โ”€
313        let (personality, autopsy) = autopsy::analyze(issues, combined_score, issues.len(), spread);
314        println!("## {} Personality", personality.emoji);
315        println!();
316        println!("**Type:** {}", personality.project_type);
317        println!("**Threat Level:** {}", personality.threat_level);
318        println!();
319        println!("**Signature traits:**");
320        for trait_text in &personality.core_traits {
321            println!("- {}", trait_text);
322        }
323        println!();
324        println!("**Diagnosis:** {}", autopsy.cause_of_death);
325        println!("**Condition:** {}", autopsy.patient_condition);
326        println!();
327
328        // โ”€โ”€ Statistics โ”€โ”€
329        println!("## {}", self.i18n.get("statistics"));
330        println!();
331        println!("| Severity | Count |");
332        println!("| --- | --- |");
333        println!("| ๐Ÿ”ฅ Nuclear | {} |", nuclear);
334        println!("| ๐ŸŒถ๏ธ Spicy | {} |", spicy);
335        println!("| ๐Ÿ˜ Mild | {} |", mild);
336        println!("| **Total** | **{}** |", total);
337        println!();
338
339        // โ”€โ”€ Behavior Distribution โ”€โ”€
340        if !combined_score.signal_scores.is_empty() {
341            println!("## Signal Distribution");
342            println!();
343            let mut signals: Vec<_> = combined_score.signal_scores.iter().collect();
344            signals.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
345            println!("| Signal | Score | Severity |");
346            println!("| --- | --- | --- |");
347            for (signal, &score) in signals {
348                let label = if score >= 12.0 {
349                    "๐Ÿ”ด High"
350                } else if score >= 6.0 {
351                    "๐ŸŸก Medium"
352                } else {
353                    "๐ŸŸข Low"
354                };
355                println!("| {} | {:.1} | {} |", signal.display_name(), score, label);
356            }
357            println!();
358        }
359
360        // โ”€โ”€ Detailed Analysis โ”€โ”€
361        if self.verbose {
362            println!("## {}", self.i18n.get("detailed_analysis"));
363            println!();
364            let mut rule_stats: HashMap<String, usize> = HashMap::new();
365            for issue in issues {
366                *rule_stats.entry(issue.rule_name.clone()).or_insert(0) += 1;
367            }
368            for (rule_name, count) in rule_stats {
369                println!("- **{}**: {} issues", rule_name, count);
370            }
371            println!();
372        }
373
374        // โ”€โ”€ Issues by File โ”€โ”€
375        println!("## Issues by File");
376        println!();
377        let mut file_groups: BTreeMap<String, Vec<&CodeIssue>> = BTreeMap::new();
378        for issue in issues {
379            let file_name = issue
380                .file_path
381                .file_name()
382                .unwrap_or_default()
383                .to_string_lossy()
384                .to_string();
385            file_groups.entry(file_name).or_default().push(issue);
386        }
387        for (file_name, file_issues) in file_groups {
388            println!("### ๐Ÿ“ {}", file_name);
389            println!();
390            let issues_to_show = if self.max_issues_per_file > 0 {
391                file_issues
392                    .into_iter()
393                    .take(self.max_issues_per_file)
394                    .collect::<Vec<_>>()
395            } else {
396                file_issues
397            };
398            for issue in issues_to_show {
399                let severity_icon = match issue.severity {
400                    Severity::Nuclear => "๐Ÿ’ฅ",
401                    Severity::Spicy => "๐ŸŒถ๏ธ",
402                    Severity::Mild => "๐Ÿ˜",
403                };
404                let key = format!(
405                    "{}:{}:{}",
406                    issue.file_path.display(),
407                    issue.line,
408                    issue.rule_name
409                );
410                let message = roasts
411                    .get(&key)
412                    .cloned()
413                    .unwrap_or_else(|| issue.message.clone());
414                println!(
415                    "- {} **Line {}:{}** - {}",
416                    severity_icon, issue.line, issue.column, message
417                );
418            }
419            println!();
420        }
421
422        // โ”€โ”€ StyleIr Summary โ”€โ”€
423        if let Some(ref sir) = self.style_ir_summary {
424            println!("## Code Metrics");
425            println!();
426            println!("| Metric | Value |");
427            println!("| --- | --- |");
428            println!("| Lines | {} |", sir.line_count);
429            println!("| Panic Calls | {} |", sir.panic_call_count);
430            println!("| Naming Violations | {} |", sir.naming_violation_count);
431            println!("| Deep Nesting | {} |", sir.deeply_nested_block_count);
432            println!("| Debug Calls | {} |", sir.debug_call_count);
433            println!("| God Functions | {} |", sir.god_function_count);
434            println!("| Unsafe Blocks | {} |", sir.unsafe_block_count);
435            println!("| Magic Numbers | {} |", sir.magic_number_count);
436            println!();
437        }
438
439        println!("## {}", self.i18n.get("suggestions"));
440        println!();
441        println!();
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    /// Objective: Verify is_test_path returns true for all supported test directory patterns.
450    /// Invariants: /tests/, /test/, /test-files/, /fixtures/, /mocks/, /examples/, /benches/
451    ///             are all recognized as test paths regardless of file extension.
452    #[test]
453    fn test_is_test_path_detects_test_directories() {
454        assert!(
455            Reporter::is_test_path(Path::new("/project/src/tests/helper.rs")),
456            "/tests/ should be detected"
457        );
458        assert!(
459            Reporter::is_test_path(Path::new("/project/tests/fixtures/data.json")),
460            "/fixtures/ should be detected"
461        );
462        assert!(
463            Reporter::is_test_path(Path::new("/project/tests/mocks/service.rs")),
464            "/mocks/ should be detected"
465        );
466        assert!(
467            Reporter::is_test_path(Path::new("/project/examples/demo.rs")),
468            "/examples/ should be detected"
469        );
470        assert!(
471            Reporter::is_test_path(Path::new("/project/benches/perf.rs")),
472            "/benches/ should be detected"
473        );
474    }
475
476    /// Objective: Verify is_test_path detects files ending with _test.{rs,go,py,js,ts,java}.
477    /// Invariants: Each supported language suffix must be recognized independently.
478    #[test]
479    fn test_is_test_path_detects_all_language_test_suffixes() {
480        assert!(
481            Reporter::is_test_path(Path::new("/project/src/foo_test.rs")),
482            "_test.rs should be test"
483        );
484        assert!(
485            Reporter::is_test_path(Path::new("/project/src/handler_test.go")),
486            "_test.go should be test"
487        );
488        assert!(
489            Reporter::is_test_path(Path::new("/project/src/util_test.py")),
490            "_test.py should be test"
491        );
492        assert!(
493            Reporter::is_test_path(Path::new("/project/src/app_test.js")),
494            "_test.js should be test"
495        );
496        assert!(
497            Reporter::is_test_path(Path::new("/project/src/app_test.ts")),
498            "_test.ts should be test"
499        );
500        assert!(
501            Reporter::is_test_path(Path::new("/project/src/Foo_test.java")),
502            "_test.java should be test"
503        );
504    }
505
506    /// Objective: Verify is_test_path detects files starting with "test_" at the root.
507    /// Invariants: `name.starts_with("test_")` only matches when the filename is the
508    ///             entire path (e.g., "test_main.py"), not when embedded in a directory.
509    #[test]
510    fn test_is_test_path_detects_test_prefix_at_root() {
511        assert!(
512            Reporter::is_test_path(Path::new("test_main.py")),
513            "bare filename starting with test_ should be test"
514        );
515    }
516
517    /// Objective: Verify is_test_path returns false for normal production source files.
518    /// Invariants: Source files in any language under src/ (or similar) should not match.
519    #[test]
520    fn test_is_test_path_does_not_flag_production_code() {
521        assert!(
522            !Reporter::is_test_path(Path::new("/project/src/main.rs")),
523            "src/main.rs should not be test"
524        );
525        assert!(
526            !Reporter::is_test_path(Path::new("/project/src/lib.rs")),
527            "src/lib.rs should not be test"
528        );
529        assert!(
530            !Reporter::is_test_path(Path::new("/project/src/server.go")),
531            "src/server.go should not be test"
532        );
533    }
534
535    /// Objective: Verify Reporter construction normalizes "en" to "en-US" correctly.
536    #[test]
537    fn test_reporter_creates_with_english_i18n() {
538        let reporter = Reporter::new(
539            false,
540            false,
541            5,
542            false,
543            false,
544            false,
545            "en",
546            Box::new(crate::llm::LocalRoastProvider),
547        );
548        assert_eq!(
549            reporter.i18n.lang, "en-US",
550            "Reporter::new with 'en' should normalize to 'en-US', got '{}'",
551            reporter.i18n.lang
552        );
553    }
554
555    /// Objective: Verify Reporter construction preserves "zh-CN" correctly.
556    #[test]
557    fn test_reporter_creates_with_chinese_i18n() {
558        let reporter = Reporter::new(
559            false,
560            false,
561            5,
562            false,
563            false,
564            false,
565            "zh-CN",
566            Box::new(crate::llm::LocalRoastProvider),
567        );
568        assert_eq!(
569            reporter.i18n.lang, "zh-CN",
570            "Reporter::new with 'zh-CN' should keep 'zh-CN', got '{}'",
571            reporter.i18n.lang
572        );
573    }
574}