garbage_code_hunter/commit_roaster/
report.rs1use crate::commit_roaster::analyzer::CommitInfo;
6use crate::commit_roaster::rules::Issue;
7use crate::common::score_to_grade;
8use colored::Colorize;
9use serde::Serialize;
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, Serialize)]
14pub struct CommitStats {
15 pub total_commits: usize,
16 pub total_issues: usize,
17 pub avg_message_length: f64,
18 pub empty_messages: usize,
19 pub single_word_messages: usize,
20 pub wip_commits: usize,
21 pub fix_only_commits: usize,
22}
23
24#[derive(Debug, Clone)]
26pub struct ScoredCommit {
27 pub commit: CommitInfo,
28 pub issues: Vec<Issue>,
29}
30
31#[derive(Debug, Clone)]
33pub struct Report {
34 pub scored_commits: Vec<ScoredCommit>,
35 pub stats: CommitStats,
36 pub total_score: f64,
37}
38
39pub fn build_report(scored_commits: Vec<ScoredCommit>) -> Report {
41 let total_commits = scored_commits.len();
42 let mut total_issues = 0;
43 let mut total_msg_len = 0usize;
44 let mut empty_messages = 0usize;
45 let mut single_word_messages = 0usize;
46 let mut wip_commits = 0usize;
47 let mut fix_only_commits = 0usize;
48
49 for sc in &scored_commits {
50 total_issues += sc.issues.len();
51 let msg = sc.commit.message.trim();
52 total_msg_len += msg.len();
53
54 if msg.is_empty() {
55 empty_messages += 1;
56 }
57 if msg.split_whitespace().count() == 1 && !msg.is_empty() {
58 single_word_messages += 1;
59 }
60 if msg.to_uppercase().starts_with("WIP") {
61 wip_commits += 1;
62 }
63 if msg.to_lowercase() == "fix" {
64 fix_only_commits += 1;
65 }
66 }
67
68 let avg_message_length = if total_commits > 0 {
69 total_msg_len as f64 / total_commits as f64
70 } else {
71 0.0
72 };
73
74 let penalty: f64 = scored_commits
76 .iter()
77 .flat_map(|sc| &sc.issues)
78 .map(|i| i.severity.weight())
79 .sum();
80 let total_score = (100.0 - penalty).max(0.0);
81
82 let stats = CommitStats {
83 total_commits,
84 total_issues,
85 avg_message_length,
86 empty_messages,
87 single_word_messages,
88 wip_commits,
89 fix_only_commits,
90 };
91
92 Report {
93 scored_commits,
94 stats,
95 total_score,
96 }
97}
98
99pub fn format_terminal(report: &Report) -> String {
101 let mut out = String::new();
102
103 out.push_str(&format!(
105 "\n{}\n",
106 "\u{1f525} Commit Roast Report \u{1f525}".bold().red()
107 ));
108 out.push_str(&format!("{}\n", "\u{2501}".repeat(40)));
109 out.push_str(&format!(
110 "Scanned {} commits, found {} issues\n\n",
111 report.stats.total_commits.to_string().yellow(),
112 report.stats.total_issues.to_string().red()
113 ));
114
115 let mut by_severity: HashMap<&str, Vec<&ScoredCommit>> = HashMap::new();
117 for sc in &report.scored_commits {
118 if sc.issues.is_empty() {
119 continue;
120 }
121 let max_sev = sc
122 .issues
123 .iter()
124 .max_by(|a, b| {
125 a.severity
126 .weight()
127 .partial_cmp(&b.severity.weight())
128 .expect("f64 partial_cmp is always Some for non-NaN severity weights")
129 })
130 .expect("sc.issues is non-empty: guarded by is_empty check above");
131 by_severity
132 .entry(max_sev.severity.label())
133 .or_default()
134 .push(sc);
135 }
136
137 let order = ["Critical", "High", "Medium", "Low", "Info"];
139 for sev_label in &order {
140 if let Some(commits) = by_severity.get(*sev_label) {
141 let emoji = match *sev_label {
142 "Critical" => "\u{1f480}",
143 "High" => "\u{1f621}",
144 "Medium" => "\u{26a0}\u{fe0f}",
145 "Low" => "\u{1f4a7}",
146 "Info" => "\u{2139}\u{fe0f}",
147 _ => "",
148 };
149 out.push_str(&format!(
150 "{} {} ({})\n",
151 emoji,
152 sev_label,
153 commits.len().to_string().yellow()
154 ));
155 for sc in commits.iter().take(5) {
156 let msg_display = if sc.commit.message.trim().is_empty() {
157 "<empty>".to_string()
158 } else {
159 truncate(sc.commit.message.trim(), 60)
160 };
161 for issue in &sc.issues {
162 out.push_str(&format!(
163 " \u{2022} {} \"{}\" \u{2014} {}\n",
164 sc.commit.short_hash.cyan(),
165 msg_display.dimmed(),
166 issue.message
167 ));
168 }
169 }
170 if commits.len() > 5 {
171 out.push_str(&format!(
172 " ... and {} more\n",
173 (commits.len() - 5).to_string().dimmed()
174 ));
175 }
176 out.push('\n');
177 }
178 }
179
180 out.push_str(&format!("{}\n", "\u{1f4ca} Statistics".bold()));
182 out.push_str(&format!(
183 " Avg message length: {} chars\n",
184 format!("{:.1}", report.stats.avg_message_length).yellow()
185 ));
186 out.push_str(&format!(
187 " Empty messages: {}\n",
188 report.stats.empty_messages.to_string().red()
189 ));
190 out.push_str(&format!(
191 " Single-word messages: {}\n",
192 report.stats.single_word_messages.to_string().red()
193 ));
194 out.push_str(&format!(
195 " WIP commits: {}\n",
196 report.stats.wip_commits.to_string().yellow()
197 ));
198 out.push_str(&format!(
199 " 'fix' only commits: {}\n\n",
200 report.stats.fix_only_commits.to_string().red()
201 ));
202
203 let grade = score_to_grade(report.total_score);
205 out.push_str(&format!(
206 "{} {}/100 ({})\n",
207 "\u{1f3c6} Score".bold(),
208 format!("{:.0}", report.total_score).bold(),
209 grade
210 ));
211
212 out
213}
214
215pub fn format_json(report: &Report) -> Result<String, serde_json::Error> {
217 #[derive(Serialize)]
218 struct JsonReport {
219 total_commits: usize,
220 total_issues: usize,
221 score: f64,
222 grade: String,
223 stats: CommitStats,
224 issues: Vec<JsonIssue>,
225 }
226
227 #[derive(Serialize)]
228 struct JsonIssue {
229 hash: String,
230 author: String,
231 message: String,
232 rule_id: String,
233 severity: String,
234 roast: String,
235 }
236
237 let json_report = JsonReport {
238 total_commits: report.stats.total_commits,
239 total_issues: report.stats.total_issues,
240 score: report.total_score,
241 grade: score_to_grade(report.total_score),
242 stats: report.stats.clone(),
243 issues: report
244 .scored_commits
245 .iter()
246 .flat_map(|sc| {
247 sc.issues.iter().map(move |issue| JsonIssue {
248 hash: sc.commit.short_hash.clone(),
249 author: sc.commit.author.clone(),
250 message: sc.commit.message.trim().to_string(),
251 rule_id: issue.rule_id.clone(),
252 severity: issue.severity.label().to_string(),
253 roast: issue.message.clone(),
254 })
255 })
256 .collect(),
257 };
258
259 serde_json::to_string_pretty(&json_report)
260}
261
262fn truncate(s: &str, max: usize) -> String {
263 crate::utils::truncate(s, max)
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::common::Severity;
270
271 fn make_commit(hash: &str, message: &str) -> CommitInfo {
272 CommitInfo {
273 hash: hash.to_string(),
274 short_hash: hash[..7].to_string(),
275 author: "test".to_string(),
276 message: message.to_string(),
277 timestamp: 0,
278 files_changed: 1,
279 insertions: 10,
280 deletions: 5,
281 }
282 }
283
284 fn make_issue(rule_id: &str, severity: Severity) -> Issue {
285 Issue {
286 rule_id: rule_id.to_string(),
287 rule_name: "test".to_string(),
288 severity,
289 message: "test roast".to_string(),
290 }
291 }
292
293 #[test]
294 fn test_build_report_empty() {
295 let report = build_report(vec![]);
296 assert_eq!(report.stats.total_commits, 0);
297 assert_eq!(report.total_score, 100.0);
298 }
299
300 #[test]
301 fn test_build_report_with_issues() {
302 let scored = vec![
303 ScoredCommit {
304 commit: make_commit("abc1234", "fix"),
305 issues: vec![make_issue("fix-only", Severity::High)],
306 },
307 ScoredCommit {
308 commit: make_commit("def5678", "good commit message here"),
309 issues: vec![],
310 },
311 ];
312 let report = build_report(scored);
313 assert_eq!(report.stats.total_commits, 2);
314 assert_eq!(report.stats.total_issues, 1);
315 assert!(report.total_score < 100.0);
316 }
317
318 #[test]
319 fn test_format_json_valid() {
320 let scored = vec![ScoredCommit {
321 commit: make_commit("abc1234", "fix"),
322 issues: vec![make_issue("fix-only", Severity::High)],
323 }];
324 let report = build_report(scored);
325 let json = format_json(&report).expect("JSON should serialize");
326 let parsed: serde_json::Value = serde_json::from_str(&json).expect("Valid JSON");
327 assert!(parsed.get("score").is_some());
328 assert!(parsed.get("issues").is_some());
329 }
330
331 #[test]
332 fn test_truncate_short_string() {
333 assert_eq!(truncate("hello", 10), "hello");
334 }
335
336 #[test]
337 fn test_truncate_long_string() {
338 assert_eq!(truncate("hello world foo bar", 10), "hello w...");
339 }
340}