garbage_code_hunter/team_roast/
mod.rs1use 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#[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
21pub 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 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}