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 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 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 if self.harsh_mode {
141 issues.retain(|issue| matches!(issue.severity, Severity::Nuclear | Severity::Spicy));
142 }
143
144 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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}