1use std::collections::HashMap;
14
15use gitcortex_core::{
16 error::Result,
17 graph::{Edge, Node},
18 schema::EdgeKind,
19 store::GraphStore,
20};
21use serde::Serialize;
22
23use super::centrality::in_degree_by_calls;
24
25const MAX_ITERATIONS: u32 = 20;
26const DEFAULT_MIN_CLUSTER_SIZE: usize = 3;
27const DEFAULT_LIMIT: usize = 20;
28const MAX_LIMIT: usize = 100;
29
30#[derive(Debug, Clone, Serialize)]
31pub struct ClusterMember {
32 pub name: String,
33 pub qualified_name: String,
34 pub kind: String,
35 pub file: String,
36 pub start_line: u32,
37}
38
39#[derive(Debug, Clone, Serialize)]
40pub struct Cluster {
41 pub label: String,
44 pub size: usize,
47 pub members: Vec<ClusterMember>,
48}
49
50const MAX_MEMBERS_PER_CLUSTER: usize = 25;
53
54pub fn find_clusters<S: GraphStore + ?Sized>(
58 store: &S,
59 branch: &str,
60 min_cluster_size: Option<usize>,
61 limit: Option<usize>,
62) -> Result<Vec<Cluster>> {
63 let min_cluster_size = min_cluster_size.unwrap_or(DEFAULT_MIN_CLUSTER_SIZE).max(2);
64 let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
65
66 let nodes = store.list_all_nodes(branch)?;
67 let edges = store.list_all_edges(branch)?;
68
69 if nodes.is_empty() {
70 return Ok(Vec::new());
71 }
72
73 let in_degree = in_degree_by_calls(&edges);
74 let adjacency = build_undirected_adjacency(&edges);
75 let labels = propagate_labels(&nodes, &adjacency);
76
77 let mut groups: HashMap<String, Vec<&Node>> = HashMap::new();
79 for n in &nodes {
80 let id = n.id.as_str();
81 let label = labels.get(&id).cloned().unwrap_or(id);
82 groups.entry(label).or_default().push(n);
83 }
84
85 let mut clusters: Vec<Cluster> = groups
86 .into_values()
87 .filter(|members| members.len() >= min_cluster_size)
88 .map(|mut members| {
89 members.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
91
92 let representative = members
96 .iter()
97 .max_by(|a, b| {
98 let deg_a = in_degree.get(&a.id.as_str()).copied().unwrap_or(0);
99 let deg_b = in_degree.get(&b.id.as_str()).copied().unwrap_or(0);
100 deg_a
101 .cmp(°_b)
102 .then_with(|| b.qualified_name.cmp(&a.qualified_name))
103 })
104 .unwrap_or_else(|| &members[0]);
105
106 Cluster {
107 label: representative.qualified_name.clone(),
108 size: members.len(),
109 members: members
110 .iter()
111 .take(MAX_MEMBERS_PER_CLUSTER)
112 .map(|n| ClusterMember {
113 name: n.name.clone(),
114 qualified_name: n.qualified_name.clone(),
115 kind: n.kind.to_string(),
116 file: n.file.display().to_string(),
117 start_line: n.span.start_line,
118 })
119 .collect(),
120 }
121 })
122 .collect();
123
124 clusters.sort_by(|a, b| b.size.cmp(&a.size).then_with(|| a.label.cmp(&b.label)));
125 clusters.truncate(limit);
126 Ok(clusters)
127}
128
129fn build_undirected_adjacency(edges: &[Edge]) -> HashMap<String, Vec<String>> {
130 let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
131 for e in edges {
132 if matches!(e.kind, EdgeKind::Contains | EdgeKind::Calls) {
133 let (a, b) = (e.src.as_str(), e.dst.as_str());
134 adjacency.entry(a.clone()).or_default().push(b.clone());
135 adjacency.entry(b).or_default().push(a);
136 }
137 }
138 adjacency
139}
140
141fn propagate_labels(
147 nodes: &[Node],
148 adjacency: &HashMap<String, Vec<String>>,
149) -> HashMap<String, String> {
150 let qname_of: HashMap<String, &str> = nodes
151 .iter()
152 .map(|n| (n.id.as_str(), n.qualified_name.as_str()))
153 .collect();
154
155 let mut order: Vec<String> = nodes.iter().map(|n| n.id.as_str()).collect();
158 order.sort_by_key(|id| qname_of.get(id.as_str()).copied().unwrap_or(""));
159
160 let mut labels: HashMap<String, String> = nodes
161 .iter()
162 .map(|n| (n.id.as_str(), n.id.as_str()))
163 .collect();
164
165 for _ in 0..MAX_ITERATIONS {
166 let mut changed = false;
167 for id in &order {
168 let Some(neighbors) = adjacency.get(id) else {
169 continue;
170 };
171 if neighbors.is_empty() {
172 continue;
173 }
174
175 let mut counts: HashMap<&str, u32> = HashMap::new();
176 for nb in neighbors {
177 if let Some(label) = labels.get(nb) {
178 *counts.entry(label.as_str()).or_insert(0) += 1;
179 }
180 }
181 if counts.is_empty() {
182 continue;
183 }
184
185 let best = counts
186 .into_iter()
187 .max_by(|(la, ca), (lb, cb)| {
188 ca.cmp(cb).then_with(|| {
189 let qa = qname_of.get(*la).copied().unwrap_or("");
190 let qb = qname_of.get(*lb).copied().unwrap_or("");
191 qb.cmp(qa)
194 })
195 })
196 .map(|(label, _)| label.to_owned());
197
198 if let Some(new_label) = best {
199 if labels.get(id) != Some(&new_label) {
200 labels.insert(id.clone(), new_label);
201 changed = true;
202 }
203 }
204 }
205 if !changed {
206 break;
207 }
208 }
209
210 labels
211}
212
213#[cfg(test)]
214mod tests {
215 use std::path::PathBuf;
216
217 use gitcortex_core::graph::{Edge, NodeId, NodeMetadata, Span};
218 use gitcortex_core::schema::{EdgeKind, NodeKind};
219
220 use super::*;
221
222 fn make_node(name: &str) -> Node {
223 Node {
224 id: NodeId::new(),
225 kind: NodeKind::Function,
226 name: name.to_owned(),
227 qualified_name: name.to_owned(),
228 file: PathBuf::from("src/lib.rs"),
229 span: Span {
230 start_line: 1,
231 end_line: 5,
232 },
233 metadata: NodeMetadata::default(),
234 }
235 }
236
237 #[test]
240 fn build_undirected_adjacency_includes_both_directions() {
241 let src = NodeId::new();
242 let dst = NodeId::new();
243 let edges = vec![Edge::new(src.clone(), dst.clone(), EdgeKind::Calls)];
244 let adj = build_undirected_adjacency(&edges);
245 assert!(adj.get(&src.as_str()).unwrap().contains(&dst.as_str()));
246 assert!(adj.get(&dst.as_str()).unwrap().contains(&src.as_str()));
247 }
248
249 #[test]
250 fn build_undirected_adjacency_excludes_uses_edges() {
251 let src = NodeId::new();
252 let dst = NodeId::new();
253 let edges = vec![Edge::new(src.clone(), dst, EdgeKind::Uses)];
254 let adj = build_undirected_adjacency(&edges);
255 assert!(!adj.contains_key(&src.as_str()));
256 }
257
258 #[test]
259 fn build_undirected_adjacency_includes_contains_edges() {
260 let parent = NodeId::new();
261 let child = NodeId::new();
262 let edges = vec![Edge::new(parent.clone(), child.clone(), EdgeKind::Contains)];
263 let adj = build_undirected_adjacency(&edges);
264 assert!(adj.get(&parent.as_str()).unwrap().contains(&child.as_str()));
265 assert!(adj.get(&child.as_str()).unwrap().contains(&parent.as_str()));
266 }
267
268 #[test]
271 fn propagate_labels_empty_nodes_returns_empty() {
272 let labels = propagate_labels(&[], &HashMap::new());
273 assert!(labels.is_empty());
274 }
275
276 #[test]
277 fn propagate_labels_isolated_node_keeps_own_id() {
278 let node = make_node("lone_fn");
279 let id = node.id.as_str();
280 let labels = propagate_labels(&[node], &HashMap::new());
281 assert_eq!(labels.get(&id), Some(&id));
283 }
284
285 #[test]
286 fn propagate_labels_two_connected_nodes_converge() {
287 let a = make_node("alpha");
288 let b = make_node("beta");
289 let a_id = a.id.as_str();
290 let b_id = b.id.as_str();
291
292 let mut adj = HashMap::new();
293 adj.insert(a_id.clone(), vec![b_id.clone()]);
294 adj.insert(b_id.clone(), vec![a_id.clone()]);
295
296 let labels = propagate_labels(&[a, b], &adj);
297 assert_eq!(
299 labels[&a_id], labels[&b_id],
300 "connected pair must converge to same label"
301 );
302 }
303
304 #[test]
305 fn propagate_labels_three_node_chain_converges() {
306 let a = make_node("aaa");
308 let b = make_node("bbb");
309 let c = make_node("ccc");
310 let a_id = a.id.as_str();
311 let b_id = b.id.as_str();
312 let c_id = c.id.as_str();
313
314 let mut adj = HashMap::new();
315 adj.insert(a_id.clone(), vec![b_id.clone()]);
316 adj.insert(b_id.clone(), vec![a_id.clone(), c_id.clone()]);
317 adj.insert(c_id.clone(), vec![b_id.clone()]);
318
319 let labels = propagate_labels(&[a, b, c], &adj);
320 assert_eq!(labels[&a_id], labels[&b_id]);
321 assert_eq!(labels[&b_id], labels[&c_id]);
322 }
323
324 #[test]
325 fn propagate_labels_deterministic_same_input_same_output() {
326 let nodes: Vec<Node> = ["p", "q", "r", "x", "y", "z"]
329 .iter()
330 .map(|n| make_node(n))
331 .collect();
332 let ids: Vec<String> = nodes.iter().map(|n| n.id.as_str()).collect();
333
334 let mut adj: HashMap<String, Vec<String>> = HashMap::new();
335 for (a, b) in [(&ids[0], &ids[1]), (&ids[1], &ids[2]), (&ids[2], &ids[0])] {
337 adj.entry(a.clone()).or_default().push(b.clone());
338 adj.entry(b.clone()).or_default().push(a.clone());
339 }
340 for (a, b) in [(&ids[3], &ids[4]), (&ids[4], &ids[5]), (&ids[5], &ids[3])] {
342 adj.entry(a.clone()).or_default().push(b.clone());
343 adj.entry(b.clone()).or_default().push(a.clone());
344 }
345
346 let run1 = propagate_labels(&nodes, &adj);
347 let run2 = propagate_labels(&nodes, &adj);
348 assert_eq!(run1, run2, "label propagation must be deterministic");
349 }
350
351 #[test]
352 fn propagate_labels_two_triangles_form_distinct_clusters() {
353 let nodes: Vec<Node> = ["p", "q", "r", "x", "y", "z"]
355 .iter()
356 .map(|n| make_node(n))
357 .collect();
358 let ids: Vec<String> = nodes.iter().map(|n| n.id.as_str()).collect();
359
360 let mut adj: HashMap<String, Vec<String>> = HashMap::new();
361 for (a, b) in [(&ids[0], &ids[1]), (&ids[1], &ids[2]), (&ids[2], &ids[0])] {
362 adj.entry(a.clone()).or_default().push(b.clone());
363 adj.entry(b.clone()).or_default().push(a.clone());
364 }
365 for (a, b) in [(&ids[3], &ids[4]), (&ids[4], &ids[5]), (&ids[5], &ids[3])] {
366 adj.entry(a.clone()).or_default().push(b.clone());
367 adj.entry(b.clone()).or_default().push(a.clone());
368 }
369
370 let labels = propagate_labels(&nodes, &adj);
371
372 let group1_label = &labels[&ids[0]];
373 let group2_label = &labels[&ids[3]];
374 assert_eq!(&labels[&ids[1]], group1_label);
376 assert_eq!(&labels[&ids[2]], group1_label);
377 assert_eq!(&labels[&ids[4]], group2_label);
378 assert_eq!(&labels[&ids[5]], group2_label);
379 assert_ne!(
381 group1_label, group2_label,
382 "disjoint triangles must form distinct clusters"
383 );
384 }
385}