Skip to main content

codelens_core/insight/
hotspot.rs

1//! Change hotspot analysis: churn × complexity.
2
3use 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    // Normalize paths: strip leading "./" so analysis paths match git paths
61    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); // deleted.rs not in analysis
240    }
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}