garbage_code_hunter/danger_zone/
mod.rs1use 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#[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
23pub 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 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 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 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 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
157fn 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
181fn get_contributor_counts(path: &Path) -> HashMap<PathBuf, usize> {
183 let mut counts = HashMap::new();
184
185 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 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}