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