garbage_code_hunter/pr_title_hunter/
report.rs1use super::types::{PrEntry, PrIssue, Severity};
4use colored::Colorize;
5
6pub struct PrStats {
8 pub total_prs: usize,
9 pub issues_found: usize,
10 pub critical_count: usize,
11 pub high_count: usize,
12 pub medium_count: usize,
13 pub low_count: usize,
14 pub avg_title_length: f64,
15 pub score: f64,
16}
17
18pub fn build_stats(prs: &[PrEntry], issues: &[PrIssue]) -> PrStats {
19 let total_prs = prs.len();
20 let issues_found = issues.len();
21
22 let mut critical_count = 0;
23 let mut high_count = 0;
24 let mut medium_count = 0;
25 let mut low_count = 0;
26
27 for issue in issues {
28 match issue.severity {
29 Severity::Critical => critical_count += 1,
30 Severity::High => high_count += 1,
31 Severity::Medium => medium_count += 1,
32 Severity::Low => low_count += 1,
33 Severity::Info => {}
34 }
35 }
36
37 let avg_title_length = if total_prs > 0 {
38 prs.iter().map(|p| p.title.len()).sum::<usize>() as f64 / total_prs as f64
39 } else {
40 0.0
41 };
42
43 let penalty: f64 = issues.iter().map(|i| i.severity.penalty()).sum();
44 let score = (100.0 - penalty).max(0.0);
45
46 PrStats {
47 total_prs,
48 issues_found,
49 critical_count,
50 high_count,
51 medium_count,
52 low_count,
53 avg_title_length,
54 score,
55 }
56}
57
58pub fn format_terminal(prs: &[PrEntry], issues: &[PrIssue]) -> String {
60 let stats = build_stats(prs, issues);
61 let mut out = String::new();
62
63 out.push_str(&format!(
64 "\n{}\n",
65 "\u{1f3af} PR Title Roast Report \u{1f3af}".bold()
66 ));
67 out.push_str(&format!("{}\n\n", "\u{2501}".repeat(40)));
68
69 out.push_str(&format!(
70 " Checked {} PRs\n\n",
71 stats.total_prs.to_string().cyan()
72 ));
73
74 if issues.is_empty() {
75 out.push_str(&format!(
76 "{}\n",
77 "\u{2728} All PR titles look good! No issues found."
78 .green()
79 .bold()
80 ));
81 return out;
82 }
83
84 let mut by_severity: Vec<(&Severity, &PrIssue)> =
86 issues.iter().map(|i| (&i.severity, i)).collect();
87 by_severity.sort_by(|a, b| a.0.cmp(b.0));
88
89 let severity_groups = [
90 (Severity::Critical, "Critical"),
91 (Severity::High, "High"),
92 (Severity::Medium, "Medium"),
93 (Severity::Low, "Low"),
94 (Severity::Info, "Info"),
95 ];
96
97 for (sev, label) in &severity_groups {
98 let group: Vec<_> = by_severity.iter().filter(|(s, _)| **s == *sev).collect();
99 if group.is_empty() {
100 continue;
101 }
102
103 let header = match sev {
104 Severity::Critical => {
105 format!("{} {} ({})", sev.emoji(), label.red().bold(), group.len())
106 }
107 Severity::High => format!("{} {} ({})", sev.emoji(), label.red(), group.len()),
108 Severity::Medium => format!("{} {} ({})", sev.emoji(), label.yellow(), group.len()),
109 Severity::Low => format!("{} {} ({})", sev.emoji(), label.blue(), group.len()),
110 Severity::Info => format!("{} {} ({})", sev.emoji(), label.dimmed(), group.len()),
111 };
112 out.push_str(&format!("{}\n", header));
113
114 for (_, issue) in &group {
115 out.push_str(&format!(
116 " {} #{}: \"{}\"\n",
117 "\u{2022}",
118 issue.pr_id.cyan(),
119 issue.pr_title
120 ));
121 out.push_str(&format!(" {}\n", issue.message.dimmed()));
122 }
123 out.push('\n');
124 }
125
126 let mut pr_issue_counts: std::collections::HashMap<&str, Vec<&PrIssue>> =
128 std::collections::HashMap::new();
129 for issue in issues {
130 pr_issue_counts.entry(&issue.pr_id).or_default().push(issue);
131 }
132 let mut worst: Vec<_> = pr_issue_counts.iter().collect();
133 worst.sort_by_key(|b| std::cmp::Reverse(b.1.len()));
134
135 if !worst.is_empty() {
136 out.push_str(&format!("{}\n", "\u{1f3c6} Worst PR Titles".bold()));
137 out.push_str(&format!("{}\n", "\u{2500}".repeat(30)));
138 for (i, (pr_id, pr_issues)) in worst.iter().take(5).enumerate() {
139 let title = pr_issues.first().map(|i| i.pr_title.as_str()).unwrap_or("");
140 out.push_str(&format!(
141 " {}. #{}: \"{}\" — {} issues\n",
142 i + 1,
143 pr_id.cyan(),
144 title,
145 pr_issues.len().to_string().red()
146 ));
147 }
148 out.push('\n');
149 }
150
151 out.push_str(&format!("{}\n", "\u{1f4ca} Statistics".bold()));
153 out.push_str(&format!("{}\n", "\u{2500}".repeat(30)));
154 out.push_str(&format!(
155 " Average title length: {:.0} chars\n",
156 stats.avg_title_length
157 ));
158 out.push_str(&format!(
159 " Issues found: {}\n",
160 stats.issues_found.to_string().red()
161 ));
162
163 out.push('\n');
165 let score_str = if stats.score >= 80.0 {
166 format!("{:.0}/100", stats.score).green().bold()
167 } else if stats.score >= 60.0 {
168 format!("{:.0}/100", stats.score).yellow().bold()
169 } else {
170 format!("{:.0}/100", stats.score).red().bold()
171 };
172 out.push_str(&format!("{} PR Title Health: {}\n", "\u{1f3af}", score_str));
173
174 out
175}
176
177pub fn format_json(prs: &[PrEntry], issues: &[PrIssue]) -> String {
179 let stats = build_stats(prs, issues);
180 let output = serde_json::json!({
181 "score": stats.score,
182 "total_prs": stats.total_prs,
183 "issues": issues.iter().map(|i| {
184 serde_json::json!({
185 "rule_id": i.rule_id,
186 "severity": format!("{:?}", i.severity),
187 "message": i.message,
188 "pr_id": i.pr_id,
189 "pr_title": i.pr_title,
190 })
191 }).collect::<Vec<_>>(),
192 "stats": {
193 "avg_title_length": stats.avg_title_length,
194 "issues_found": stats.issues_found,
195 }
196 });
197
198 serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
199}
200
201#[cfg(test)]
202mod tests {
203 use super::super::types::PrSource;
204 use super::*;
205
206 fn make_pr(id: &str, title: &str) -> PrEntry {
207 PrEntry {
208 id: id.to_string(),
209 title: title.to_string(),
210 author: None,
211 source: PrSource::Local,
212 }
213 }
214
215 fn make_issue(pr_id: &str, title: &str, rule_id: &str, severity: Severity) -> PrIssue {
216 PrIssue {
217 rule_id: rule_id.to_string(),
218 severity,
219 message: "test issue".to_string(),
220 pr_id: pr_id.to_string(),
221 pr_title: title.to_string(),
222 }
223 }
224
225 #[test]
226 fn test_build_stats_counts() {
227 let prs = vec![make_pr("1", "title"), make_pr("2", "title")];
228 let issues = vec![
229 make_issue("1", "title", "test", Severity::High),
230 make_issue("2", "title", "test", Severity::Low),
231 ];
232 let stats = build_stats(&prs, &issues);
233 assert_eq!(stats.total_prs, 2);
234 assert_eq!(stats.issues_found, 2);
235 assert_eq!(stats.high_count, 1);
236 assert_eq!(stats.low_count, 1);
237 }
238
239 #[test]
240 fn test_format_terminal_empty() {
241 let prs = vec![make_pr("1", "feat: good title")];
242 let output = format_terminal(&prs, &[]);
243 assert!(output.contains("All PR titles look good"));
244 }
245
246 #[test]
247 fn test_format_terminal_with_issues() {
248 let prs = vec![make_pr("1", "fix")];
249 let issues = vec![make_issue("1", "fix", "generic-title", Severity::High)];
250 let output = format_terminal(&prs, &issues);
251 assert!(output.contains("High"));
252 assert!(output.contains("fix"));
253 }
254
255 #[test]
256 fn test_format_json_valid() {
257 let prs = vec![make_pr("1", "title")];
258 let issues = vec![make_issue("1", "title", "test", Severity::Medium)];
259 let json = format_json(&prs, &issues);
260 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
261 assert!(parsed["score"].as_f64().is_some());
262 }
263}