use crate::analysis::flow::brandes_betweenness;
use crate::model::{
Confidence, Edge, EdgeKind, FileRiskScore, RiskFactors, RiskScore, RiskStats, RiskWeights,
SymbolNode,
};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
pub fn compute_criticality_scores(symbols: &[SymbolNode], edges: &[Edge]) -> HashMap<String, f64> {
let nodes: HashSet<String> = symbols.iter().map(|s| s.qualified_name.clone()).collect();
brandes_betweenness(&nodes, edges)
}
pub fn compute_coupling_scores(symbols: &[SymbolNode], edges: &[Edge]) -> HashMap<String, f64> {
let symbol_set: HashSet<&str> = symbols.iter().map(|s| s.qualified_name.as_str()).collect();
let relevant_edges: Vec<&Edge> = edges
.iter()
.filter(|e| e.kind.confidence() != Confidence::Structural)
.filter(|e| {
symbol_set.contains(e.source.as_str()) && symbol_set.contains(e.target.as_str())
})
.collect();
let mut degrees: HashMap<&str, usize> = HashMap::new();
for name in &symbol_set {
degrees.insert(name, 0);
}
for edge in &relevant_edges {
*degrees.entry(edge.source.as_str()).or_default() += 1; *degrees.entry(edge.target.as_str()).or_default() += 1; }
let max_degree = degrees.values().copied().max().unwrap_or(0);
if max_degree == 0 {
return symbols
.iter()
.map(|s| (s.qualified_name.clone(), 0.0))
.collect();
}
symbols
.iter()
.map(|s| {
let deg = degrees.get(s.qualified_name.as_str()).copied().unwrap_or(0);
(s.qualified_name.clone(), deg as f64 / max_degree as f64)
})
.collect()
}
pub fn compute_test_gaps(symbols: &[SymbolNode], edges: &[Edge]) -> HashMap<String, f64> {
let tested: HashSet<&str> = edges
.iter()
.filter(|e| e.kind == EdgeKind::TestedBy)
.map(|e| e.target.as_str())
.collect();
symbols
.iter()
.map(|s| {
let gap = if tested.contains(s.qualified_name.as_str()) {
0.0
} else {
1.0
};
(s.qualified_name.clone(), gap)
})
.collect()
}
pub fn split_into_segments(s: &str) -> Vec<String> {
let mut segments = Vec::new();
for part in s.split([':', '.', '_', '/']) {
if part.is_empty() {
continue;
}
let mut current = String::new();
let chars: Vec<char> = part.chars().collect();
for i in 0..chars.len() {
if i > 0
&& chars[i].is_uppercase()
&& chars[i - 1].is_lowercase()
&& !current.is_empty()
{
segments.push(current.to_lowercase());
current.clear();
}
current.push(chars[i]);
}
if !current.is_empty() {
segments.push(current.to_lowercase());
}
}
segments
}
pub fn compute_sensitivity(symbols: &[SymbolNode], patterns: &[String]) -> HashMap<String, f64> {
let lower_patterns: Vec<String> = patterns.iter().map(|p| p.to_lowercase()).collect();
symbols
.iter()
.map(|s| {
let mut all_segments = split_into_segments(&s.qualified_name);
for decorator in &s.decorators {
all_segments.extend(split_into_segments(decorator));
}
let matched = all_segments.iter().any(|segment| {
lower_patterns
.iter()
.any(|pattern| segment.starts_with(pattern))
});
(s.qualified_name.clone(), if matched { 1.0 } else { 0.0 })
})
.collect()
}
pub fn score_symbols(
symbols: &[SymbolNode],
criticality: &HashMap<String, f64>,
coupling: &HashMap<String, f64>,
test_gaps: &HashMap<String, f64>,
sensitivity: &HashMap<String, f64>,
weights: &RiskWeights,
) -> Vec<RiskScore> {
let w = weights.normalized();
let mut scores: Vec<RiskScore> = symbols
.iter()
.map(|s| {
let name = &s.qualified_name;
let crit = criticality.get(name).copied().unwrap_or(0.0);
let coup = coupling.get(name).copied().unwrap_or(0.0);
let tgap = test_gaps.get(name).copied().unwrap_or(1.0);
let sens = sensitivity.get(name).copied().unwrap_or(0.0);
let composite = (w.criticality * crit
+ w.coupling * coup
+ w.test_gap * tgap
+ w.sensitivity * sens)
.clamp(0.0, 1.0);
RiskScore {
qualified_name: name.clone(),
composite,
factors: RiskFactors {
criticality: crit,
coupling: coup,
test_gap: tgap,
sensitivity: sens,
},
}
})
.collect();
scores.sort_by(|a, b| {
b.composite
.partial_cmp(&a.composite)
.unwrap_or(std::cmp::Ordering::Equal)
});
scores
}
pub fn aggregate_file_scores(
symbol_scores: &[RiskScore],
symbols: &[SymbolNode],
) -> Vec<FileRiskScore> {
let name_to_file: HashMap<&str, &PathBuf> = symbols
.iter()
.map(|s| (s.qualified_name.as_str(), &s.location.file))
.collect();
let mut file_map: HashMap<PathBuf, (f64, usize, String)> = HashMap::new();
for score in symbol_scores {
if let Some(&file) = name_to_file.get(score.qualified_name.as_str()) {
let entry = file_map
.entry(file.clone())
.or_insert((0.0, 0, String::new()));
entry.1 += 1; if score.composite > entry.0 {
entry.0 = score.composite;
entry.2 = score.qualified_name.clone();
}
}
}
let mut file_scores: Vec<FileRiskScore> = file_map
.into_iter()
.map(
|(path, (composite, symbol_count, highest_symbol))| FileRiskScore {
path,
composite,
symbol_count,
highest_symbol,
},
)
.collect();
file_scores.sort_by(|a, b| {
b.composite
.partial_cmp(&a.composite)
.unwrap_or(std::cmp::Ordering::Equal)
});
file_scores
}
pub fn compute_risk_stats(scores: &[RiskScore], file_count: usize) -> RiskStats {
if scores.is_empty() {
return RiskStats {
symbols_scored: 0,
files_scored: file_count,
avg_risk: 0.0,
median_risk: 0.0,
p90_risk: 0.0,
};
}
let mut composites: Vec<f64> = scores.iter().map(|s| s.composite).collect();
composites.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = composites.len();
let avg = composites.iter().sum::<f64>() / n as f64;
let median = if n.is_multiple_of(2) {
(composites[n / 2 - 1] + composites[n / 2]) / 2.0
} else {
composites[n / 2]
};
let p90_idx = ((n as f64) * 0.9).ceil() as usize;
let p90 = composites[p90_idx.min(n - 1)];
RiskStats {
symbols_scored: n,
files_scored: file_count,
avg_risk: avg,
median_risk: median,
p90_risk: p90,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Edge, EdgeKind, Location, RiskWeights, SymbolKind, SymbolNode, Visibility};
fn make_symbol(name: &str, file: &str) -> SymbolNode {
SymbolNode {
name: name.split("::").last().unwrap_or(name).into(),
qualified_name: name.into(),
kind: SymbolKind::Function,
location: Location {
file: file.into(),
line_start: 1,
line_end: 10,
col_start: 0,
col_end: 0,
},
visibility: Visibility::Public,
is_exported: true,
is_async: false,
is_test: false,
decorators: vec![],
signature: None,
}
}
fn make_edge(source: &str, target: &str, kind: EdgeKind) -> Edge {
Edge {
kind,
source: source.into(),
target: target.into(),
metadata: None,
}
}
#[test]
fn test_criticality_delegates_to_brandes() {
let symbols = vec![
make_symbol("a::A", "a.rs"),
make_symbol("b::B", "b.rs"),
make_symbol("c::C", "c.rs"),
];
let edges = vec![
make_edge("a::A", "b::B", EdgeKind::Calls),
make_edge("b::B", "c::C", EdgeKind::Calls),
];
let scores = compute_criticality_scores(&symbols, &edges);
assert!(scores.get("b::B").unwrap_or(&0.0) >= scores.get("a::A").unwrap_or(&0.0));
assert!(scores.get("b::B").unwrap_or(&0.0) >= scores.get("c::C").unwrap_or(&0.0));
}
#[test]
fn test_coupling_excludes_structural_edges() {
let symbols = vec![make_symbol("a::A", "a.rs"), make_symbol("b::B", "b.rs")];
let edges = vec![
make_edge("a::A", "b::B", EdgeKind::Calls), make_edge("a::A", "b::B", EdgeKind::Contains), ];
let scores = compute_coupling_scores(&symbols, &edges);
assert!((scores["a::A"] - 1.0).abs() < f64::EPSILON);
assert!((scores["b::B"] - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_coupling_both_endpoints_must_be_symbols() {
let symbols = vec![make_symbol("a::A", "a.rs")];
let edges = vec![
make_edge("a::A", "file.rs", EdgeKind::Calls), ];
let scores = compute_coupling_scores(&symbols, &edges);
assert!((scores["a::A"]).abs() < f64::EPSILON);
}
#[test]
fn test_coupling_max_degree_zero() {
let symbols = vec![make_symbol("a::A", "a.rs"), make_symbol("b::B", "b.rs")];
let edges: Vec<Edge> = vec![];
let scores = compute_coupling_scores(&symbols, &edges);
assert!((scores["a::A"]).abs() < f64::EPSILON);
assert!((scores["b::B"]).abs() < f64::EPSILON);
}
#[test]
fn test_coupling_normalization() {
let symbols = vec![
make_symbol("a::A", "a.rs"),
make_symbol("b::B", "b.rs"),
make_symbol("c::C", "c.rs"),
];
let edges = vec![
make_edge("a::A", "b::B", EdgeKind::Calls),
make_edge("a::A", "c::C", EdgeKind::Calls),
make_edge("b::B", "c::C", EdgeKind::Calls),
];
let scores = compute_coupling_scores(&symbols, &edges);
assert!((scores["a::A"] - 1.0).abs() < f64::EPSILON);
assert!((scores["b::B"] - 1.0).abs() < f64::EPSILON);
assert!((scores["c::C"] - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_test_gap_untested() {
let symbols = vec![make_symbol("a::A", "a.rs")];
let edges: Vec<Edge> = vec![]; let scores = compute_test_gaps(&symbols, &edges);
assert!((scores["a::A"] - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_test_gap_tested() {
let symbols = vec![make_symbol("a::A", "a.rs")];
let edges = vec![make_edge("test::test_a", "a::A", EdgeKind::TestedBy)];
let scores = compute_test_gaps(&symbols, &edges);
assert!((scores["a::A"]).abs() < f64::EPSILON);
}
#[test]
fn test_sensitivity_word_boundary() {
let symbols = vec![
make_symbol("src/auth.rs::auth_service", "src/auth.rs"),
make_symbol("src/lib.rs::HashMap", "src/lib.rs"),
];
let patterns = vec!["auth".into(), "hash".into()];
let scores = compute_sensitivity(&symbols, &patterns);
assert!((scores["src/auth.rs::auth_service"] - 1.0).abs() < f64::EPSILON);
assert!((scores["src/lib.rs::HashMap"] - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_sensitivity_camel_case() {
let symbols = vec![make_symbol("mod::AuthToken", "mod.rs")];
let patterns = vec!["auth".into()];
let scores = compute_sensitivity(&symbols, &patterns);
assert!((scores["mod::AuthToken"] - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_sensitivity_decorators() {
let mut sym = make_symbol("mod::handler", "mod.rs");
sym.decorators = vec!["auth_required".into()];
let patterns = vec!["auth".into()];
let scores = compute_sensitivity(&[sym], &patterns);
assert!((scores["mod::handler"] - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_sensitivity_no_match() {
let symbols = vec![make_symbol("mod::foo_bar", "mod.rs")];
let patterns = vec!["auth".into(), "sql".into()];
let scores = compute_sensitivity(&symbols, &patterns);
assert!((scores["mod::foo_bar"]).abs() < f64::EPSILON);
}
#[test]
fn test_split_segments() {
let segments = split_into_segments("src/lib.rs::AuthService");
assert!(segments.contains(&"auth".to_string()));
assert!(segments.contains(&"service".to_string()));
assert!(segments.contains(&"src".to_string()));
assert!(segments.contains(&"lib".to_string()));
}
#[test]
fn test_score_symbols_weighted_sum() {
let symbols = vec![make_symbol("a::A", "a.rs")];
let mut crit = HashMap::new();
crit.insert("a::A".to_string(), 0.8);
let mut coup = HashMap::new();
coup.insert("a::A".to_string(), 0.6);
let mut tgap = HashMap::new();
tgap.insert("a::A".to_string(), 1.0);
let mut sens = HashMap::new();
sens.insert("a::A".to_string(), 0.5);
let weights = RiskWeights::default(); let scores = score_symbols(&symbols, &crit, &coup, &tgap, &sens, &weights);
assert!((scores[0].composite - 0.74).abs() < 0.001);
}
#[test]
fn test_aggregate_file_scores() {
let symbols = vec![make_symbol("a::X", "a.rs"), make_symbol("a::Y", "a.rs")];
let scores = vec![
RiskScore {
qualified_name: "a::X".into(),
composite: 0.8,
factors: RiskFactors {
criticality: 0.0,
coupling: 0.0,
test_gap: 0.0,
sensitivity: 0.0,
},
},
RiskScore {
qualified_name: "a::Y".into(),
composite: 0.5,
factors: RiskFactors {
criticality: 0.0,
coupling: 0.0,
test_gap: 0.0,
sensitivity: 0.0,
},
},
];
let file_scores = aggregate_file_scores(&scores, &symbols);
assert_eq!(file_scores.len(), 1);
assert!((file_scores[0].composite - 0.8).abs() < f64::EPSILON);
assert_eq!(file_scores[0].highest_symbol, "a::X");
assert_eq!(file_scores[0].symbol_count, 2);
}
#[test]
fn test_risk_stats_median() {
let scores = vec![
RiskScore {
qualified_name: "a".into(),
composite: 0.1,
factors: RiskFactors {
criticality: 0.0,
coupling: 0.0,
test_gap: 0.0,
sensitivity: 0.0,
},
},
RiskScore {
qualified_name: "b".into(),
composite: 0.5,
factors: RiskFactors {
criticality: 0.0,
coupling: 0.0,
test_gap: 0.0,
sensitivity: 0.0,
},
},
RiskScore {
qualified_name: "c".into(),
composite: 0.9,
factors: RiskFactors {
criticality: 0.0,
coupling: 0.0,
test_gap: 0.0,
sensitivity: 0.0,
},
},
];
let stats = compute_risk_stats(&scores, 2);
assert!((stats.median_risk - 0.5).abs() < f64::EPSILON);
assert!((stats.avg_risk - 0.5).abs() < f64::EPSILON);
}
}