Skip to main content

garbage_code_hunter/decay/
mod.rs

1//! Git Decay Curve — analyze project quality degradation over time.
2
3use crate::common::i18n_ext::t;
4use crate::common::OutputFormat;
5use anyhow::Result;
6use colored::Colorize;
7use std::path::Path;
8
9/// A single point on the decay curve.
10#[derive(Debug, Clone)]
11pub struct DecayPoint {
12    pub date: String,
13    pub score: f64,
14    pub event: Option<String>,
15}
16
17/// Full decay analysis result.
18#[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
26/// Run decay analysis on a git repository.
27pub 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
38/// Analyze git log for quality decay signals.
39fn analyze_decay(path: &Path) -> Result<DecayReport> {
40    // Get commit history with stats
41    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    // Analyze quality signals per commit
60    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        // Penalize bad commit messages
68        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        // Penalize rapid commits (sign of hotfixes)
78        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    // Find turning point (biggest sustained drop)
95    let turning_point = find_turning_point(&points);
96
97    // Find worst contributor
98    let worst_contributor = worst_author_debt
99        .iter()
100        .max_by_key(|(_, v)| *v)
101        .map(|(k, _)| k.clone());
102
103    // Determine current health
104    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        // Skip shortstat line(s)
143        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    // Look for the biggest sustained drop (3+ consecutive decreases)
169    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    // Current health
212    out.push_str(&format!(
213        "  {}: {}\n\n",
214        t(lang, "当前健康度", "Current Health"),
215        report.current_health.bold()
216    ));
217
218    // Decay timeline (last 10 points)
219    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    // Turning point
249    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    // Worst contributor
273    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    // ── is_generic_message ────────────────────────────────────────
323
324    /// Objective: Verify all generic message patterns are detected.
325    /// Invariants: Exact match "fix" or starts with "fix " are generic.
326    #[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    /// Objective: Verify case-insensitive matching works.
339    #[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    /// Objective: Verify generic messages followed by content (starts with "fix ") are detected.
347    #[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    /// Objective: Verify non-generic messages are not flagged.
357    /// Invariants: Descriptive messages with context are not generic.
358    #[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    // ── health_label ──────────────────────────────────────────────
383
384    /// Objective: Verify health label boundary values.
385    /// Invariants: 90+ = Thriving, 70-89 = Healthy, 50-69 = Declining, 30-49 = Critical, <30 = Terminal.
386    #[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    // ── find_turning_point ────────────────────────────────────────
401
402    /// Objective: Verify find_turning_point returns None when there are fewer than 3 data points.
403    /// Invariants: Need at least 3 points to detect a sustained drop.
404    #[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    /// Objective: Verify turning point is detected when score drops >10 over 3+ points.
435    #[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    /// Objective: Verify turning point is NOT detected when drop is <=10.
467    /// Invariants: Drop must be >10 (strict greater).
468    #[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    /// Objective: Verify the BIGGEST drop wins when there are multiple drops.
492    #[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            }, // points[0]-points[2] = 20
510            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            }, // points[2]-points[4] = 40 ← biggest
520            DecayPoint {
521                date: "2024-06".into(),
522                score: 25.0,
523                event: None,
524            }, // points[3]-points[5] = 40 (tied, first wins)
525        ];
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    /// Objective: Verify turning point detection with scores that increase (no turning point).
536    #[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    // ── parse_git_log ─────────────────────────────────────────────
563
564    /// Objective: Verify parse_git_log handles standard git log format.
565    #[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    /// Objective: Verify parse_git_log handles empty input.
577    #[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    /// Objective: Verify parse_git_log handles malformed lines gracefully.
584    #[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    /// Objective: Verify parse_git_log handles single commit.
593    #[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    // ── JSON output ───────────────────────────────────────────────
603
604    /// Objective: Verify display_json produces valid JSON with all expected fields.
605    #[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    /// Objective: Verify display_json handles empty timeline.
633    #[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    // ── truncate (delegates to utils) ─────────────────────────────
659
660    /// Objective: Verify truncate delegates to utils::truncate without panicking.
661    #[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}