Skip to main content

code_ranker_graph/
stats.rs

1//! Per-graph aggregate stats: the mean of each tracked numeric metric across
2//! the project's file nodes. Zero/missing values are excluded from a metric's
3//! average (matching the historical behavior); a metric is emitted only when
4//! its average is positive.
5
6use crate::attrs::{attr_f64, is_external, num_attr};
7use code_ranker_plugin_api::{attrs::AttrValue, graph::Graph};
8use std::collections::BTreeMap;
9
10/// Metrics averaged into the stats block, in a fixed vocabulary. Structural-only
11/// keys (`loc`, `lloc`, `exits`, `args`, `closures`, `fan_out_external`) are not
12/// aggregated — they are per-node detail.
13const STAT_KEYS: &[&str] = &[
14    "cyclomatic",
15    "cognitive",
16    "fan_in",
17    "fan_out",
18    "hk",
19    "mi",
20    "mi_sei",
21    "sloc",
22    "cloc",
23    "blank",
24    "tloc",
25    "length",
26    "vocabulary",
27    "volume",
28    "effort",
29    "time",
30    "bugs",
31];
32
33/// Compute the averages over all internal (file) nodes. Returns a flat map keyed
34/// by metric name; empty when nothing positive was found.
35pub fn compute_stats(graph: &Graph) -> BTreeMap<String, AttrValue> {
36    let mut stats = BTreeMap::new();
37    for key in STAT_KEYS {
38        let vals: Vec<f64> = graph
39            .nodes
40            .iter()
41            .filter(|n| !is_external(n))
42            .filter_map(|n| attr_f64(n, key))
43            .filter(|v| v.is_finite() && *v > 0.0)
44            .collect();
45        if vals.is_empty() {
46            continue;
47        }
48        let avg = vals.iter().sum::<f64>() / vals.len() as f64;
49        if avg > 0.0 {
50            stats.insert((*key).to_string(), num_attr(avg));
51        }
52    }
53    stats
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59    use code_ranker_plugin_api::node::Node;
60
61    fn file(id: &str, cyclomatic: Option<i64>) -> Node {
62        let mut n = Node {
63            id: id.into(),
64            kind: "file".into(),
65            name: id.into(),
66            parent: None,
67            attrs: Default::default(),
68        };
69        if let Some(c) = cyclomatic {
70            n.attrs.insert("cyclomatic".into(), AttrValue::Int(c));
71        }
72        n
73    }
74
75    #[test]
76    fn average_excludes_zero_and_missing() {
77        let g = Graph {
78            nodes: vec![
79                file("a", Some(2)),
80                file("b", Some(4)),
81                file("z", Some(0)),
82                file("n", None),
83            ],
84            edges: vec![],
85        };
86        let s = compute_stats(&g);
87        assert_eq!(s.get("cyclomatic"), Some(&AttrValue::Int(3)));
88    }
89
90    #[test]
91    fn empty_graph_has_no_stats() {
92        let g = Graph::default();
93        assert!(compute_stats(&g).is_empty());
94    }
95}