use crate::detectors::base::{Detector, DetectorConfig};
use crate::graph::GraphClient;
use crate::models::{Finding, Severity};
use anyhow::Result;
use std::collections::{HashMap, HashSet};
use tracing::{debug, info};
use uuid::Uuid;
pub struct DegreeCentralityDetector {
config: DetectorConfig,
high_complexity_threshold: u32,
high_percentile: f64,
min_indegree: usize,
min_outdegree: usize,
}
impl DegreeCentralityDetector {
pub fn new() -> Self {
Self {
config: DetectorConfig::new(),
high_complexity_threshold: 15,
high_percentile: 95.0,
min_indegree: 5,
min_outdegree: 21, }
}
pub fn with_config(config: DetectorConfig) -> Self {
Self {
high_complexity_threshold: config.get_option_or("high_complexity_threshold", 15),
high_percentile: config.get_option_or("high_percentile", 95.0),
min_indegree: config.get_option_or("min_indegree", 5),
min_outdegree: config.get_option_or("min_outdegree", 21),
config,
}
}
fn create_god_class_finding(
&self,
name: &str,
qualified_name: &str,
file_path: &str,
in_degree: usize,
out_degree: usize,
complexity: u32,
loc: u32,
max_in_degree: usize,
threshold: usize,
) -> Finding {
let percentile = if max_in_degree > 0 {
(in_degree as f64 / max_in_degree as f64) * 100.0
} else {
0.0
};
let severity = if complexity >= self.high_complexity_threshold * 2 || percentile >= 99.0 {
Severity::Critical
} else if complexity >= (self.high_complexity_threshold * 3 / 2) || percentile >= 97.0 {
Severity::High
} else {
Severity::Medium
};
let description = format!(
"File `{}` is a potential **God Class**: high in-degree \
({} dependents) combined with high complexity ({}).\n\n\
**What this means:**\n\
- Many functions depend on this code ({} callers)\n\
- The code itself is complex (complexity: {})\n\
- Changes are high-risk with wide blast radius\n\
- This is a maintainability bottleneck\n\n\
**Metrics:**\n\
- In-degree: {} (threshold: {})\n\
- Complexity: {}\n\
- Lines of code: {}\n\
- Out-degree: {}",
name,
in_degree,
complexity,
in_degree,
complexity,
in_degree,
threshold,
complexity,
loc,
out_degree
);
let suggested_fix = "\
**For God Classes:**\n\n\
1. **Extract interfaces**: Define contracts to reduce coupling\n\n\
2. **Split responsibilities**: Break into focused modules using SRP\n\n\
3. **Use dependency injection**: Reduce direct imports\n\n\
4. **Add abstraction layers**: Shield dependents from changes\n\n\
5. **Prioritize test coverage**: High-risk code needs safety net"
.to_string();
let estimated_effort = match severity {
Severity::Critical => "Large (1-2 days)",
Severity::High => "Large (4-8 hours)",
_ => "Medium (2-4 hours)",
};
Finding {
id: Uuid::new_v4().to_string(),
detector: "DegreeCentralityDetector".to_string(),
severity,
title: format!("God Class: {}", name),
description,
affected_files: vec![file_path.into()],
line_start: None,
line_end: None,
suggested_fix: Some(suggested_fix),
estimated_effort: Some(estimated_effort.to_string()),
category: Some("architecture".to_string()),
cwe_id: None,
why_it_matters: Some(
"God Classes violate the Single Responsibility Principle. \
They accumulate too many responsibilities, making them hard to \
understand, test, and maintain."
.to_string(),
),
}
}
fn create_feature_envy_finding(
&self,
name: &str,
qualified_name: &str,
file_path: &str,
in_degree: usize,
out_degree: usize,
complexity: u32,
loc: u32,
max_out_degree: usize,
threshold: usize,
) -> Finding {
let percentile = if max_out_degree > 0 {
(out_degree as f64 / max_out_degree as f64) * 100.0
} else {
0.0
};
let severity = if percentile >= 99.0 {
Severity::High
} else if percentile >= 97.0 {
Severity::Medium
} else {
Severity::Low
};
let description = format!(
"Function `{}` shows **Feature Envy**: calls {} other functions, \
suggesting it reaches into too many modules.\n\n\
**What this means:**\n\
- This function depends on {} other functions\n\
- May be handling responsibilities that belong elsewhere\n\
- Tight coupling makes changes cascade\n\
- Could be a 'God Module' orchestrating everything\n\n\
**Metrics:**\n\
- Out-degree: {} (threshold: {})\n\
- In-degree: {}\n\
- Complexity: {}\n\
- Lines of code: {}",
name, out_degree, out_degree, out_degree, threshold, in_degree, complexity, loc
);
let suggested_fix = "\
**For Feature Envy:**\n\n\
1. **Move logic to data**: Put behavior where data lives\n\n\
2. **Extract classes**: Group related functionality\n\n\
3. **Use delegation**: Have other modules handle their own logic\n\n\
4. **Review module boundaries**: This may be misplaced code\n\n\
5. **Apply facade pattern**: If orchestration is needed, make it explicit"
.to_string();
let estimated_effort = match severity {
Severity::High => "Medium (2-4 hours)",
Severity::Medium => "Medium (1-2 hours)",
_ => "Small (30-60 minutes)",
};
Finding {
id: Uuid::new_v4().to_string(),
detector: "DegreeCentralityDetector".to_string(),
severity,
title: format!("Feature Envy: {}", name),
description,
affected_files: vec![file_path.into()],
line_start: None,
line_end: None,
suggested_fix: Some(suggested_fix),
estimated_effort: Some(estimated_effort.to_string()),
category: Some("coupling".to_string()),
cwe_id: None,
why_it_matters: Some(
"Feature Envy occurs when a function uses features of other classes \
more than its own. This creates tight coupling and makes the code \
harder to maintain and test."
.to_string(),
),
}
}
fn create_coupling_hotspot_finding(
&self,
name: &str,
qualified_name: &str,
file_path: &str,
in_degree: usize,
out_degree: usize,
complexity: u32,
loc: u32,
) -> Finding {
let total_coupling = in_degree + out_degree;
let severity = if complexity >= self.high_complexity_threshold {
Severity::Critical
} else {
Severity::High
};
let description = format!(
"Function `{}` is a **Coupling Hotspot**: high in-degree ({}) \
AND high out-degree ({}).\n\n\
**What this means:**\n\
- Both heavily depended ON ({} callers)\n\
- AND heavily dependent ON others ({} callees)\n\
- Total coupling: {} connections\n\
- Changes here cascade in both directions\n\
- This is a critical architectural risk\n\n\
**Metrics:**\n\
- In-degree: {}\n\
- Out-degree: {}\n\
- Total coupling: {}\n\
- Complexity: {}\n\
- Lines of code: {}",
name,
in_degree,
out_degree,
in_degree,
out_degree,
total_coupling,
in_degree,
out_degree,
total_coupling,
complexity,
loc
);
let suggested_fix = "\
**For Coupling Hotspots (Critical):**\n\n\
1. **Architectural review**: This function is a design bottleneck\n\n\
2. **Split by responsibility**: Extract into focused modules\n\n\
3. **Introduce layers**: Create abstraction boundaries\n\n\
4. **Apply SOLID principles**:\n\
- Single Responsibility (split concerns)\n\
- Interface Segregation (smaller interfaces)\n\
- Dependency Inversion (depend on abstractions)\n\n\
5. **Consider strangler pattern**: Gradually replace with better design"
.to_string();
let estimated_effort = if severity == Severity::Critical {
"Large (1-2 days)"
} else {
"Large (4-8 hours)"
};
Finding {
id: Uuid::new_v4().to_string(),
detector: "DegreeCentralityDetector".to_string(),
severity,
title: format!("Coupling Hotspot: {}", name),
description,
affected_files: vec![file_path.into()],
line_start: None,
line_end: None,
suggested_fix: Some(suggested_fix),
estimated_effort: Some(estimated_effort.to_string()),
category: Some("architecture".to_string()),
cwe_id: None,
why_it_matters: Some(
"Coupling hotspots are the most problematic code - they both depend on \
many other parts AND are depended on by many parts. Any change here \
cascades in all directions."
.to_string(),
),
}
}
}
impl Default for DegreeCentralityDetector {
fn default() -> Self {
Self::new()
}
}
impl Detector for DegreeCentralityDetector {
fn name(&self) -> &'static str {
"DegreeCentralityDetector"
}
fn description(&self) -> &'static str {
"Detects coupling issues using degree centrality (God Classes, Feature Envy, Coupling Hotspots)"
}
fn category(&self) -> &'static str {
"coupling"
}
fn config(&self) -> Option<&DetectorConfig> {
Some(&self.config)
}
fn detect(&self, graph: &GraphClient) -> Result<Vec<Finding>> {
debug!("Starting degree centrality detection");
let query = r#"
MATCH (f:Function)
OPTIONAL MATCH (caller:Function)-[:CALLS]->(f)
OPTIONAL MATCH (f)-[:CALLS]->(callee:Function)
WITH f,
count(DISTINCT caller) AS in_degree,
count(DISTINCT callee) AS out_degree
RETURN f.qualifiedName AS qualified_name,
f.name AS name,
f.filePath AS file_path,
coalesce(f.complexity, 0) AS complexity,
coalesce(f.loc, 0) AS loc,
in_degree,
out_degree
ORDER BY in_degree + out_degree DESC
"#;
let results = graph.execute(query)?;
if results.is_empty() {
debug!("No functions found");
return Ok(vec![]);
}
struct FuncData {
qualified_name: String,
name: String,
file_path: String,
complexity: u32,
loc: u32,
in_degree: usize,
out_degree: usize,
}
let func_data: Vec<FuncData> = results
.iter()
.filter_map(|row| {
Some(FuncData {
qualified_name: row.get("qualified_name")?.as_str()?.to_string(),
name: row.get("name")?.as_str()?.to_string(),
file_path: row
.get("file_path")?
.as_str()
.unwrap_or("unknown")
.to_string(),
complexity: row.get("complexity")?.as_i64().unwrap_or(0) as u32,
loc: row.get("loc")?.as_i64().unwrap_or(0) as u32,
in_degree: row.get("in_degree")?.as_i64().unwrap_or(0) as usize,
out_degree: row.get("out_degree")?.as_i64().unwrap_or(0) as usize,
})
})
.collect();
if func_data.is_empty() {
return Ok(vec![]);
}
let in_degrees: Vec<usize> = func_data.iter().map(|f| f.in_degree).collect();
let out_degrees: Vec<usize> = func_data.iter().map(|f| f.out_degree).collect();
let max_in_degree = *in_degrees.iter().max().unwrap_or(&0);
let max_out_degree = *out_degrees.iter().max().unwrap_or(&0);
let avg_in_degree = in_degrees.iter().sum::<usize>() as f64 / in_degrees.len() as f64;
let avg_out_degree = out_degrees.iter().sum::<usize>() as f64 / out_degrees.len() as f64;
info!(
"Degree stats: avg_in={:.1}, max_in={}, avg_out={:.1}, max_out={}",
avg_in_degree, max_in_degree, avg_out_degree, max_out_degree
);
let mut sorted_in = in_degrees.clone();
let mut sorted_out = out_degrees.clone();
sorted_in.sort_unstable();
sorted_out.sort_unstable();
let percentile_idx = |v: &[usize]| {
((v.len() as f64 * self.high_percentile / 100.0) as usize)
.min(v.len().saturating_sub(1))
};
let in_threshold = sorted_in
.get(percentile_idx(&sorted_in))
.copied()
.unwrap_or(0);
let out_threshold = sorted_out
.get(percentile_idx(&sorted_out))
.copied()
.unwrap_or(0);
let mut findings = Vec::new();
let mut high_indegree: HashSet<String> = HashSet::new();
let mut high_outdegree: HashSet<String> = HashSet::new();
for f in &func_data {
if f.in_degree >= in_threshold.max(self.min_indegree)
&& f.complexity >= self.high_complexity_threshold
{
high_indegree.insert(f.qualified_name.clone());
let finding = self.create_god_class_finding(
&f.name,
&f.qualified_name,
&f.file_path,
f.in_degree,
f.out_degree,
f.complexity,
f.loc,
max_in_degree,
in_threshold.max(self.min_indegree),
);
findings.push(finding);
}
}
for f in &func_data {
if f.out_degree >= out_threshold.max(self.min_outdegree) {
high_outdegree.insert(f.qualified_name.clone());
let finding = self.create_feature_envy_finding(
&f.name,
&f.qualified_name,
&f.file_path,
f.in_degree,
f.out_degree,
f.complexity,
f.loc,
max_out_degree,
out_threshold.max(self.min_outdegree),
);
findings.push(finding);
}
}
for f in &func_data {
if high_indegree.contains(&f.qualified_name)
&& high_outdegree.contains(&f.qualified_name)
{
let finding = self.create_coupling_hotspot_finding(
&f.name,
&f.qualified_name,
&f.file_path,
f.in_degree,
f.out_degree,
f.complexity,
f.loc,
);
findings.push(finding);
}
}
findings.sort_by(|a, b| b.severity.cmp(&a.severity));
if let Some(max) = self.config.max_findings {
findings.truncate(max);
}
info!("DegreeCentralityDetector found {} findings", findings.len());
Ok(findings)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_detector() {
let detector = DegreeCentralityDetector::new();
assert_eq!(detector.high_complexity_threshold, 15);
assert_eq!(detector.min_indegree, 5);
}
#[test]
fn test_with_config() {
let config = DetectorConfig::new()
.with_option("high_complexity_threshold", serde_json::json!(25))
.with_option("min_indegree", serde_json::json!(10));
let detector = DegreeCentralityDetector::with_config(config);
assert_eq!(detector.high_complexity_threshold, 25);
assert_eq!(detector.min_indegree, 10);
}
}