Skip to main content

garbage_code_hunter/danger_zone/
mod.rs

1//! Danger Zone — identify the most dangerous files in the codebase.
2
3use crate::analyzer::{CodeAnalyzer, CodeIssue};
4use crate::common::i18n_ext::t;
5use crate::common::OutputFormat;
6use anyhow::Result;
7use colored::Colorize;
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11/// A dangerous file entry.
12#[derive(Debug, Clone)]
13pub struct DangerEntry {
14    pub file: PathBuf,
15    pub risk_score: f64,
16    pub risk_level: &'static str,
17    pub issue_count: usize,
18    pub churn: usize,
19    pub contributors: usize,
20    pub reasons: Vec<String>,
21}
22
23/// Run danger zone analysis.
24pub fn run(path: &Path, format: &OutputFormat, lang: &str) -> Result<String> {
25    let analyzer = CodeAnalyzer::new(&[], lang);
26    let issues = analyzer.analyze_path(path);
27
28    // Group issues by file
29    let mut by_file: HashMap<PathBuf, Vec<&CodeIssue>> = HashMap::new();
30    for issue in &issues {
31        by_file
32            .entry(issue.file_path.clone())
33            .or_default()
34            .push(issue);
35    }
36
37    // Get git churn data (optional)
38    let churn_data = get_churn_data(path);
39    let blame_data = get_contributor_counts(path);
40
41    let mut entries: Vec<DangerEntry> = Vec::new();
42
43    for (file, file_issues) in &by_file {
44        let issue_count = file_issues.len();
45        let churn = churn_data.get(file).copied().unwrap_or(0);
46        let contributors = blame_data.get(file).copied().unwrap_or(1);
47
48        // Calculate risk score
49        let issue_score = (issue_count as f64 * 3.0).min(50.0);
50        let churn_score = (churn as f64 * 0.5).min(30.0);
51        let contributor_score = (contributors as f64 * 2.0).min(20.0);
52        let risk_score = issue_score + churn_score + contributor_score;
53
54        let risk_level = match risk_score as u32 {
55            0..=10 => "LOW",
56            11..=30 => "MEDIUM",
57            31..=60 => "HIGH",
58            _ => "EXTREME",
59        };
60
61        let mut reasons = Vec::new();
62        if issue_count > 5 {
63            reasons.push(format!("{} code issues", issue_count));
64        }
65        if churn > 20 {
66            reasons.push(format!("modified {} times recently", churn));
67        }
68        if contributors > 5 {
69            reasons.push(format!("{} authors touched this file", contributors));
70        }
71
72        entries.push(DangerEntry {
73            file: file.clone(),
74            risk_score,
75            risk_level,
76            issue_count,
77            churn,
78            contributors,
79            reasons,
80        });
81    }
82
83    // Sort by risk score descending
84    entries.sort_by(|a, b| b.risk_score.partial_cmp(&a.risk_score).unwrap());
85
86    let output = match format {
87        OutputFormat::Terminal => format_terminal(&entries, lang),
88        OutputFormat::Json => format_json(&entries),
89    };
90
91    Ok(output)
92}
93
94fn format_terminal(entries: &[DangerEntry], lang: &str) -> String {
95    let mut out = String::new();
96
97    out.push_str(&format!(
98        "\n{}\n",
99        t(lang, "\u{1f525} 危险区域", "\u{1f525} Danger Zone").bold()
100    ));
101    out.push_str(&format!("{}\n\n", "\u{2501}".repeat(40)));
102
103    if entries.is_empty() {
104        out.push_str(&format!(
105            "  {}\n",
106            t(lang, "没有分析到文件。", "No files analyzed.")
107        ));
108        return out;
109    }
110
111    for (i, entry) in entries.iter().take(10).enumerate() {
112        let risk_colored = match entry.risk_level {
113            "EXTREME" => entry.risk_level.red().bold(),
114            "HIGH" => entry.risk_level.red(),
115            "MEDIUM" => entry.risk_level.yellow(),
116            _ => entry.risk_level.green(),
117        };
118        let file_short = entry
119            .file
120            .file_name()
121            .map(|f| f.to_string_lossy().to_string())
122            .unwrap_or_else(|| entry.file.display().to_string());
123
124        out.push_str(&format!(
125            "  #{} {} [Risk: {}]\n",
126            i + 1,
127            file_short.bold(),
128            risk_colored
129        ));
130        out.push_str(&format!("     Score: {:.0}/100\n", entry.risk_score));
131        for reason in &entry.reasons {
132            out.push_str(&format!("     \u{2022} {}\n", reason.dimmed()));
133        }
134        out.push('\n');
135    }
136
137    out
138}
139
140fn format_json(entries: &[DangerEntry]) -> String {
141    serde_json::json!({
142        "files": entries.iter().take(10).map(|e| {
143            serde_json::json!({
144                "file": e.file.display().to_string(),
145                "risk_score": e.risk_score,
146                "risk_level": e.risk_level,
147                "issue_count": e.issue_count,
148                "churn": e.churn,
149                "contributors": e.contributors,
150                "reasons": e.reasons,
151            })
152        }).collect::<Vec<_>>(),
153    })
154    .to_string()
155}
156
157/// Get file churn (number of commits touching each file) from git log.
158fn get_churn_data(path: &Path) -> HashMap<PathBuf, usize> {
159    let mut churn = HashMap::new();
160
161    let output = match std::process::Command::new("git")
162        .args(["log", "--name-only", "--pretty=format:", "-30"])
163        .current_dir(path)
164        .output()
165    {
166        Ok(o) if o.status.success() => o,
167        _ => return churn,
168    };
169
170    let stdout = String::from_utf8_lossy(&output.stdout);
171    for line in stdout.lines() {
172        let trimmed = line.trim();
173        if !trimmed.is_empty() {
174            *churn.entry(PathBuf::from(trimmed)).or_insert(0) += 1;
175        }
176    }
177
178    churn
179}
180
181/// Get unique contributor counts per file from git blame.
182fn get_contributor_counts(path: &Path) -> HashMap<PathBuf, usize> {
183    let mut counts = HashMap::new();
184
185    // Get list of tracked files
186    let output = match std::process::Command::new("git")
187        .args(["ls-files"])
188        .current_dir(path)
189        .output()
190    {
191        Ok(o) if o.status.success() => o,
192        _ => return counts,
193    };
194
195    let stdout = String::from_utf8_lossy(&output.stdout);
196    for file_line in stdout.lines().take(50) {
197        let file_path = path.join(file_line.trim());
198        if !file_line.trim().ends_with(".rs") {
199            continue;
200        }
201
202        // Get unique authors for this file
203        let blame_output = match std::process::Command::new("git")
204            .args(["blame", "--line-porcelain", file_line.trim()])
205            .current_dir(path)
206            .output()
207        {
208            Ok(o) if o.status.success() => o,
209            _ => continue,
210        };
211
212        let blame_stdout = String::from_utf8_lossy(&blame_output.stdout);
213        let mut authors = std::collections::HashSet::new();
214        for blame_line in blame_stdout.lines() {
215            if let Some(author) = blame_line.strip_prefix("author ") {
216                authors.insert(author.to_string());
217            }
218        }
219
220        counts.insert(file_path, authors.len());
221    }
222
223    counts
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_run_on_current_dir() {
232        let result = run(std::path::Path::new("."), &OutputFormat::Terminal, "en-US");
233        assert!(result.is_ok());
234    }
235
236    #[test]
237    fn test_run_on_current_dir_chinese() {
238        let result = run(std::path::Path::new("."), &OutputFormat::Terminal, "zh-CN");
239        assert!(result.is_ok());
240    }
241
242    #[test]
243    fn test_run_json_format() {
244        let result = run(std::path::Path::new("."), &OutputFormat::Json, "en-US");
245        assert!(result.is_ok());
246    }
247}