Skip to main content

garbage_code_hunter/team_roast/
mod.rs

1//! Team Roast Mode — per-developer analysis and roasting.
2
3use crate::common::i18n_ext::t;
4use crate::common::OutputFormat;
5use anyhow::Result;
6use colored::Colorize;
7use std::collections::HashMap;
8use std::path::Path;
9
10/// Stats for a single team member.
11#[derive(Debug, Clone)]
12pub struct MemberStats {
13    pub name: String,
14    pub commit_count: usize,
15    pub fix_commits: usize,
16    pub avg_message_length: f64,
17    pub worst_message: String,
18    pub roast: String,
19}
20
21/// Run team analysis.
22pub fn run(path: &Path, format: &OutputFormat, limit: usize, lang: &str) -> Result<String> {
23    let stats = analyze_team(path, limit)?;
24
25    let output = match format {
26        OutputFormat::Terminal => format_terminal(&stats, lang),
27        OutputFormat::Json => format_json(&stats),
28    };
29
30    Ok(output)
31}
32
33fn analyze_team(path: &Path, limit: usize) -> Result<Vec<MemberStats>> {
34    let output = std::process::Command::new("git")
35        .args([
36            "log",
37            &format!("-{}", limit),
38            "--format=%an|%s",
39            "--no-merges",
40        ])
41        .current_dir(path)
42        .output()?;
43
44    if !output.status.success() {
45        return Err(anyhow::anyhow!("Not a git repository"));
46    }
47
48    let stdout = String::from_utf8_lossy(&output.stdout);
49    let mut author_data: HashMap<String, Vec<String>> = HashMap::new();
50
51    for line in stdout.lines() {
52        if let Some((author, message)) = line.split_once('|') {
53            author_data
54                .entry(author.to_string())
55                .or_default()
56                .push(message.to_string());
57        }
58    }
59
60    let mut stats: Vec<MemberStats> = author_data
61        .into_iter()
62        .map(|(name, messages)| {
63            let commit_count = messages.len();
64            let fix_commits = messages
65                .iter()
66                .filter(|m| {
67                    let lower = m.to_lowercase();
68                    lower.contains("fix")
69                        || lower.contains("hotfix")
70                        || lower.contains("bug")
71                        || lower.contains("patch")
72                })
73                .count();
74
75            let avg_len = if commit_count > 0 {
76                messages.iter().map(|m| m.len()).sum::<usize>() as f64 / commit_count as f64
77            } else {
78                0.0
79            };
80
81            let worst = messages
82                .iter()
83                .min_by_key(|m| m.len())
84                .cloned()
85                .unwrap_or_default();
86
87            let fix_ratio = if commit_count > 0 {
88                fix_commits as f64 / commit_count as f64
89            } else {
90                0.0
91            };
92
93            let roast = generate_roast(&name, commit_count, fix_ratio, avg_len, &worst);
94
95            MemberStats {
96                name,
97                commit_count,
98                fix_commits,
99                avg_message_length: avg_len,
100                worst_message: worst,
101                roast,
102            }
103        })
104        .collect();
105
106    // Sort by commit count descending
107    stats.sort_by_key(|a| std::cmp::Reverse(a.commit_count));
108
109    Ok(stats)
110}
111
112fn generate_roast(
113    name: &str,
114    commits: usize,
115    fix_ratio: f64,
116    avg_msg_len: f64,
117    worst_msg: &str,
118) -> String {
119    let mut roasts = Vec::new();
120
121    if fix_ratio > 0.4 {
122        roasts.push(format!(
123            "{:.0}% of commits are fixes — someone's code keeps breaking",
124            fix_ratio * 100.0
125        ));
126    }
127
128    if avg_msg_len < 15.0 {
129        roasts.push(format!(
130            "average commit message is {:.0} chars — a novel by their standards",
131            avg_msg_len
132        ));
133    }
134
135    if worst_msg.len() < 5 && !worst_msg.is_empty() {
136        roasts.push(format!(
137            "worst commit message: '{}' — truly poetic",
138            worst_msg
139        ));
140    }
141
142    if commits > 50 && fix_ratio > 0.3 {
143        roasts.push("prolific committer, half of it is fixing their own code".to_string());
144    }
145
146    if roasts.is_empty() {
147        format!("{} — nothing to roast, suspiciously clean", name)
148    } else {
149        roasts.join("; ")
150    }
151}
152
153fn format_terminal(stats: &[MemberStats], lang: &str) -> String {
154    let mut out = String::new();
155
156    out.push_str(&format!(
157        "\n{}\n",
158        t(lang, "\u{1f465} 团队分析", "\u{1f465} Team Analysis").bold()
159    ));
160    out.push_str(&format!("{}\n\n", "\u{2501}".repeat(40)));
161
162    if stats.is_empty() {
163        out.push_str(&format!(
164            "  {}\n",
165            t(
166                lang,
167                "在 git 历史中没有找到团队成员。",
168                "No team members found in git history."
169            )
170        ));
171        return out;
172    }
173
174    for (i, member) in stats.iter().take(10).enumerate() {
175        out.push_str(&format!("  #{} {}\n", i + 1, member.name.bold()));
176        out.push_str(&format!(
177            "     Commits: {} | Fix commits: {} ({:.0}%)\n",
178            member.commit_count,
179            member.fix_commits,
180            if member.commit_count > 0 {
181                member.fix_commits as f64 / member.commit_count as f64 * 100.0
182            } else {
183                0.0
184            }
185        ));
186        out.push_str(&format!(
187            "     Avg message length: {:.0} chars\n",
188            member.avg_message_length
189        ));
190        if !member.worst_message.is_empty() {
191            out.push_str(&format!(
192                "     Worst message: \"{}\"\n",
193                truncate(&member.worst_message, 40)
194            ));
195        }
196        out.push_str(&format!("     \u{1f3ad} {}\n\n", member.roast.italic()));
197    }
198
199    out
200}
201
202fn format_json(stats: &[MemberStats]) -> String {
203    serde_json::json!({
204        "members": stats.iter().map(|m| {
205            serde_json::json!({
206                "name": m.name,
207                "commit_count": m.commit_count,
208                "fix_commits": m.fix_commits,
209                "avg_message_length": m.avg_message_length,
210                "worst_message": m.worst_message,
211                "roast": m.roast,
212            })
213        }).collect::<Vec<_>>(),
214    })
215    .to_string()
216}
217
218fn truncate(s: &str, max: usize) -> String {
219    crate::utils::truncate(s, max)
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn test_generate_roast() {
228        let roast = generate_roast("Alice", 10, 0.5, 8.0, "fix");
229        assert!(roast.contains("fixes"));
230    }
231
232    #[test]
233    fn test_generate_roast_clean() {
234        let roast = generate_roast("Bob", 10, 0.1, 50.0, "a proper commit message");
235        assert!(roast.contains("nothing to roast"));
236    }
237
238    #[test]
239    fn test_run_on_current_dir() {
240        let result = run(
241            std::path::Path::new("."),
242            &OutputFormat::Terminal,
243            50,
244            "en-US",
245        );
246        assert!(result.is_ok());
247    }
248
249    #[test]
250    fn test_run_on_current_dir_chinese() {
251        let result = run(
252            std::path::Path::new("."),
253            &OutputFormat::Terminal,
254            50,
255            "zh-CN",
256        );
257        assert!(result.is_ok());
258    }
259
260    #[test]
261    fn test_run_json_format() {
262        let result = run(std::path::Path::new("."), &OutputFormat::Json, 50, "en-US");
263        assert!(result.is_ok());
264    }
265
266    #[test]
267    fn test_generate_roast_high_fix_ratio() {
268        let roast = generate_roast("Charlie", 100, 0.6, 8.0, "x");
269        assert!(roast.contains("60%"));
270    }
271
272    #[test]
273    fn test_generate_roast_short_messages() {
274        let roast = generate_roast("Dave", 10, 0.1, 5.0, "ok");
275        assert!(roast.contains("chars"));
276    }
277}