use super::{AnalysisTarget, Context, ContextDetails, ContextProvider};
use anyhow::Result;
use im::{HashMap, HashSet, Vector};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Module {
pub name: String,
pub path: PathBuf,
pub intrinsic_risk: f64,
pub functions: Vector<String>,
}
#[derive(Debug, Clone)]
pub struct DependencyEdge {
pub from: String,
pub to: String,
pub coupling_strength: f64,
}
#[derive(Debug, Clone)]
pub struct DependencyGraph {
modules: HashMap<String, Module>,
edges: Vector<DependencyEdge>,
}
impl Default for DependencyGraph {
fn default() -> Self {
Self::new()
}
}
impl DependencyGraph {
pub fn new() -> Self {
Self {
modules: HashMap::new(),
edges: Vector::new(),
}
}
pub fn add_module(&mut self, module: Module) {
self.modules.insert(module.name.clone(), module);
}
pub fn add_dependency(&mut self, from: String, to: String, coupling_strength: f64) {
self.edges.push_back(DependencyEdge {
from,
to,
coupling_strength,
});
}
pub fn get_dependencies(&self, module: &str) -> Vector<&DependencyEdge> {
self.edges.iter().filter(|e| e.from == module).collect()
}
pub fn get_dependents(&self, module: &str) -> Vector<&DependencyEdge> {
self.edges.iter().filter(|e| e.to == module).collect()
}
pub fn get_module(&self, name: &str) -> Option<&Module> {
self.modules.get(name)
}
pub fn modules(&self) -> impl Iterator<Item = &Module> {
self.modules.values()
}
}
pub struct DependencyRiskCalculator {
graph: DependencyGraph,
risk_scores: HashMap<String, f64>,
}
impl DependencyRiskCalculator {
pub fn new(graph: DependencyGraph) -> Self {
let mut risk_scores = HashMap::new();
for module in graph.modules() {
risk_scores.insert(module.name.clone(), module.intrinsic_risk);
}
Self { graph, risk_scores }
}
pub fn propagate_risk(&mut self) {
let mut changed = true;
let mut iterations = 0;
const MAX_ITERATIONS: usize = 10;
const CONVERGENCE_THRESHOLD: f64 = 0.01;
while changed && iterations < MAX_ITERATIONS {
changed = false;
let mut new_scores = self.risk_scores.clone();
for module in self.graph.modules() {
let old_risk = self.risk_scores.get(&module.name).copied().unwrap_or(0.0);
let propagated_risk = self.calculate_propagated_risk(&module.name);
if (propagated_risk - old_risk).abs() > CONVERGENCE_THRESHOLD {
new_scores.insert(module.name.clone(), propagated_risk);
changed = true;
}
}
self.risk_scores = new_scores;
iterations += 1;
}
log::debug!("Risk propagation converged after {iterations} iterations");
}
fn calculate_propagated_risk(&self, module_name: &str) -> f64 {
let module = match self.graph.get_module(module_name) {
Some(m) => m,
None => return 0.0,
};
let base_risk = module.intrinsic_risk;
let mut dependency_risk = 0.0;
for dep in self.graph.get_dependencies(module_name) {
let dep_risk = self.risk_scores.get(&dep.to).copied().unwrap_or(0.0);
dependency_risk += dep_risk * dep.coupling_strength * 0.3;
}
let dependents = self.graph.get_dependents(module_name);
let criticality_factor = 1.0 + (dependents.len() as f64 * 0.1).min(0.5);
base_risk * criticality_factor + dependency_risk
}
pub fn calculate_blast_radius(&self, module_name: &str) -> usize {
let mut affected = HashSet::new();
let mut to_visit = Vector::new();
to_visit.push_back(module_name.to_string());
while let Some(current) = to_visit.pop_front() {
if affected.contains(¤t) {
continue;
}
affected.insert(current.clone());
for dep in self.graph.get_dependents(¤t) {
if !affected.contains(&dep.from) {
to_visit.push_back(dep.from.clone());
}
}
}
affected.len()
}
pub fn get_risk(&self, module_name: &str) -> f64 {
self.risk_scores.get(module_name).copied().unwrap_or(0.0)
}
pub fn find_module_for_function(&self, function_name: &str) -> Option<&Module> {
self.graph
.modules()
.find(|m| m.functions.contains(&function_name.to_string()))
}
}
pub struct DependencyRiskProvider {
calculator: DependencyRiskCalculator,
}
impl DependencyRiskProvider {
pub fn new(graph: DependencyGraph) -> Self {
let mut calculator = DependencyRiskCalculator::new(graph);
calculator.propagate_risk();
Self { calculator }
}
}
impl DependencyRiskProvider {
fn classify_contribution_by_blast_radius(blast_radius: usize) -> f64 {
match blast_radius {
r if r > 10 => 1.5, r if r > 5 => 1.0, r if r > 2 => 0.5, 2 => 0.2, _ => 0.0, }
}
fn extract_module_metrics(&self, module: Option<&Module>) -> (f64, usize, Vec<String>) {
if let Some(module) = module {
let risk = self.calculator.get_risk(&module.name);
let radius = self.calculator.calculate_blast_radius(&module.name);
let deps: Vec<String> = self
.calculator
.graph
.get_dependents(&module.name)
.iter()
.map(|d| d.from.clone())
.collect();
(risk, radius, deps)
} else {
(0.0, 0, vec![])
}
}
}
impl ContextProvider for DependencyRiskProvider {
fn name(&self) -> &str {
"dependency_risk"
}
fn gather(&self, target: &AnalysisTarget) -> Result<Context> {
let module = self
.calculator
.find_module_for_function(&target.function_name);
let (propagated_risk, blast_radius, dependents) = self.extract_module_metrics(module);
let depth = self.calculate_dependency_depth(&target.function_name);
let contribution = Self::classify_contribution_by_blast_radius(blast_radius);
Ok(Context {
provider: self.name().to_string(),
weight: self.weight(),
contribution,
details: ContextDetails::DependencyChain {
depth,
propagated_risk,
dependents,
blast_radius,
},
})
}
fn weight(&self) -> f64 {
1.2 }
fn explain(&self, context: &Context) -> String {
match &context.details {
ContextDetails::DependencyChain {
depth,
propagated_risk,
dependents,
blast_radius,
} => Self::classify_blast_radius_impact(
*blast_radius,
*depth,
*propagated_risk,
dependents.len(),
),
_ => "No dependency information".to_string(),
}
}
}
impl DependencyRiskProvider {
fn classify_blast_radius_impact(
blast_radius: usize,
depth: usize,
propagated_risk: f64,
dependents_count: usize,
) -> String {
match blast_radius {
r if r > 10 => format!(
"Critical dependency with blast radius {} affecting {} modules",
blast_radius, dependents_count
),
r if r > 5 => format!(
"Important dependency with {} dependents (risk: {:.1})",
dependents_count, propagated_risk
),
r if r > 0 => format!("Dependency depth {} with limited impact", depth),
_ => "Isolated component with no dependencies".to_string(),
}
}
fn calculate_dependency_depth(&self, function_name: &str) -> usize {
if let Some(module) = self.calculator.find_module_for_function(function_name) {
self.calculate_module_depth_iterative(&module.name)
} else {
0
}
}
fn calculate_module_depth_iterative(&self, start_module: &str) -> usize {
let mut visited = HashSet::new();
let mut max_depth = 0;
let mut stack: Vec<(String, usize)> = vec![(start_module.to_string(), 0)];
while let Some((module_name, depth)) = stack.pop() {
if visited.contains(&module_name) {
continue;
}
visited.insert(module_name.clone());
max_depth = max_depth.max(depth);
let deps = self.calculator.graph.get_dependencies(&module_name);
for dep in deps {
if !visited.contains(&dep.to) {
stack.push((dep.to.clone(), depth + 1));
}
}
}
max_depth
}
}
#[cfg(test)]
mod tests {
use super::*;
use im::vector;
#[test]
fn test_dependency_graph() {
let mut graph = DependencyGraph::new();
graph.add_module(Module {
name: "core".to_string(),
path: PathBuf::from("src/core"),
intrinsic_risk: 5.0,
functions: vector!["process".to_string()],
});
graph.add_module(Module {
name: "api".to_string(),
path: PathBuf::from("src/api"),
intrinsic_risk: 3.0,
functions: vector!["handle".to_string()],
});
graph.add_dependency("api".to_string(), "core".to_string(), 0.8);
let deps = graph.get_dependencies("api");
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].to, "core");
let dependents = graph.get_dependents("core");
assert_eq!(dependents.len(), 1);
assert_eq!(dependents[0].from, "api");
}
#[test]
fn test_risk_propagation() {
let mut graph = DependencyGraph::new();
graph.add_module(Module {
name: "high_risk".to_string(),
path: PathBuf::from("src/high"),
intrinsic_risk: 8.0,
functions: vector!["critical".to_string()],
});
graph.add_module(Module {
name: "low_risk".to_string(),
path: PathBuf::from("src/low"),
intrinsic_risk: 2.0,
functions: vector!["simple".to_string()],
});
graph.add_dependency("low_risk".to_string(), "high_risk".to_string(), 0.7);
let mut calculator = DependencyRiskCalculator::new(graph);
calculator.propagate_risk();
let low_risk_score = calculator.get_risk("low_risk");
assert!(low_risk_score > 2.0); assert!(low_risk_score < 8.0); }
#[test]
fn test_blast_radius() {
let mut graph = DependencyGraph::new();
for i in 0..5 {
graph.add_module(Module {
name: format!("module_{i}"),
path: PathBuf::from(format!("src/mod{i}")),
intrinsic_risk: 3.0,
functions: vector![format!("func_{}", i)],
});
}
for i in 0..4 {
graph.add_dependency(format!("module_{}", i + 1), format!("module_{i}"), 0.5);
}
let calculator = DependencyRiskCalculator::new(graph);
assert_eq!(calculator.calculate_blast_radius("module_4"), 1);
assert_eq!(calculator.calculate_blast_radius("module_3"), 2);
assert_eq!(calculator.calculate_blast_radius("module_2"), 3);
assert_eq!(calculator.calculate_blast_radius("module_0"), 5);
}
#[test]
fn test_explain_critical_dependency_blast_radius_over_10() {
let provider = DependencyRiskProvider {
calculator: DependencyRiskCalculator::new(DependencyGraph::new()),
};
let context = Context {
provider: "dependency".to_string(),
weight: 1.2,
contribution: 5.0,
details: ContextDetails::DependencyChain {
depth: 3,
propagated_risk: 8.5,
dependents: vec!["module_a".to_string(), "module_b".to_string()],
blast_radius: 15,
},
};
let explanation = provider.explain(&context);
assert_eq!(
explanation,
"Critical dependency with blast radius 15 affecting 2 modules"
);
}
#[test]
fn test_explain_important_dependency_blast_radius_6_to_10() {
let provider = DependencyRiskProvider {
calculator: DependencyRiskCalculator::new(DependencyGraph::new()),
};
let context = Context {
provider: "dependency".to_string(),
weight: 1.2,
contribution: 3.0,
details: ContextDetails::DependencyChain {
depth: 2,
propagated_risk: 6.3,
dependents: vec![
"module_x".to_string(),
"module_y".to_string(),
"module_z".to_string(),
],
blast_radius: 7,
},
};
let explanation = provider.explain(&context);
assert_eq!(
explanation,
"Important dependency with 3 dependents (risk: 6.3)"
);
}
#[test]
fn test_explain_limited_impact_blast_radius_1_to_5() {
let provider = DependencyRiskProvider {
calculator: DependencyRiskCalculator::new(DependencyGraph::new()),
};
let context = Context {
provider: "dependency".to_string(),
weight: 1.2,
contribution: 1.5,
details: ContextDetails::DependencyChain {
depth: 1,
propagated_risk: 2.0,
dependents: vec!["module_single".to_string()],
blast_radius: 3,
},
};
let explanation = provider.explain(&context);
assert_eq!(explanation, "Dependency depth 1 with limited impact");
}
#[test]
fn test_explain_isolated_component_blast_radius_0() {
let provider = DependencyRiskProvider {
calculator: DependencyRiskCalculator::new(DependencyGraph::new()),
};
let context = Context {
provider: "dependency".to_string(),
weight: 1.2,
contribution: 0.0,
details: ContextDetails::DependencyChain {
depth: 0,
propagated_risk: 0.0,
dependents: vec![],
blast_radius: 0,
},
};
let explanation = provider.explain(&context);
assert_eq!(explanation, "Isolated component with no dependencies");
}
#[test]
fn test_explain_non_dependency_context() {
let provider = DependencyRiskProvider {
calculator: DependencyRiskCalculator::new(DependencyGraph::new()),
};
let context = Context {
provider: "dependency".to_string(),
weight: 1.2,
contribution: 0.0,
details: ContextDetails::Historical {
change_frequency: 5.0,
bug_density: 0.1,
age_days: 365,
author_count: 3,
total_commits: 10,
bug_fix_count: 1,
},
};
let explanation = provider.explain(&context);
assert_eq!(explanation, "No dependency information");
}
#[test]
fn test_gather_with_high_blast_radius() {
let mut graph = DependencyGraph::new();
graph.add_module(Module {
name: "core".to_string(),
path: PathBuf::from("src/core"),
intrinsic_risk: 5.0,
functions: vector!["critical_function".to_string()],
});
for i in 0..12 {
graph.add_module(Module {
name: format!("dependent_{i}"),
path: PathBuf::from(format!("src/dep{i}")),
intrinsic_risk: 2.0,
functions: vector![format!("func_{i}")],
});
graph.add_dependency(format!("dependent_{i}"), "core".to_string(), 0.8);
}
let calculator = DependencyRiskCalculator::new(graph);
let provider = DependencyRiskProvider { calculator };
let target = AnalysisTarget {
root_path: PathBuf::from("."),
file_path: PathBuf::from("src/core/lib.rs"),
function_name: "critical_function".to_string(),
line_range: (10, 50),
reference_time: chrono::Utc::now(),
};
let context = provider.gather(&target).unwrap();
assert_eq!(context.provider, "dependency_risk");
assert_eq!(context.weight, 1.2);
assert_eq!(context.contribution, 1.5);
match context.details {
ContextDetails::DependencyChain {
blast_radius,
dependents,
..
} => {
assert!(blast_radius > 10);
assert_eq!(dependents.len(), 12);
}
_ => panic!("Expected DependencyChain details"),
}
}
#[test]
fn test_gather_with_medium_blast_radius() {
let mut graph = DependencyGraph::new();
graph.add_module(Module {
name: "service".to_string(),
path: PathBuf::from("src/service"),
intrinsic_risk: 4.0,
functions: vector!["process".to_string()],
});
for i in 0..7 {
graph.add_module(Module {
name: format!("consumer_{i}"),
path: PathBuf::from(format!("src/consumer{i}")),
intrinsic_risk: 2.0,
functions: vector![format!("use_{i}")],
});
graph.add_dependency(format!("consumer_{i}"), "service".to_string(), 0.6);
}
let calculator = DependencyRiskCalculator::new(graph);
let provider = DependencyRiskProvider { calculator };
let target = AnalysisTarget {
root_path: PathBuf::from("."),
file_path: PathBuf::from("src/service/mod.rs"),
function_name: "process".to_string(),
line_range: (5, 25),
reference_time: chrono::Utc::now(),
};
let context = provider.gather(&target).unwrap();
assert_eq!(context.contribution, 1.0);
match context.details {
ContextDetails::DependencyChain { blast_radius, .. } => {
assert!(blast_radius > 5);
assert!(blast_radius <= 10);
}
_ => panic!("Expected DependencyChain details"),
}
}
#[test]
fn test_gather_with_low_blast_radius() {
let mut graph = DependencyGraph::new();
graph.add_module(Module {
name: "util".to_string(),
path: PathBuf::from("src/util"),
intrinsic_risk: 3.0,
functions: vector!["helper".to_string()],
});
for i in 0..3 {
graph.add_module(Module {
name: format!("user_{i}"),
path: PathBuf::from(format!("src/user{i}")),
intrinsic_risk: 2.0,
functions: vector![format!("call_{i}")],
});
graph.add_dependency(format!("user_{i}"), "util".to_string(), 0.5);
}
let calculator = DependencyRiskCalculator::new(graph);
let provider = DependencyRiskProvider { calculator };
let target = AnalysisTarget {
root_path: PathBuf::from("."),
file_path: PathBuf::from("src/util/helpers.rs"),
function_name: "helper".to_string(),
line_range: (1, 10),
reference_time: chrono::Utc::now(),
};
let context = provider.gather(&target).unwrap();
assert_eq!(context.contribution, 0.5);
match context.details {
ContextDetails::DependencyChain { blast_radius, .. } => {
assert!(blast_radius > 2);
assert!(blast_radius <= 5);
}
_ => panic!("Expected DependencyChain details"),
}
}
#[test]
fn test_gather_with_minimal_blast_radius() {
let mut graph = DependencyGraph::new();
graph.add_module(Module {
name: "leaf".to_string(),
path: PathBuf::from("src/leaf"),
intrinsic_risk: 2.0,
functions: vector!["simple".to_string()],
});
graph.add_module(Module {
name: "caller".to_string(),
path: PathBuf::from("src/caller"),
intrinsic_risk: 2.0,
functions: vector!["invoke".to_string()],
});
graph.add_dependency("caller".to_string(), "leaf".to_string(), 0.3);
let calculator = DependencyRiskCalculator::new(graph);
let provider = DependencyRiskProvider { calculator };
let target = AnalysisTarget {
root_path: PathBuf::from("."),
file_path: PathBuf::from("src/leaf/simple.rs"),
function_name: "simple".to_string(),
line_range: (1, 5),
reference_time: chrono::Utc::now(),
};
let context = provider.gather(&target).unwrap();
assert_eq!(context.contribution, 0.2);
match context.details {
ContextDetails::DependencyChain {
blast_radius,
dependents,
..
} => {
assert!(blast_radius <= 2);
assert_eq!(dependents.len(), 1);
}
_ => panic!("Expected DependencyChain details"),
}
}
#[test]
fn test_gather_without_module() {
let graph = DependencyGraph::new();
let calculator = DependencyRiskCalculator::new(graph);
let provider = DependencyRiskProvider { calculator };
let target = AnalysisTarget {
root_path: PathBuf::from("."),
file_path: PathBuf::from("src/unknown/file.rs"),
function_name: "orphan_function".to_string(),
line_range: (1, 10),
reference_time: chrono::Utc::now(),
};
let context = provider.gather(&target).unwrap();
assert_eq!(context.provider, "dependency_risk");
assert_eq!(context.weight, 1.2);
assert_eq!(context.contribution, 0.0);
match context.details {
ContextDetails::DependencyChain {
depth,
propagated_risk,
dependents,
blast_radius,
} => {
assert_eq!(depth, 0);
assert_eq!(propagated_risk, 0.0);
assert!(dependents.is_empty());
assert_eq!(blast_radius, 0);
}
_ => panic!("Expected DependencyChain details"),
}
}
#[test]
fn test_classify_contribution_critical_blast_radius() {
assert_eq!(
DependencyRiskProvider::classify_contribution_by_blast_radius(15),
1.5
);
assert_eq!(
DependencyRiskProvider::classify_contribution_by_blast_radius(11),
1.5
);
assert_eq!(
DependencyRiskProvider::classify_contribution_by_blast_radius(100),
1.5
);
}
#[test]
fn test_classify_contribution_high_blast_radius() {
assert_eq!(
DependencyRiskProvider::classify_contribution_by_blast_radius(6),
1.0
);
assert_eq!(
DependencyRiskProvider::classify_contribution_by_blast_radius(8),
1.0
);
assert_eq!(
DependencyRiskProvider::classify_contribution_by_blast_radius(10),
1.0
);
}
#[test]
fn test_classify_contribution_medium_blast_radius() {
assert_eq!(
DependencyRiskProvider::classify_contribution_by_blast_radius(3),
0.5
);
assert_eq!(
DependencyRiskProvider::classify_contribution_by_blast_radius(4),
0.5
);
assert_eq!(
DependencyRiskProvider::classify_contribution_by_blast_radius(5),
0.5
);
}
#[test]
fn test_classify_contribution_isolated() {
assert_eq!(
DependencyRiskProvider::classify_contribution_by_blast_radius(0),
0.0
);
assert_eq!(
DependencyRiskProvider::classify_contribution_by_blast_radius(1),
0.0
);
}
#[test]
fn test_classify_contribution_low_blast_radius() {
assert_eq!(
DependencyRiskProvider::classify_contribution_by_blast_radius(2),
0.2
);
}
#[test]
fn test_extract_module_metrics_with_module() {
let mut graph = DependencyGraph::new();
graph.add_module(Module {
name: "test_module".to_string(),
path: PathBuf::from("src/test"),
intrinsic_risk: 5.0,
functions: vector!["test_func".to_string()],
});
for i in 0..3 {
graph.add_module(Module {
name: format!("dependent_{i}"),
path: PathBuf::from(format!("src/dep{i}")),
intrinsic_risk: 2.0,
functions: vector![format!("func_{i}")],
});
graph.add_dependency(format!("dependent_{i}"), "test_module".to_string(), 0.5);
}
let calculator = DependencyRiskCalculator::new(graph.clone());
let provider = DependencyRiskProvider { calculator };
let module = graph.modules.get("test_module");
let (risk, radius, deps) = provider.extract_module_metrics(module);
assert_eq!(risk, 5.0); assert_eq!(radius, 4); assert_eq!(deps.len(), 3); assert!(deps.contains(&"dependent_0".to_string()));
assert!(deps.contains(&"dependent_1".to_string()));
assert!(deps.contains(&"dependent_2".to_string()));
}
#[test]
fn test_extract_module_metrics_without_module() {
let graph = DependencyGraph::new();
let calculator = DependencyRiskCalculator::new(graph);
let provider = DependencyRiskProvider { calculator };
let (risk, radius, deps) = provider.extract_module_metrics(None);
assert_eq!(risk, 0.0);
assert_eq!(radius, 0);
assert!(deps.is_empty());
}
#[test]
fn test_gather_with_module_no_dependencies() {
let mut graph = DependencyGraph::new();
graph.add_module(Module {
name: "isolated".to_string(),
path: PathBuf::from("src/isolated"),
intrinsic_risk: 3.0,
functions: vector!["standalone".to_string()],
});
let calculator = DependencyRiskCalculator::new(graph);
let provider = DependencyRiskProvider { calculator };
let target = AnalysisTarget {
root_path: PathBuf::from("."),
file_path: PathBuf::from("src/isolated/mod.rs"),
function_name: "standalone".to_string(),
line_range: (10, 20),
reference_time: chrono::Utc::now(),
};
let context = provider.gather(&target).unwrap();
assert_eq!(context.contribution, 0.0);
match context.details {
ContextDetails::DependencyChain {
blast_radius,
dependents,
propagated_risk,
..
} => {
assert_eq!(blast_radius, 1); assert!(dependents.is_empty());
assert_eq!(propagated_risk, 3.0); }
_ => panic!("Expected DependencyChain details"),
}
}
#[test]
fn test_classify_blast_radius_critical() {
let result = DependencyRiskProvider::classify_blast_radius_impact(15, 3, 8.5, 12);
assert_eq!(
result,
"Critical dependency with blast radius 15 affecting 12 modules"
);
let result = DependencyRiskProvider::classify_blast_radius_impact(11, 2, 7.0, 8);
assert_eq!(
result,
"Critical dependency with blast radius 11 affecting 8 modules"
);
}
#[test]
fn test_classify_blast_radius_important() {
let result = DependencyRiskProvider::classify_blast_radius_impact(6, 2, 5.5, 4);
assert_eq!(result, "Important dependency with 4 dependents (risk: 5.5)");
let result = DependencyRiskProvider::classify_blast_radius_impact(10, 3, 7.2, 7);
assert_eq!(result, "Important dependency with 7 dependents (risk: 7.2)");
}
#[test]
fn test_classify_blast_radius_limited() {
let result = DependencyRiskProvider::classify_blast_radius_impact(1, 5, 2.0, 1);
assert_eq!(result, "Dependency depth 5 with limited impact");
let result = DependencyRiskProvider::classify_blast_radius_impact(5, 3, 3.5, 3);
assert_eq!(result, "Dependency depth 3 with limited impact");
}
#[test]
fn test_classify_blast_radius_isolated() {
let result = DependencyRiskProvider::classify_blast_radius_impact(0, 0, 0.0, 0);
assert_eq!(result, "Isolated component with no dependencies");
}
}