Skip to main content

garbage_code_hunter/pr_title_hunter/
report.rs

1//! Report generation for PR title analysis.
2
3use super::types::{PrEntry, PrIssue, Severity};
4use colored::Colorize;
5
6/// Build statistics from PR analysis.
7pub 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
58/// Format analysis report for terminal output.
59pub 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    // Group issues by severity and show them
85    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    // Worst PRs (sorted by issue count)
127    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    // Statistics
152    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    // Score
164    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
177/// Format analysis report as JSON.
178pub 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}