use crate::snapshot::FunctionSnapshot;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HotspotAxis {
Risk,
Coupling,
Ownership,
Centrality,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RankedFile {
pub file: String,
pub score: f64,
}
pub fn rank_by_axis(
entries: &[FunctionSnapshot],
axis: HotspotAxis,
top_n: usize,
) -> Vec<RankedFile> {
let mut by_file: std::collections::HashMap<&str, f64> = std::collections::HashMap::new();
for f in entries {
let Some(score) = axis_score(f, axis) else {
continue;
};
by_file
.entry(f.file.as_str())
.and_modify(|best| *best = best.max(score))
.or_insert(score);
}
let mut scored: Vec<RankedFile> = by_file
.into_iter()
.map(|(file, score)| RankedFile {
file: file.to_string(),
score,
})
.collect();
scored.sort_by(|a, b| {
b.score
.total_cmp(&a.score)
.then_with(|| a.file.cmp(&b.file))
});
scored.truncate(top_n);
scored
}
fn axis_score(f: &FunctionSnapshot, axis: HotspotAxis) -> Option<f64> {
match axis {
HotspotAxis::Risk => Some(f.activity_risk.unwrap_or(f.lrs)),
HotspotAxis::Coupling => f.directed_coupling.filter(|&dc| dc != 0.0),
HotspotAxis::Ownership => f.newcomer_rate,
HotspotAxis::Centrality => f.callgraph.as_ref().map(|cg| cg.pagerank),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::language::Language;
use crate::report::MetricsReport;
use crate::risk::RiskBand;
use crate::snapshot::CallGraphMetrics;
fn callgraph_with_pagerank(pagerank: f64) -> CallGraphMetrics {
CallGraphMetrics {
fan_in: 0,
fan_out: 0,
pagerank,
betweenness: 0.0,
scc_id: 0,
scc_size: 1,
is_entrypoint: false,
dependency_depth: None,
neighbor_churn: None,
}
}
fn fixture(file: &str) -> FunctionSnapshot {
FunctionSnapshot {
function_id: format!("{file}::f"),
file: file.to_string(),
line: 1,
language: Language::Rust,
metrics: MetricsReport {
cc: 1,
nd: 1,
fo: 1,
ns: 1,
loc: 1,
},
lrs: 0.0,
band: RiskBand::Low,
suppression_reason: None,
churn: None,
touch_count_30d: None,
days_since_last_change: None,
callgraph: None,
activity_risk: None,
risk_factors: None,
percentile: None,
driver: None,
driver_detail: None,
quadrant: None,
patterns: vec![],
pattern_details: None,
subsystem: None,
authors_90d: None,
directed_coupling: None,
jaccard_label_stability: None,
convention_bug_fix_count: None,
burst_score: None,
commit_count: None,
author_count: None,
author_entropy: None,
isolation_rate: None,
age_days: None,
last_touch_days: None,
newcomer_rate: None,
explanation: None,
}
}
#[test]
fn multi_axis_risk_ordering() {
let mut entries = vec![
fixture("a.rs"),
fixture("b.rs"),
fixture("c.rs"),
fixture("d.rs"),
fixture("e.rs"),
];
entries[0].activity_risk = Some(3.0);
entries[1].activity_risk = Some(1.0);
entries[2].activity_risk = Some(5.0);
entries[3].activity_risk = Some(2.0);
entries[4].activity_risk = Some(4.0);
let ranked = rank_by_axis(&entries, HotspotAxis::Risk, 10);
let files: Vec<&str> = ranked.iter().map(|r| r.file.as_str()).collect();
assert_eq!(files, vec!["c.rs", "e.rs", "a.rs", "d.rs", "b.rs"]);
}
#[test]
fn multi_axis_coupling_excludes_zeros() {
let mut entries = vec![fixture("a.rs"), fixture("b.rs"), fixture("c.rs")];
entries[0].directed_coupling = Some(0.0);
entries[1].directed_coupling = Some(2.5);
entries[2].directed_coupling = None;
let ranked = rank_by_axis(&entries, HotspotAxis::Coupling, 10);
let files: Vec<&str> = ranked.iter().map(|r| r.file.as_str()).collect();
assert_eq!(files, vec!["b.rs"]);
}
#[test]
fn multi_axis_ownership_excludes_no_window_data() {
let mut entries = vec![fixture("a.rs"), fixture("b.rs"), fixture("c.rs")];
entries[0].newcomer_rate = Some(0.5);
entries[1].newcomer_rate = None;
entries[2].newcomer_rate = Some(0.0);
let ranked = rank_by_axis(&entries, HotspotAxis::Ownership, 10);
let files: Vec<&str> = ranked.iter().map(|r| r.file.as_str()).collect();
assert_eq!(files, vec!["a.rs", "c.rs"]);
}
#[test]
fn multi_axis_centrality_ordering() {
let mut entries = vec![fixture("a.rs"), fixture("b.rs"), fixture("c.rs")];
entries[0].callgraph = Some(callgraph_with_pagerank(0.05));
entries[1].callgraph = Some(callgraph_with_pagerank(0.20));
entries[2].callgraph = Some(callgraph_with_pagerank(0.10));
let ranked = rank_by_axis(&entries, HotspotAxis::Centrality, 10);
let files: Vec<&str> = ranked.iter().map(|r| r.file.as_str()).collect();
assert_eq!(files, vec!["b.rs", "c.rs", "a.rs"]);
}
#[test]
fn multi_axis_centrality_excludes_missing_callgraph() {
let mut entries = vec![fixture("a.rs"), fixture("b.rs"), fixture("c.rs")];
entries[0].callgraph = Some(callgraph_with_pagerank(0.15));
entries[1].callgraph = None;
let mut c2 = fixture("c.rs");
entries[2].callgraph = Some(callgraph_with_pagerank(0.05));
c2.callgraph = Some(callgraph_with_pagerank(0.30));
entries.push(c2);
let ranked = rank_by_axis(&entries, HotspotAxis::Centrality, 10);
let files: Vec<&str> = ranked.iter().map(|r| r.file.as_str()).collect();
assert_eq!(files, vec!["c.rs", "a.rs"]);
}
#[test]
fn multi_axis_top_n_respected() {
let mut entries: Vec<FunctionSnapshot> = (0..20)
.map(|i| {
let mut f = fixture(&format!("f{i}.rs"));
f.activity_risk = Some(i as f64);
f
})
.collect();
entries.shuffle_deterministic();
let ranked = rank_by_axis(&entries, HotspotAxis::Risk, 5);
assert_eq!(ranked.len(), 5);
}
trait ShuffleDeterministic {
fn shuffle_deterministic(&mut self);
}
impl<T> ShuffleDeterministic for Vec<T> {
fn shuffle_deterministic(&mut self) {
self.reverse();
}
}
}