use petgraph::Graph;
use serde::{Deserialize, Serialize};
use srcgraph_core::{ClassNode, EdgeKind};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MethodCc {
#[serde(default)]
pub name: String,
#[serde(default = "one")]
pub complexity: u32,
}
fn one() -> u32 {
1
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CcStats {
pub mean: f64,
pub median: u32,
pub min: u32,
pub max: u32,
pub std: f64,
pub total: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Bimodality {
pub is_bimodal: bool,
pub num_modes: u8,
pub dip_score: f64,
pub split_point: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CyclomaticReport {
pub class_id: String,
pub methods: Option<Vec<MethodCc>>,
pub stats: Option<CcStats>,
pub bimodality: Option<Bimodality>,
}
pub fn parse_methods(blob: &serde_json::Value) -> Option<Vec<MethodCc>> {
let arr = blob.get("methods")?.as_array()?;
let mut out = Vec::with_capacity(arr.len());
for v in arr {
let m: MethodCc = serde_json::from_value(v.clone()).ok()?;
out.push(m);
}
Some(out)
}
pub fn compute_stats(values: &[u32]) -> CcStats {
let n = values.len();
if n == 0 {
return CcStats::default();
}
let sum: u64 = values.iter().map(|v| *v as u64).sum();
let mean = sum as f64 / n as f64;
let mut sorted: Vec<u32> = values.to_vec();
sorted.sort_unstable();
let median = sorted[n / 2];
let max = *sorted.last().unwrap();
let min = sorted[0];
let variance: f64 = values
.iter()
.map(|v| {
let d = *v as f64 - mean;
d * d
})
.sum::<f64>()
/ n as f64;
CcStats {
mean,
median,
min,
max,
std: variance.sqrt(),
total: n,
}
}
pub fn detect_bimodality(values: &[u32]) -> Bimodality {
if values.len() < 4 {
return Bimodality {
is_bimodal: false,
num_modes: 1,
dip_score: 0.0,
split_point: None,
};
}
let mut sorted: Vec<u32> = values.to_vec();
sorted.sort_unstable();
let gaps: Vec<u32> = sorted.windows(2).map(|w| w[1] - w[0]).collect();
let max_gap = *gaps.iter().max().unwrap_or(&0);
if max_gap == 0 {
return Bimodality {
is_bimodal: false,
num_modes: 1,
dip_score: 0.0,
split_point: None,
};
}
let max_gap_idx = gaps.iter().position(|g| *g == max_gap).unwrap();
let mut gaps_sorted = gaps.clone();
gaps_sorted.sort_unstable();
let median_gap = gaps_sorted[gaps_sorted.len() / 2];
let mean_gap = gaps.iter().map(|g| *g as f64).sum::<f64>() / gaps.len() as f64;
let dip_score = if mean_gap > 0.0 {
((max_gap as f64 / (mean_gap + 0.01)) / 5.0).min(1.0)
} else {
0.0
};
let left_count = max_gap_idx + 1;
let right_count = sorted.len() - left_count;
let threshold = std::cmp::max(3, median_gap.saturating_mul(3));
let is_bimodal = max_gap >= threshold && left_count >= 2 && right_count >= 2;
Bimodality {
is_bimodal,
num_modes: if is_bimodal { 2 } else { 1 },
dip_score: (dip_score * 10_000.0).round() / 10_000.0,
split_point: if is_bimodal {
Some(sorted[max_gap_idx])
} else {
None
},
}
}
pub fn compute_cyclomatic<N, E>(graph: &Graph<N, E>) -> Vec<CyclomaticReport>
where
N: ClassNode,
E: EdgeKind,
{
graph
.node_indices()
.map(|nx| {
let node = &graph[nx];
let class_id = node.id().to_owned();
let Some(blob) = node.cyclomatic_complexity() else {
return CyclomaticReport {
class_id,
methods: None,
stats: None,
bimodality: None,
};
};
let Some(methods) = parse_methods(blob) else {
return CyclomaticReport {
class_id,
methods: None,
stats: None,
bimodality: None,
};
};
let values: Vec<u32> = methods.iter().map(|m| m.complexity).collect();
let stats = compute_stats(&values);
let bimodality = detect_bimodality(&values);
CyclomaticReport {
class_id,
methods: Some(methods),
stats: Some(stats),
bimodality: Some(bimodality),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use srcgraph_core::{EdgeType, OwnedClassNode, OwnedGraph};
use petgraph::Graph;
use serde_json::json;
fn class(id: &str, cc: Option<serde_json::Value>) -> OwnedClassNode {
OwnedClassNode {
id: id.to_owned(),
name: id.to_owned(),
namespace: "test".to_owned(),
line_count: 10,
method_count: 1,
halstead_eta1: 0,
halstead_eta2: 0,
halstead_n1: 0,
halstead_n2: 0,
method_connectivity: None,
method_fingerprints: None,
method_tokens: None,
call_sequences: None,
cyclomatic_complexity: cc,
path_conditions: None,
invariants: None,
error_messages: None,
magic_numbers: None,
dead_code: None,
tenant_branches: None,
state_transitions: None,
}
}
#[test]
fn parse_methods_extracts_array() {
let blob = json!({"methods": [
{"name": "foo", "complexity": 1},
{"name": "bar", "complexity": 7}
]});
let m = parse_methods(&blob).unwrap();
assert_eq!(m.len(), 2);
assert_eq!(m[0].name, "foo");
assert_eq!(m[1].complexity, 7);
}
#[test]
fn parse_methods_defaults_missing_complexity_to_one() {
let blob = json!({"methods": [{"name": "ctor"}]});
let m = parse_methods(&blob).unwrap();
assert_eq!(m[0].complexity, 1);
}
#[test]
fn compute_stats_empty_is_zeros() {
let s = compute_stats(&[]);
assert_eq!(s.total, 0);
assert_eq!(s.mean, 0.0);
}
#[test]
fn compute_stats_basic_distribution() {
let s = compute_stats(&[1, 2, 3, 4, 5]);
assert_eq!(s.total, 5);
assert!((s.mean - 3.0).abs() < 1e-12);
assert_eq!(s.median, 3);
assert_eq!(s.min, 1);
assert_eq!(s.max, 5);
assert!((s.std - 2_f64.sqrt()).abs() < 1e-12);
}
#[test]
fn bimodality_short_input_is_unimodal() {
let b = detect_bimodality(&[1, 20, 3]);
assert!(!b.is_bimodal);
assert_eq!(b.num_modes, 1);
assert_eq!(b.split_point, None);
}
#[test]
fn bimodality_flat_distribution_is_unimodal() {
let b = detect_bimodality(&[5, 5, 5, 5, 5]);
assert!(!b.is_bimodal);
assert_eq!(b.dip_score, 0.0);
}
#[test]
fn bimodality_two_clusters_detected() {
let b = detect_bimodality(&[1, 2, 2, 3, 20, 21, 22, 23]);
assert!(b.is_bimodal);
assert_eq!(b.num_modes, 2);
assert_eq!(b.split_point, Some(3));
assert!(b.dip_score > 0.0);
}
#[test]
fn bimodality_unbalanced_split_rejected() {
let b = detect_bimodality(&[1, 2, 3, 4, 50]);
assert!(!b.is_bimodal);
}
#[test]
fn compute_cyclomatic_walks_graph_and_handles_missing_blob() {
let mut g: OwnedGraph = Graph::new();
let a = g.add_node(class(
"A",
Some(json!({"methods": [
{"name": "m1", "complexity": 1},
{"name": "m2", "complexity": 2},
{"name": "m3", "complexity": 2},
{"name": "m4", "complexity": 30}
]})),
));
let b = g.add_node(class("B", None));
g.add_edge(a, b, EdgeType::MethodCall);
let reports = compute_cyclomatic(&g);
assert_eq!(reports.len(), 2);
let r_a = reports.iter().find(|r| r.class_id == "A").unwrap();
let stats_a = r_a.stats.as_ref().unwrap();
assert_eq!(stats_a.total, 4);
assert_eq!(stats_a.max, 30);
assert!(!r_a.bimodality.as_ref().unwrap().is_bimodal);
let r_b = reports.iter().find(|r| r.class_id == "B").unwrap();
assert!(r_b.methods.is_none());
assert!(r_b.stats.is_none());
assert!(r_b.bimodality.is_none());
}
}