1use crate::graph::{AvgCoupling, Graph, GraphStats, Halstead, Loc, Maintainability, Node};
2
3pub fn annotate_stats(graph: &mut Graph) {
4 fn avg<F>(nodes: &[Node], f: F) -> f64
5 where
6 F: Fn(&Node) -> Option<f64>,
7 {
8 let vals: Vec<f64> = nodes
9 .iter()
10 .filter_map(f)
11 .filter(|v| v.is_finite() && *v > 0.0)
12 .collect();
13 if vals.is_empty() {
14 return 0.0;
15 }
16 vals.iter().sum::<f64>() / vals.len() as f64
17 }
18
19 let nodes = &graph.nodes;
20
21 let cyclomatic = avg(nodes, |n| n.complexity.as_ref().map(|c| c.cyclomatic));
22 let cognitive = avg(nodes, |n| n.complexity.as_ref().map(|c| c.cognitive));
23
24 let fan_in = avg(nodes, |n| {
25 n.complexity
26 .as_ref()
27 .and_then(|c| c.coupling.as_ref())
28 .map(|c| c.fan_in as f64)
29 });
30 let fan_out = avg(nodes, |n| {
31 n.complexity
32 .as_ref()
33 .and_then(|c| c.coupling.as_ref())
34 .map(|c| c.fan_out as f64)
35 });
36 let hk = avg(nodes, |n| {
37 n.complexity
38 .as_ref()
39 .and_then(|c| c.coupling.as_ref())
40 .map(|c| c.hk)
41 });
42 let coupling = (fan_in > 0.0 || fan_out > 0.0 || hk > 0.0).then_some(AvgCoupling {
43 fan_in,
44 fan_out,
45 hk,
46 });
47
48 let mi = avg(nodes, |n| {
49 n.complexity
50 .as_ref()
51 .and_then(|c| c.maintainability.as_ref())
52 .map(|m| m.mi)
53 });
54 let mi_sei = avg(nodes, |n| {
55 n.complexity
56 .as_ref()
57 .and_then(|c| c.maintainability.as_ref())
58 .map(|m| m.mi_sei)
59 });
60 let maintainability = (mi > 0.0).then_some(Maintainability { mi, mi_sei });
61
62 let loc_source = avg(nodes, |n| {
63 n.complexity
64 .as_ref()
65 .and_then(|c| c.loc.as_ref())
66 .map(|l| l.source)
67 });
68 let loc_comments = avg(nodes, |n| {
69 n.complexity
70 .as_ref()
71 .and_then(|c| c.loc.as_ref())
72 .map(|l| l.comments)
73 });
74 let loc_blank = avg(nodes, |n| {
75 n.complexity
76 .as_ref()
77 .and_then(|c| c.loc.as_ref())
78 .map(|l| l.blank)
79 });
80 let loc = (loc_source > 0.0).then_some(Loc {
81 source: loc_source,
82 logical: 0.0,
83 comments: loc_comments,
84 blank: loc_blank,
85 });
86
87 let h_length = avg(nodes, |n| {
88 n.complexity
89 .as_ref()
90 .and_then(|c| c.halstead.as_ref())
91 .map(|h| h.length)
92 });
93 let h_vocabulary = avg(nodes, |n| {
94 n.complexity
95 .as_ref()
96 .and_then(|c| c.halstead.as_ref())
97 .map(|h| h.vocabulary)
98 });
99 let h_volume = avg(nodes, |n| {
100 n.complexity
101 .as_ref()
102 .and_then(|c| c.halstead.as_ref())
103 .map(|h| h.volume)
104 });
105 let h_effort = avg(nodes, |n| {
106 n.complexity
107 .as_ref()
108 .and_then(|c| c.halstead.as_ref())
109 .map(|h| h.effort)
110 });
111 let h_time = avg(nodes, |n| {
112 n.complexity
113 .as_ref()
114 .and_then(|c| c.halstead.as_ref())
115 .map(|h| h.time)
116 });
117 let h_bugs = avg(nodes, |n| {
118 n.complexity
119 .as_ref()
120 .and_then(|c| c.halstead.as_ref())
121 .map(|h| h.bugs)
122 });
123 let halstead = (h_volume > 0.0).then_some(Halstead {
124 length: h_length,
125 vocabulary: h_vocabulary,
126 volume: h_volume,
127 effort: h_effort,
128 time: h_time,
129 bugs: h_bugs,
130 });
131
132 if cyclomatic == 0.0
133 && cognitive == 0.0
134 && coupling.is_none()
135 && maintainability.is_none()
136 && loc.is_none()
137 && halstead.is_none()
138 {
139 return;
140 }
141
142 graph.stats = Some(GraphStats {
143 cyclomatic,
144 cognitive,
145 coupling,
146 maintainability,
147 loc,
148 halstead,
149 });
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use crate::graph::{Complexity, Coupling, NodeKind};
156
157 fn node(id: &str, complexity: Option<Complexity>) -> Node {
158 Node {
159 id: id.into(),
160 kind: NodeKind::File,
161 name: id.into(),
162 path: String::new(),
163 parent: None,
164 external: None,
165 version: None,
166 visibility: None,
167 loc: None,
168 line: None,
169 item_count: None,
170 method_count: None,
171 complexity,
172 cycle_kind: None,
173 }
174 }
175
176 fn graph_of(nodes: Vec<Node>) -> Graph {
177 Graph {
178 nodes,
179 edges: Vec::new(),
180 cycles: Vec::new(),
181 stats: None,
182 }
183 }
184
185 #[test]
186 fn empty_graph_leaves_stats_none() {
187 let mut g = graph_of(vec![]);
188 annotate_stats(&mut g);
189 assert!(g.stats.is_none());
190 }
191
192 #[test]
193 fn nodes_without_complexity_leave_stats_none() {
194 let mut g = graph_of(vec![node("a", None), node("b", None)]);
195 annotate_stats(&mut g);
196 assert!(g.stats.is_none(), "no metrics → no stats block");
197 }
198
199 #[test]
200 fn all_zero_metrics_leave_stats_none() {
201 let mut g = graph_of(vec![node(
202 "a",
203 Some(Complexity {
204 cyclomatic: 0.0,
205 ..Default::default()
206 }),
207 )]);
208 annotate_stats(&mut g);
209 assert!(g.stats.is_none(), "all-zero metrics → early return");
210 }
211
212 #[test]
213 fn cyclomatic_average_excludes_zero_and_missing() {
214 let mut g = graph_of(vec![
217 node(
218 "a",
219 Some(Complexity {
220 cyclomatic: 2.0,
221 ..Default::default()
222 }),
223 ),
224 node(
225 "b",
226 Some(Complexity {
227 cyclomatic: 4.0,
228 ..Default::default()
229 }),
230 ),
231 node(
232 "z",
233 Some(Complexity {
234 cyclomatic: 0.0,
235 ..Default::default()
236 }),
237 ),
238 node("n", None),
239 ]);
240 annotate_stats(&mut g);
241 let stats = g.stats.expect("metrics present → stats block");
242 assert_eq!(stats.cyclomatic, 3.0, "(2+4)/2, zero excluded");
243 }
244
245 #[test]
246 fn coupling_is_averaged_and_attached() {
247 let mut g = graph_of(vec![
248 node(
249 "a",
250 Some(Complexity {
251 coupling: Some(Coupling {
252 fan_in: 2,
253 fan_out: 4,
254 hk: 10.0,
255 ..Default::default()
256 }),
257 ..Default::default()
258 }),
259 ),
260 node(
261 "b",
262 Some(Complexity {
263 coupling: Some(Coupling {
264 fan_in: 4,
265 fan_out: 8,
266 hk: 30.0,
267 ..Default::default()
268 }),
269 ..Default::default()
270 }),
271 ),
272 ]);
273 annotate_stats(&mut g);
274 let c = g.stats.unwrap().coupling.expect("coupling averaged");
275 assert_eq!(c.fan_in, 3.0);
276 assert_eq!(c.fan_out, 6.0);
277 assert_eq!(c.hk, 20.0);
278 }
279
280 #[test]
281 fn maintainability_attached_when_mi_positive() {
282 let mut g = graph_of(vec![node(
283 "a",
284 Some(Complexity {
285 maintainability: Some(Maintainability {
286 mi: 80.0,
287 mi_sei: 70.0,
288 }),
289 ..Default::default()
290 }),
291 )]);
292 annotate_stats(&mut g);
293 let m = g.stats.unwrap().maintainability.expect("mi > 0 → attached");
294 assert_eq!(m.mi, 80.0);
295 assert_eq!(m.mi_sei, 70.0);
296 }
297
298 #[test]
299 fn halstead_attached_when_volume_positive() {
300 let mut g = graph_of(vec![node(
301 "a",
302 Some(Complexity {
303 halstead: Some(Halstead {
304 length: 10.0,
305 vocabulary: 6.0,
306 volume: 40.0,
307 effort: 100.0,
308 time: 5.0,
309 bugs: 0.1,
310 }),
311 ..Default::default()
312 }),
313 )]);
314 annotate_stats(&mut g);
315 let h = g.stats.unwrap().halstead.expect("volume > 0 → attached");
316 assert_eq!(h.volume, 40.0);
317 assert_eq!(h.length, 10.0);
318 }
319
320 #[test]
321 fn halstead_absent_when_volume_zero_but_other_metrics_present() {
322 let mut g = graph_of(vec![node(
324 "a",
325 Some(Complexity {
326 cyclomatic: 3.0,
327 halstead: Some(Halstead {
328 length: 5.0,
329 vocabulary: 0.0,
330 volume: 0.0,
331 effort: 0.0,
332 time: 0.0,
333 bugs: 0.0,
334 }),
335 ..Default::default()
336 }),
337 )]);
338 annotate_stats(&mut g);
339 let stats = g.stats.expect("cyclomatic keeps stats present");
340 assert_eq!(stats.cyclomatic, 3.0);
341 assert!(stats.halstead.is_none(), "volume 0 → halstead gate closed");
342 }
343}