Skip to main content

garbage_code_hunter/commit_roaster/
report.rs

1//! Report generation for commit-roaster.
2//!
3//! Generates formatted output with issues, stats, and roasts.
4
5use 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/// Aggregated statistics about analyzed commits.
13#[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/// A commit paired with its detected issues.
25#[derive(Debug, Clone)]
26pub struct ScoredCommit {
27    pub commit: CommitInfo,
28    pub issues: Vec<Issue>,
29}
30
31/// Full analysis report.
32#[derive(Debug, Clone)]
33pub struct Report {
34    pub scored_commits: Vec<ScoredCommit>,
35    pub stats: CommitStats,
36    pub total_score: f64,
37}
38
39/// Build a report from scored commits.
40pub 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    // Score: start at 100, subtract penalties
75    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
99/// Format report as colored terminal output.
100pub fn format_terminal(report: &Report) -> String {
101    let mut out = String::new();
102
103    // Header
104    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    // Group issues by severity
116    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                    .unwrap()
129            })
130            .unwrap();
131        by_severity
132            .entry(max_sev.severity.label())
133            .or_default()
134            .push(sc);
135    }
136
137    // Print by severity
138    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    // Stats
181    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    // Score
204    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
215/// Format report as JSON.
216pub 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}