1use petgraph::Graph;
20use serde::{Deserialize, Serialize};
21
22use srcgraph_core::{ClassNode, EdgeKind};
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct MethodCc {
27 #[serde(default)]
28 pub name: String,
29 #[serde(default = "one")]
31 pub complexity: u32,
32}
33
34fn one() -> u32 {
35 1
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40pub struct CcStats {
41 pub mean: f64,
42 pub median: u32,
43 pub min: u32,
44 pub max: u32,
45 pub std: f64,
46 pub total: usize,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, Default)]
52pub struct Bimodality {
53 pub is_bimodal: bool,
54 pub num_modes: u8,
56 pub dip_score: f64,
60 pub split_point: Option<u32>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct CyclomaticReport {
67 pub class_id: String,
68 pub methods: Option<Vec<MethodCc>>,
71 pub stats: Option<CcStats>,
72 pub bimodality: Option<Bimodality>,
73}
74
75pub fn parse_methods(blob: &serde_json::Value) -> Option<Vec<MethodCc>> {
77 let arr = blob.get("methods")?.as_array()?;
78 let mut out = Vec::with_capacity(arr.len());
79 for v in arr {
80 let m: MethodCc = serde_json::from_value(v.clone()).ok()?;
81 out.push(m);
82 }
83 Some(out)
84}
85
86pub fn compute_stats(values: &[u32]) -> CcStats {
90 let n = values.len();
91 if n == 0 {
92 return CcStats::default();
93 }
94 let sum: u64 = values.iter().map(|v| *v as u64).sum();
95 let mean = sum as f64 / n as f64;
96 let mut sorted: Vec<u32> = values.to_vec();
97 sorted.sort_unstable();
98 let median = sorted[n / 2];
99 let max = *sorted.last().unwrap();
100 let min = sorted[0];
101 let variance: f64 = values
102 .iter()
103 .map(|v| {
104 let d = *v as f64 - mean;
105 d * d
106 })
107 .sum::<f64>()
108 / n as f64;
109 CcStats {
110 mean,
111 median,
112 min,
113 max,
114 std: variance.sqrt(),
115 total: n,
116 }
117}
118
119pub fn detect_bimodality(values: &[u32]) -> Bimodality {
123 if values.len() < 4 {
124 return Bimodality {
125 is_bimodal: false,
126 num_modes: 1,
127 dip_score: 0.0,
128 split_point: None,
129 };
130 }
131 let mut sorted: Vec<u32> = values.to_vec();
132 sorted.sort_unstable();
133 let gaps: Vec<u32> = sorted.windows(2).map(|w| w[1] - w[0]).collect();
134
135 let max_gap = *gaps.iter().max().unwrap_or(&0);
136 if max_gap == 0 {
137 return Bimodality {
138 is_bimodal: false,
139 num_modes: 1,
140 dip_score: 0.0,
141 split_point: None,
142 };
143 }
144 let max_gap_idx = gaps.iter().position(|g| *g == max_gap).unwrap();
145
146 let mut gaps_sorted = gaps.clone();
147 gaps_sorted.sort_unstable();
148 let median_gap = gaps_sorted[gaps_sorted.len() / 2];
149
150 let mean_gap = gaps.iter().map(|g| *g as f64).sum::<f64>() / gaps.len() as f64;
151 let dip_score = if mean_gap > 0.0 {
152 ((max_gap as f64 / (mean_gap + 0.01)) / 5.0).min(1.0)
153 } else {
154 0.0
155 };
156
157 let left_count = max_gap_idx + 1;
158 let right_count = sorted.len() - left_count;
159 let threshold = std::cmp::max(3, median_gap.saturating_mul(3));
160 let is_bimodal = max_gap >= threshold && left_count >= 2 && right_count >= 2;
161
162 Bimodality {
163 is_bimodal,
164 num_modes: if is_bimodal { 2 } else { 1 },
165 dip_score: (dip_score * 10_000.0).round() / 10_000.0,
167 split_point: if is_bimodal {
168 Some(sorted[max_gap_idx])
169 } else {
170 None
171 },
172 }
173}
174
175pub fn compute_cyclomatic<N, E>(graph: &Graph<N, E>) -> Vec<CyclomaticReport>
181where
182 N: ClassNode,
183 E: EdgeKind,
184{
185 graph
186 .node_indices()
187 .map(|nx| {
188 let node = &graph[nx];
189 let class_id = node.id().to_owned();
190 let Some(blob) = node.cyclomatic_complexity() else {
191 return CyclomaticReport {
192 class_id,
193 methods: None,
194 stats: None,
195 bimodality: None,
196 };
197 };
198 let Some(methods) = parse_methods(blob) else {
199 return CyclomaticReport {
200 class_id,
201 methods: None,
202 stats: None,
203 bimodality: None,
204 };
205 };
206 let values: Vec<u32> = methods.iter().map(|m| m.complexity).collect();
207 let stats = compute_stats(&values);
208 let bimodality = detect_bimodality(&values);
209 CyclomaticReport {
210 class_id,
211 methods: Some(methods),
212 stats: Some(stats),
213 bimodality: Some(bimodality),
214 }
215 })
216 .collect()
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use srcgraph_core::{EdgeType, OwnedClassNode, OwnedGraph};
223 use petgraph::Graph;
224 use serde_json::json;
225
226 fn class(id: &str, cc: Option<serde_json::Value>) -> OwnedClassNode {
227 OwnedClassNode {
228 id: id.to_owned(),
229 name: id.to_owned(),
230 namespace: "test".to_owned(),
231 line_count: 10,
232 method_count: 1,
233 halstead_eta1: 0,
234 halstead_eta2: 0,
235 halstead_n1: 0,
236 halstead_n2: 0,
237 method_connectivity: None,
238 method_fingerprints: None,
239 method_tokens: None,
240 call_sequences: None,
241 cyclomatic_complexity: cc,
242 path_conditions: None,
243 invariants: None,
244 error_messages: None,
245 magic_numbers: None,
246 dead_code: None,
247 tenant_branches: None,
248 state_transitions: None,
249 }
250 }
251
252 #[test]
253 fn parse_methods_extracts_array() {
254 let blob = json!({"methods": [
255 {"name": "foo", "complexity": 1},
256 {"name": "bar", "complexity": 7}
257 ]});
258 let m = parse_methods(&blob).unwrap();
259 assert_eq!(m.len(), 2);
260 assert_eq!(m[0].name, "foo");
261 assert_eq!(m[1].complexity, 7);
262 }
263
264 #[test]
265 fn parse_methods_defaults_missing_complexity_to_one() {
266 let blob = json!({"methods": [{"name": "ctor"}]});
267 let m = parse_methods(&blob).unwrap();
268 assert_eq!(m[0].complexity, 1);
269 }
270
271 #[test]
272 fn compute_stats_empty_is_zeros() {
273 let s = compute_stats(&[]);
274 assert_eq!(s.total, 0);
275 assert_eq!(s.mean, 0.0);
276 }
277
278 #[test]
279 fn compute_stats_basic_distribution() {
280 let s = compute_stats(&[1, 2, 3, 4, 5]);
282 assert_eq!(s.total, 5);
283 assert!((s.mean - 3.0).abs() < 1e-12);
284 assert_eq!(s.median, 3);
285 assert_eq!(s.min, 1);
286 assert_eq!(s.max, 5);
287 assert!((s.std - 2_f64.sqrt()).abs() < 1e-12);
288 }
289
290 #[test]
291 fn bimodality_short_input_is_unimodal() {
292 let b = detect_bimodality(&[1, 20, 3]);
294 assert!(!b.is_bimodal);
295 assert_eq!(b.num_modes, 1);
296 assert_eq!(b.split_point, None);
297 }
298
299 #[test]
300 fn bimodality_flat_distribution_is_unimodal() {
301 let b = detect_bimodality(&[5, 5, 5, 5, 5]);
303 assert!(!b.is_bimodal);
304 assert_eq!(b.dip_score, 0.0);
305 }
306
307 #[test]
308 fn bimodality_two_clusters_detected() {
309 let b = detect_bimodality(&[1, 2, 2, 3, 20, 21, 22, 23]);
312 assert!(b.is_bimodal);
313 assert_eq!(b.num_modes, 2);
314 assert_eq!(b.split_point, Some(3));
315 assert!(b.dip_score > 0.0);
316 }
317
318 #[test]
319 fn bimodality_unbalanced_split_rejected() {
320 let b = detect_bimodality(&[1, 2, 3, 4, 50]);
323 assert!(!b.is_bimodal);
324 }
325
326 #[test]
327 fn compute_cyclomatic_walks_graph_and_handles_missing_blob() {
328 let mut g: OwnedGraph = Graph::new();
329 let a = g.add_node(class(
330 "A",
331 Some(json!({"methods": [
332 {"name": "m1", "complexity": 1},
333 {"name": "m2", "complexity": 2},
334 {"name": "m3", "complexity": 2},
335 {"name": "m4", "complexity": 30}
336 ]})),
337 ));
338 let b = g.add_node(class("B", None));
339 g.add_edge(a, b, EdgeType::MethodCall);
340
341 let reports = compute_cyclomatic(&g);
342 assert_eq!(reports.len(), 2);
343
344 let r_a = reports.iter().find(|r| r.class_id == "A").unwrap();
345 let stats_a = r_a.stats.as_ref().unwrap();
346 assert_eq!(stats_a.total, 4);
347 assert_eq!(stats_a.max, 30);
348 assert!(!r_a.bimodality.as_ref().unwrap().is_bimodal);
352
353 let r_b = reports.iter().find(|r| r.class_id == "B").unwrap();
354 assert!(r_b.methods.is_none());
355 assert!(r_b.stats.is_none());
356 assert!(r_b.bimodality.is_none());
357 }
358}