garbage_code_hunter/decay/
mod.rs1use crate::common::i18n_ext::t;
4use crate::common::OutputFormat;
5use anyhow::Result;
6use colored::Colorize;
7use std::path::Path;
8
9#[derive(Debug, Clone)]
11pub struct DecayPoint {
12 pub date: String,
13 pub score: f64,
14 pub event: Option<String>,
15}
16
17#[derive(Debug, Clone)]
19pub struct DecayReport {
20 pub points: Vec<DecayPoint>,
21 pub turning_point: Option<DecayPoint>,
22 pub worst_contributor: Option<String>,
23 pub current_health: &'static str,
24}
25
26pub fn run(path: &Path, format: &OutputFormat, lang: &str) -> Result<String> {
28 let report = analyze_decay(path)?;
29
30 let output = match format {
31 OutputFormat::Terminal => display_terminal(&report, lang),
32 OutputFormat::Json => display_json(&report),
33 };
34
35 Ok(output)
36}
37
38fn analyze_decay(path: &Path) -> Result<DecayReport> {
40 let output = std::process::Command::new("git")
42 .args([
43 "log",
44 "--format=%H|%ai|%an|%s",
45 "--shortstat",
46 "--no-merges",
47 "-50",
48 ])
49 .current_dir(path)
50 .output()?;
51
52 if !output.status.success() {
53 return Err(anyhow::anyhow!("Not a git repository or git not available"));
54 }
55
56 let stdout = String::from_utf8_lossy(&output.stdout);
57 let commits = parse_git_log(&stdout);
58
59 let mut points = Vec::new();
61 let mut worst_author_debt = std::collections::HashMap::<String, u32>::new();
62
63 for commit in &commits {
64 let mut score: f64 = 100.0;
65 let mut event = None;
66
67 let msg = &commit.message;
69 if msg.len() < 5 {
70 score -= 20.0;
71 event = Some("minimal commit message".to_string());
72 } else if is_generic_message(msg) {
73 score -= 10.0;
74 event = Some(format!("generic message: '{}'", truncate(msg, 20)));
75 }
76
77 if msg.to_lowercase().contains("fix") || msg.to_lowercase().contains("hotfix") {
79 score -= 5.0;
80 *worst_author_debt.entry(commit.author.clone()).or_insert(0) += 1;
81 }
82
83 if msg.to_lowercase().contains("wip") {
84 score -= 15.0;
85 }
86
87 points.push(DecayPoint {
88 date: commit.date[..10].to_string(),
89 score: score.max(0.0),
90 event,
91 });
92 }
93
94 let turning_point = find_turning_point(&points);
96
97 let worst_contributor = worst_author_debt
99 .iter()
100 .max_by_key(|(_, v)| *v)
101 .map(|(k, _)| k.clone());
102
103 let avg_score = if points.is_empty() {
105 100.0
106 } else {
107 points.iter().map(|p| p.score).sum::<f64>() / points.len() as f64
108 };
109 let current_health = health_label(avg_score);
110
111 Ok(DecayReport {
112 points,
113 turning_point,
114 worst_contributor,
115 current_health,
116 })
117}
118
119#[derive(Debug)]
120struct CommitInfo {
121 date: String,
122 author: String,
123 message: String,
124}
125
126fn parse_git_log(output: &str) -> Vec<CommitInfo> {
127 let mut commits = Vec::new();
128 let mut lines = output.lines();
129
130 while let Some(line) = lines.next() {
131 let parts: Vec<&str> = line.splitn(4, '|').collect();
132 if parts.len() < 4 {
133 continue;
134 }
135
136 commits.push(CommitInfo {
137 date: parts[1].to_string(),
138 author: parts[2].to_string(),
139 message: parts[3].to_string(),
140 });
141
142 for next in lines.by_ref() {
144 if next.trim().is_empty() || next.contains("file changed") {
145 break;
146 }
147 }
148 }
149
150 commits
151}
152
153fn is_generic_message(msg: &str) -> bool {
154 let lower = msg.to_lowercase();
155 let generics = [
156 "fix", "update", "change", "wip", "tmp", "temp", "asdf", "test",
157 ];
158 generics
159 .iter()
160 .any(|g| lower.trim() == *g || lower.starts_with(&format!("{} ", g)))
161}
162
163fn find_turning_point(points: &[DecayPoint]) -> Option<DecayPoint> {
164 if points.len() < 3 {
165 return None;
166 }
167
168 let mut max_drop = 0.0;
170 let mut turning = None;
171
172 for i in 2..points.len() {
173 let drop = points[i - 2].score - points[i].score;
174 if drop > max_drop && drop > 10.0 {
175 max_drop = drop;
176 turning = Some(points[i].clone());
177 }
178 }
179
180 turning
181}
182
183fn health_label(score: f64) -> &'static str {
184 match score as u32 {
185 90..=100 => "Thriving",
186 70..=89 => "Healthy",
187 50..=69 => "Declining",
188 30..=49 => "Critical",
189 _ => "Terminal",
190 }
191}
192
193fn truncate(s: &str, max: usize) -> String {
194 crate::utils::truncate(s, max)
195}
196
197fn display_terminal(report: &DecayReport, lang: &str) -> String {
198 let mut out = String::new();
199
200 out.push_str(&format!(
201 "\n{}\n",
202 t(
203 lang,
204 "\u{1f4c9} 项目衰变分析",
205 "\u{1f4c9} Project Decay Analysis"
206 )
207 .bold()
208 ));
209 out.push_str(&format!("{}\n\n", "\u{2501}".repeat(40)));
210
211 out.push_str(&format!(
213 " {}: {}\n\n",
214 t(lang, "当前健康度", "Current Health"),
215 report.current_health.bold()
216 ));
217
218 let display_points: Vec<_> = if report.points.len() > 10 {
220 report.points[report.points.len() - 10..].to_vec()
221 } else {
222 report.points.clone()
223 };
224
225 for point in &display_points {
226 let bar_len = (point.score / 5.0) as usize;
227 let bar: String = "\u{2588}".repeat(bar_len);
228 let bar_colored = if point.score >= 80.0 {
229 bar.green()
230 } else if point.score >= 50.0 {
231 bar.yellow()
232 } else {
233 bar.red()
234 };
235 let event_str = point
236 .event
237 .as_ref()
238 .map(|e| format!(" \u{2190} {}", e.dimmed()))
239 .unwrap_or_default();
240 out.push_str(&format!(
241 " {} {:.0} {}{}\n",
242 &point.date, point.score, bar_colored, event_str
243 ));
244 }
245
246 out.push('\n');
247
248 if let Some(tp) = &report.turning_point {
250 out.push_str(&format!(
251 "{}\n",
252 t(lang, "\u{1f534} 转折点", "\u{1f534} Turning Point").bold()
253 ));
254 out.push_str(&format!(
255 " {}\n",
256 t(
257 lang,
258 &format!("{} — 质量显著下降", tp.date),
259 &format!("{} — quality dropped significantly", tp.date)
260 )
261 ));
262 if let Some(event) = &tp.event {
263 out.push_str(&format!(
264 " {}: {}\n",
265 t(lang, "触发因素", "Trigger"),
266 event
267 ));
268 }
269 out.push('\n');
270 }
271
272 if let Some(author) = &report.worst_contributor {
274 out.push_str(&format!(
275 "{}\n",
276 t(
277 lang,
278 "\u{1f468}\u{200d}\u{1f4bb} 最多修复提交的作者",
279 "\u{1f468}\u{200d}\u{1f4bb} Most Fix-Heavy Author"
280 )
281 .bold()
282 ));
283 out.push_str(&format!(
284 " {}\n",
285 t(
286 lang,
287 &format!("{} — 检测到最多的 'fix' 提交", author),
288 &format!("{} — most 'fix' commits detected", author)
289 )
290 ));
291 }
292
293 out
294}
295
296fn display_json(report: &DecayReport) -> String {
297 serde_json::json!({
298 "current_health": report.current_health,
299 "turning_point": report.turning_point.as_ref().map(|tp| {
300 serde_json::json!({
301 "date": tp.date,
302 "score": tp.score,
303 "event": tp.event,
304 })
305 }),
306 "worst_contributor": report.worst_contributor,
307 "timeline": report.points.iter().map(|p| {
308 serde_json::json!({
309 "date": p.date,
310 "score": p.score,
311 "event": p.event,
312 })
313 }).collect::<Vec<_>>(),
314 })
315 .to_string()
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn test_is_generic_message() {
324 assert!(is_generic_message("fix"));
325 assert!(is_generic_message("update"));
326 assert!(is_generic_message("WIP"));
327 assert!(!is_generic_message("fix: resolve auth token refresh bug"));
328 }
329
330 #[test]
331 fn test_health_label() {
332 assert_eq!(health_label(95.0), "Thriving");
333 assert_eq!(health_label(75.0), "Healthy");
334 assert_eq!(health_label(55.0), "Declining");
335 assert_eq!(health_label(35.0), "Critical");
336 assert_eq!(health_label(10.0), "Terminal");
337 }
338
339 #[test]
340 fn test_find_turning_point() {
341 let points = vec![
342 DecayPoint {
343 date: "2024-01".to_string(),
344 score: 90.0,
345 event: None,
346 },
347 DecayPoint {
348 date: "2024-02".to_string(),
349 score: 85.0,
350 event: None,
351 },
352 DecayPoint {
353 date: "2024-03".to_string(),
354 score: 60.0,
355 event: None,
356 },
357 ];
358 let tp = find_turning_point(&points);
359 assert!(tp.is_some());
360 }
361}