1use 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]
327 fn test_is_generic_message_exact_match() {
328 assert!(is_generic_message("fix"), "'fix' exact match");
329 assert!(is_generic_message("update"), "'update' exact match");
330 assert!(is_generic_message("change"), "'change' exact match");
331 assert!(is_generic_message("wip"), "'wip' exact match");
332 assert!(is_generic_message("tmp"), "'tmp' exact match");
333 assert!(is_generic_message("temp"), "'temp' exact match");
334 assert!(is_generic_message("asdf"), "'asdf' exact match");
335 assert!(is_generic_message("test"), "'test' exact match");
336 }
337
338 #[test]
340 fn test_is_generic_message_case_insensitive() {
341 assert!(is_generic_message("FIX"), "FIX uppercase");
342 assert!(is_generic_message("WIP"), "WIP uppercase");
343 assert!(is_generic_message("Temp"), "Temp mixed case");
344 }
345
346 #[test]
348 fn test_is_generic_message_with_trailing() {
349 assert!(is_generic_message("fix stuff"), "'fix ' prefix");
350 assert!(is_generic_message("update the code"), "'update ' prefix");
351 assert!(is_generic_message("wip changes"), "'wip ' prefix");
352 assert!(is_generic_message("tmp notes"), "'tmp ' prefix");
353 assert!(is_generic_message("test the build"), "'test ' prefix");
354 }
355
356 #[test]
359 fn test_is_generic_message_non_generic() {
360 assert!(
361 !is_generic_message("fix: resolve auth token refresh bug"),
362 "fix: prefix is not generic"
363 );
364 assert!(
365 !is_generic_message("fixed the race condition"),
366 "'fixed' is not exact 'fix'"
367 );
368 assert!(
369 !is_generic_message("refactor database layer"),
370 "descriptive message"
371 );
372 assert!(
373 !is_generic_message("testing new feature"),
374 "'testing' does not start with 'test '"
375 );
376 assert!(
377 !is_generic_message("updates the docs"),
378 "'updates' is not 'update'"
379 );
380 }
381
382 #[test]
387 fn test_health_label_boundaries() {
388 assert_eq!(health_label(100.0), "Thriving", "100 => Thriving");
389 assert_eq!(health_label(90.0), "Thriving", "90 => Thriving");
390 assert_eq!(health_label(89.0), "Healthy", "89 => Healthy");
391 assert_eq!(health_label(70.0), "Healthy", "70 => Healthy");
392 assert_eq!(health_label(69.0), "Declining", "69 => Declining");
393 assert_eq!(health_label(50.0), "Declining", "50 => Declining");
394 assert_eq!(health_label(49.0), "Critical", "49 => Critical");
395 assert_eq!(health_label(30.0), "Critical", "30 => Critical");
396 assert_eq!(health_label(29.0), "Terminal", "29 => Terminal");
397 assert_eq!(health_label(0.0), "Terminal", "0 => Terminal");
398 }
399
400 #[test]
405 fn test_find_turning_point_too_few_points() {
406 assert!(find_turning_point(&[]).is_none(), "empty => None");
407 assert!(
408 find_turning_point(&[DecayPoint {
409 date: "2024-01".into(),
410 score: 90.0,
411 event: None
412 }])
413 .is_none(),
414 "1 point => None"
415 );
416 assert!(
417 find_turning_point(&[
418 DecayPoint {
419 date: "2024-01".into(),
420 score: 90.0,
421 event: None
422 },
423 DecayPoint {
424 date: "2024-02".into(),
425 score: 85.0,
426 event: None
427 },
428 ])
429 .is_none(),
430 "2 points => None"
431 );
432 }
433
434 #[test]
436 fn test_find_turning_point_detected() {
437 let points = vec![
438 DecayPoint {
439 date: "2024-01".into(),
440 score: 90.0,
441 event: None,
442 },
443 DecayPoint {
444 date: "2024-02".into(),
445 score: 85.0,
446 event: None,
447 },
448 DecayPoint {
449 date: "2024-03".into(),
450 score: 60.0,
451 event: None,
452 },
453 ];
454 let tp = find_turning_point(&points);
455 assert!(
456 tp.is_some(),
457 "drop from 90→60 over 3 points should be detected"
458 );
459 assert_eq!(
460 tp.unwrap().score,
461 60.0,
462 "turning point is the lowest point in the sequence"
463 );
464 }
465
466 #[test]
469 fn test_find_turning_point_small_drop_not_detected() {
470 let points = vec![
471 DecayPoint {
472 date: "2024-01".into(),
473 score: 80.0,
474 event: None,
475 },
476 DecayPoint {
477 date: "2024-02".into(),
478 score: 75.0,
479 event: None,
480 },
481 DecayPoint {
482 date: "2024-03".into(),
483 score: 71.0,
484 event: None,
485 },
486 ];
487 let tp = find_turning_point(&points);
488 assert!(tp.is_none(), "9-point drop should not be a turning point");
489 }
490
491 #[test]
493 fn test_find_turning_point_biggest_drop_wins() {
494 let points = vec![
495 DecayPoint {
496 date: "2024-01".into(),
497 score: 90.0,
498 event: None,
499 },
500 DecayPoint {
501 date: "2024-02".into(),
502 score: 80.0,
503 event: None,
504 },
505 DecayPoint {
506 date: "2024-03".into(),
507 score: 70.0,
508 event: None,
509 }, DecayPoint {
511 date: "2024-04".into(),
512 score: 65.0,
513 event: None,
514 },
515 DecayPoint {
516 date: "2024-05".into(),
517 score: 30.0,
518 event: None,
519 }, DecayPoint {
521 date: "2024-06".into(),
522 score: 25.0,
523 event: None,
524 }, ];
526 let tp = find_turning_point(&points);
527 assert!(tp.is_some(), "should detect a turning point");
528 assert_eq!(
529 tp.unwrap().score,
530 30.0,
531 "biggest drop (40 pts) ends at score 30.0"
532 );
533 }
534
535 #[test]
537 fn test_find_turning_point_increasing_scores() {
538 let points = vec![
539 DecayPoint {
540 date: "2024-01".into(),
541 score: 50.0,
542 event: None,
543 },
544 DecayPoint {
545 date: "2024-02".into(),
546 score: 60.0,
547 event: None,
548 },
549 DecayPoint {
550 date: "2024-03".into(),
551 score: 70.0,
552 event: None,
553 },
554 ];
555 let tp = find_turning_point(&points);
556 assert!(
557 tp.is_none(),
558 "increasing scores should not produce a turning point"
559 );
560 }
561
562 #[test]
566 fn test_parse_git_log_standard() {
567 let input = "abc123|2024-01-15 10:00:00 +0800|Alice|fix: resolve bug\n 1 file changed, 2 insertions(+)\n\ndef456|2024-01-16 11:00:00 +0800|Bob|refactor module\n 2 files changed, 10 insertions(+), 3 deletions(-)\n";
568 let commits = parse_git_log(input);
569 assert_eq!(commits.len(), 2, "should parse 2 commits");
570 assert_eq!(commits[0].author, "Alice");
571 assert_eq!(commits[0].message, "fix: resolve bug");
572 assert_eq!(commits[1].author, "Bob");
573 assert_eq!(commits[1].message, "refactor module");
574 }
575
576 #[test]
578 fn test_parse_git_log_empty() {
579 let commits = parse_git_log("");
580 assert!(commits.is_empty(), "empty input => no commits");
581 }
582
583 #[test]
585 fn test_parse_git_log_malformed() {
586 let input = "not-enough-parts\nabc123|2024-01-15|Alice|valid message\n 1 file changed, 1 insertion(+)\n";
587 let commits = parse_git_log(input);
588 assert_eq!(commits.len(), 1, "malformed line should be skipped");
589 assert_eq!(commits[0].author, "Alice");
590 }
591
592 #[test]
594 fn test_parse_git_log_single() {
595 let input = "abc|2024-06-01|Carol|initial commit\n 1 file changed, 1 insertion(+)\n";
596 let commits = parse_git_log(input);
597 assert_eq!(commits.len(), 1);
598 assert_eq!(commits[0].date, "2024-06-01");
599 assert_eq!(commits[0].message, "initial commit");
600 }
601
602 #[test]
606 fn test_display_json_structure() {
607 let report = DecayReport {
608 points: vec![DecayPoint {
609 date: "2024-06-01".into(),
610 score: 72.5,
611 event: Some("bad commit".into()),
612 }],
613 turning_point: Some(DecayPoint {
614 date: "2024-06-01".into(),
615 score: 72.5,
616 event: Some("bad commit".into()),
617 }),
618 worst_contributor: Some("Alice".into()),
619 current_health: "Healthy",
620 };
621 let json = display_json(&report);
622 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
623 assert_eq!(parsed["current_health"], "Healthy");
624 assert_eq!(parsed["worst_contributor"], "Alice");
625 assert!(
626 parsed["turning_point"].is_object(),
627 "turning_point should be an object"
628 );
629 assert!(parsed["timeline"].is_array(), "timeline should be an array");
630 }
631
632 #[test]
634 fn test_display_json_empty_timeline() {
635 let report = DecayReport {
636 points: vec![],
637 turning_point: None,
638 worst_contributor: None,
639 current_health: "Terminal",
640 };
641 let json = display_json(&report);
642 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
643 assert_eq!(parsed["current_health"], "Terminal");
644 assert!(
645 parsed["turning_point"].is_null(),
646 "no turning point => null"
647 );
648 assert!(
649 parsed["worst_contributor"].is_null(),
650 "no worst author => null"
651 );
652 assert!(
653 parsed["timeline"].as_array().unwrap().is_empty(),
654 "empty timeline"
655 );
656 }
657
658 #[test]
662 fn test_truncate_delegates() {
663 let result = truncate("hello world", 100);
664 assert_eq!(result, "hello world", "within max length => unchanged");
665 }
666}