1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use serde::Serialize;
7
8use crate::analyzer::stats::{AnalysisResult, Complexity};
9use crate::git::FileChurn;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
12pub enum RiskLevel {
13 High,
14 Medium,
15 Low,
16}
17
18impl std::fmt::Display for RiskLevel {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 match self {
21 RiskLevel::High => write!(f, "HIGH"),
22 RiskLevel::Medium => write!(f, "MED"),
23 RiskLevel::Low => write!(f, "LOW"),
24 }
25 }
26}
27
28#[derive(Debug, Clone, Serialize)]
29pub struct ChurnMetrics {
30 pub commits: usize,
31 pub lines_added: usize,
32 pub lines_deleted: usize,
33 pub lines_churn: usize,
34}
35
36#[derive(Debug, Clone, Serialize)]
37pub struct FileHotspot {
38 pub path: PathBuf,
39 pub language: String,
40 pub churn: ChurnMetrics,
41 pub complexity: Complexity,
42 pub hotspot_score: f64,
43 pub risk: RiskLevel,
44}
45
46#[derive(Debug, Clone, Serialize)]
47pub struct HotspotReport {
48 pub files: Vec<FileHotspot>,
49 pub since: String,
50 pub total_commits: usize,
51}
52
53pub fn analyze(
54 churns: &[FileChurn],
55 analysis: &AnalysisResult,
56 since: &str,
57 total_commits: usize,
58 top_n: usize,
59) -> HotspotReport {
60 let stats_map: HashMap<&Path, _> = analysis
62 .files
63 .iter()
64 .map(|f| {
65 let p: &Path = f.path.strip_prefix("./").unwrap_or(&f.path);
66 (p, f)
67 })
68 .collect();
69
70 let mut hotspots: Vec<FileHotspot> = churns
71 .iter()
72 .filter_map(|churn| {
73 let key: &Path = churn.path.strip_prefix("./").unwrap_or(&churn.path);
74 stats_map.get(key).map(|stats| FileHotspot {
75 path: churn.path.clone(),
76 language: stats.language.clone(),
77 churn: ChurnMetrics {
78 commits: churn.commits,
79 lines_added: churn.lines_added,
80 lines_deleted: churn.lines_deleted,
81 lines_churn: churn.lines_added + churn.lines_deleted,
82 },
83 complexity: stats.complexity.clone(),
84 hotspot_score: 0.0,
85 risk: RiskLevel::Low,
86 })
87 })
88 .collect();
89
90 normalize_and_score(&mut hotspots);
91 hotspots.sort_by(|a, b| {
92 b.hotspot_score
93 .partial_cmp(&a.hotspot_score)
94 .unwrap_or(std::cmp::Ordering::Equal)
95 });
96 hotspots.truncate(top_n);
97
98 HotspotReport {
99 files: hotspots,
100 since: since.to_string(),
101 total_commits,
102 }
103}
104
105fn normalize_and_score(hotspots: &mut [FileHotspot]) {
106 if hotspots.is_empty() {
107 return;
108 }
109 let max_churn = hotspots.iter().map(|h| h.churn.commits).max().unwrap_or(1) as f64;
110 let max_cc = hotspots
111 .iter()
112 .map(|h| h.complexity.cyclomatic)
113 .max()
114 .unwrap_or(1) as f64;
115
116 for h in hotspots.iter_mut() {
117 let norm_churn = if max_churn > 0.0 {
118 h.churn.commits as f64 / max_churn
119 } else {
120 0.0
121 };
122 let norm_cc = if max_cc > 0.0 {
123 h.complexity.cyclomatic as f64 / max_cc
124 } else {
125 0.0
126 };
127 h.hotspot_score = norm_churn * norm_cc;
128 h.risk = match h.hotspot_score {
129 s if s > 0.7 => RiskLevel::High,
130 s if s > 0.3 => RiskLevel::Medium,
131 _ => RiskLevel::Low,
132 };
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use crate::analyzer::stats::{FileStats, LineStats, Summary};
140 use std::time::Duration;
141
142 fn make_churns() -> Vec<FileChurn> {
143 vec![
144 FileChurn {
145 path: PathBuf::from("hot.rs"),
146 commits: 50,
147 lines_added: 500,
148 lines_deleted: 200,
149 },
150 FileChurn {
151 path: PathBuf::from("warm.rs"),
152 commits: 10,
153 lines_added: 100,
154 lines_deleted: 50,
155 },
156 FileChurn {
157 path: PathBuf::from("cold.rs"),
158 commits: 2,
159 lines_added: 10,
160 lines_deleted: 5,
161 },
162 FileChurn {
163 path: PathBuf::from("deleted.rs"),
164 commits: 5,
165 lines_added: 30,
166 lines_deleted: 30,
167 },
168 ]
169 }
170
171 fn make_analysis() -> AnalysisResult {
172 let files = vec![
173 FileStats {
174 path: PathBuf::from("hot.rs"),
175 language: "Rust".to_string(),
176 lines: LineStats {
177 total: 300,
178 code: 250,
179 comment: 20,
180 blank: 30,
181 },
182 size: 5000,
183 complexity: Complexity {
184 functions: 5,
185 cyclomatic: 40,
186 max_depth: 6,
187 avg_func_lines: 50.0,
188 },
189 },
190 FileStats {
191 path: PathBuf::from("warm.rs"),
192 language: "Rust".to_string(),
193 lines: LineStats {
194 total: 100,
195 code: 80,
196 comment: 10,
197 blank: 10,
198 },
199 size: 2000,
200 complexity: Complexity {
201 functions: 4,
202 cyclomatic: 10,
203 max_depth: 3,
204 avg_func_lines: 20.0,
205 },
206 },
207 FileStats {
208 path: PathBuf::from("cold.rs"),
209 language: "Rust".to_string(),
210 lines: LineStats {
211 total: 50,
212 code: 40,
213 comment: 5,
214 blank: 5,
215 },
216 size: 1000,
217 complexity: Complexity {
218 functions: 2,
219 cyclomatic: 4,
220 max_depth: 2,
221 avg_func_lines: 20.0,
222 },
223 },
224 ];
225 AnalysisResult {
226 summary: Summary::from_file_stats(&files),
227 files,
228 elapsed: Duration::from_millis(50),
229 scanned_files: 3,
230 skipped_files: 0,
231 }
232 }
233
234 #[test]
235 fn test_hotspot_basic() {
236 let report = analyze(&make_churns(), &make_analysis(), "90d", 100, 10);
237 assert_eq!(report.since, "90d");
238 assert_eq!(report.total_commits, 100);
239 assert_eq!(report.files.len(), 3); }
241
242 #[test]
243 fn test_hotspot_sorted_descending() {
244 let report = analyze(&make_churns(), &make_analysis(), "90d", 100, 10);
245 for window in report.files.windows(2) {
246 assert!(window[0].hotspot_score >= window[1].hotspot_score);
247 }
248 }
249
250 #[test]
251 fn test_hotspot_highest_is_hot() {
252 let report = analyze(&make_churns(), &make_analysis(), "90d", 100, 10);
253 assert_eq!(report.files[0].path, PathBuf::from("hot.rs"));
254 assert!((report.files[0].hotspot_score - 1.0).abs() < 0.01);
255 assert_eq!(report.files[0].risk, RiskLevel::High);
256 }
257
258 #[test]
259 fn test_hotspot_skips_deleted_files() {
260 let report = analyze(&make_churns(), &make_analysis(), "90d", 100, 10);
261 assert!(report
262 .files
263 .iter()
264 .all(|f| f.path != std::path::Path::new("deleted.rs")));
265 }
266
267 #[test]
268 fn test_hotspot_top_n() {
269 let report = analyze(&make_churns(), &make_analysis(), "90d", 100, 1);
270 assert_eq!(report.files.len(), 1);
271 }
272
273 #[test]
274 fn test_hotspot_empty_churns() {
275 let report = analyze(&[], &make_analysis(), "90d", 0, 10);
276 assert!(report.files.is_empty());
277 }
278
279 #[test]
280 fn test_hotspot_churn_metrics() {
281 let report = analyze(&make_churns(), &make_analysis(), "90d", 100, 10);
282 let hot = &report.files[0];
283 assert_eq!(hot.churn.commits, 50);
284 assert_eq!(hot.churn.lines_added, 500);
285 assert_eq!(hot.churn.lines_deleted, 200);
286 assert_eq!(hot.churn.lines_churn, 700);
287 }
288
289 #[test]
290 fn test_risk_levels() {
291 let report = analyze(&make_churns(), &make_analysis(), "90d", 100, 10);
292 let risks: Vec<RiskLevel> = report.files.iter().map(|f| f.risk).collect();
293 assert!(risks.contains(&RiskLevel::High));
294 }
295}