gitcortex_mcp/mcp/
centrality.rs1use gitcortex_core::{error::Result, store::GraphStore};
10use serde::Serialize;
11
12pub use gitcortex_core::graph::in_degree_by_calls;
15
16#[derive(Debug, Clone, Serialize)]
17pub struct GodNode {
18 pub name: String,
19 pub qualified_name: String,
20 pub kind: String,
21 pub file: String,
22 pub start_line: u32,
23 pub in_degree: u32,
24}
25
26const DEFAULT_MIN_IN_DEGREE: u32 = 10;
30const DEFAULT_LIMIT: usize = 20;
31const MAX_LIMIT: usize = 100;
32
33pub fn find_god_nodes<S: GraphStore + ?Sized>(
38 store: &S,
39 branch: &str,
40 min_in_degree: Option<u32>,
41 limit: Option<usize>,
42) -> Result<Vec<GodNode>> {
43 let min_in_degree = min_in_degree.unwrap_or(DEFAULT_MIN_IN_DEGREE);
44 let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
45
46 let nodes = store.list_all_nodes(branch)?;
47 let edges = store.list_all_edges(branch)?;
48 let in_degree = in_degree_by_calls(&edges);
49
50 let mut scored: Vec<(GodNode, u32)> = nodes
51 .into_iter()
52 .filter_map(|n| {
53 let deg = in_degree.get(&n.id.as_str()).copied().unwrap_or(0);
54 if deg < min_in_degree {
55 return None;
56 }
57 Some((
58 GodNode {
59 name: n.name,
60 qualified_name: n.qualified_name,
61 kind: n.kind.to_string(),
62 file: n.file.display().to_string(),
63 start_line: n.span.start_line,
64 in_degree: deg,
65 },
66 deg,
67 ))
68 })
69 .collect();
70
71 scored.sort_by(|a, b| {
72 b.1.cmp(&a.1)
73 .then_with(|| a.0.qualified_name.cmp(&b.0.qualified_name))
74 });
75
76 Ok(scored.into_iter().take(limit).map(|(g, _)| g).collect())
77}
78
79#[cfg(test)]
80mod tests {
81 use gitcortex_core::graph::{Edge, NodeId};
82 use gitcortex_core::schema::EdgeKind;
83
84 use super::in_degree_by_calls;
85
86 #[test]
87 fn in_degree_empty_edges_returns_empty_map() {
88 assert!(in_degree_by_calls(&[]).is_empty());
89 }
90
91 #[test]
92 fn in_degree_counts_calls_edges_only() {
93 let a = NodeId::new();
94 let b = NodeId::new();
95 let edges = vec![
96 Edge::new(a.clone(), b.clone(), EdgeKind::Calls),
97 Edge::new(a.clone(), b.clone(), EdgeKind::Contains), Edge::new(a.clone(), b.clone(), EdgeKind::Uses), ];
100 let map = in_degree_by_calls(&edges);
101 assert_eq!(map.get(&b.as_str()), Some(&1));
102 assert!(
103 !map.contains_key(&a.as_str()),
104 "src should not appear as dst"
105 );
106 }
107
108 #[test]
109 fn in_degree_multiple_callers_accumulate() {
110 let dst = NodeId::new();
111 let callers: Vec<NodeId> = (0..5).map(|_| NodeId::new()).collect();
112 let edges: Vec<Edge> = callers
113 .iter()
114 .map(|src| Edge::new(src.clone(), dst.clone(), EdgeKind::Calls))
115 .collect();
116 let map = in_degree_by_calls(&edges);
117 assert_eq!(map.get(&dst.as_str()), Some(&5));
118 }
119
120 #[test]
121 fn in_degree_src_node_absent_unless_also_dst() {
122 let a = NodeId::new();
123 let b = NodeId::new();
124 let c = NodeId::new();
125 let edges = vec![
127 Edge::new(a.clone(), b.clone(), EdgeKind::Calls),
128 Edge::new(b.clone(), c.clone(), EdgeKind::Calls),
129 ];
130 let map = in_degree_by_calls(&edges);
131 assert!(
132 !map.contains_key(&a.as_str()),
133 "pure caller should not appear"
134 );
135 assert_eq!(map.get(&b.as_str()), Some(&1));
136 assert_eq!(map.get(&c.as_str()), Some(&1));
137 }
138
139 #[test]
140 fn in_degree_sort_order_descending_ties_by_qname() {
141 let high = NodeId::new();
143 let low = NodeId::new();
144 let callers: Vec<NodeId> = (0..3).map(|_| NodeId::new()).collect();
145 let mut edges: Vec<Edge> = callers
146 .iter()
147 .map(|src| Edge::new(src.clone(), high.clone(), EdgeKind::Calls))
148 .collect();
149 edges.push(Edge::new(NodeId::new(), low.clone(), EdgeKind::Calls));
150 let map = in_degree_by_calls(&edges);
151 assert!(map[&high.as_str()] > map[&low.as_str()]);
152 }
153}