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    #[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}