Skip to main content

codelens_core/insight/
hotspot.rs

1//! Change hotspot analysis: churn × complexity.
2
3use 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); // deleted.rs not in analysis
231    }
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}