Skip to main content

graphify_analyze/
lib.rs

1//! Graph analysis algorithms for graphify.
2//!
3//! Identifies god nodes, surprising cross-community connections, and generates
4//! suggested questions for exploration.
5
6use std::collections::{HashMap, HashSet};
7
8use tracing::debug;
9
10use graphify_core::graph::KnowledgeGraph;
11use graphify_core::model::{BridgeNode, GodNode, Surprise};
12
13// ---------------------------------------------------------------------------
14// God nodes
15// ---------------------------------------------------------------------------
16
17/// Find the most-connected nodes, excluding file-level hubs and method stubs.
18///
19/// Returns up to `top_n` nodes sorted by degree descending.
20/// Generic labels like "lib", "mod", "main" are disambiguated with the crate/module
21/// name extracted from `source_file`.
22pub fn god_nodes(graph: &KnowledgeGraph, top_n: usize) -> Vec<GodNode> {
23    let generic_labels = ["lib", "mod", "main", "index", "init"];
24
25    let mut candidates: Vec<GodNode> = graph
26        .node_ids()
27        .into_iter()
28        .filter(|id| !is_file_node(graph, id) && !is_method_stub(graph, id))
29        .map(|id| {
30            let node = graph.get_node(&id).unwrap();
31            let label = if generic_labels.contains(&node.label.as_str()) {
32                // Extract crate/module name from source_file path
33                // e.g. "crates/graphify-export/src/lib.rs" → "graphify-export::lib"
34                disambiguate_label(&node.label, &node.source_file)
35            } else {
36                node.label.clone()
37            };
38            GodNode {
39                id: id.clone(),
40                label,
41                degree: graph.degree(&id),
42                community: node.community,
43            }
44        })
45        .collect();
46
47    candidates.sort_by(|a, b| b.degree.cmp(&a.degree));
48    candidates.truncate(top_n);
49    debug!("found {} god node candidates", candidates.len());
50    candidates
51}
52
53/// Disambiguate a generic label using the source file path.
54///
55/// Extracts the parent directory or crate name to create a unique label.
56/// Examples:
57/// - ("lib", "crates/graphify-export/src/lib.rs") → "graphify-export::lib"
58/// - ("mod", "src/config.rs") → "src::mod"
59/// - ("lib", "src/lib.rs") → "lib"
60fn disambiguate_label(label: &str, source_file: &str) -> String {
61    let parts: Vec<&str> = source_file.split('/').collect();
62    // Try to find crate name: look for the segment before "src/"
63    for (i, &segment) in parts.iter().enumerate() {
64        if segment == "src" && i > 0 {
65            return format!("{}::{}", parts[i - 1], label);
66        }
67    }
68    // Fallback: use parent directory
69    if parts.len() >= 2 {
70        return format!("{}::{}", parts[parts.len() - 2], label);
71    }
72    label.to_string()
73}
74
75// ---------------------------------------------------------------------------
76// Surprising connections
77// ---------------------------------------------------------------------------
78
79/// Find surprising connections that span different communities or source files.
80///
81/// A connection is "surprising" if:
82/// - the two endpoints belong to different communities, or
83/// - the two endpoints come from different source files, or
84/// - the edge confidence is `AMBIGUOUS` or `INFERRED`.
85///
86/// Results are scored and the top `top_n` are returned.
87pub fn surprising_connections(
88    graph: &KnowledgeGraph,
89    communities: &HashMap<usize, Vec<String>>,
90    top_n: usize,
91) -> Vec<Surprise> {
92    // Build reverse map: node_id → community_id
93    let node_to_community: HashMap<&str, usize> = communities
94        .iter()
95        .flat_map(|(&cid, nodes)| nodes.iter().map(move |n| (n.as_str(), cid)))
96        .collect();
97
98    let mut surprises: Vec<(f64, Surprise)> = Vec::new();
99
100    for (src, tgt, edge) in graph.edges_with_endpoints() {
101        // Skip file/stub nodes
102        if is_file_node(graph, src) || is_file_node(graph, tgt) {
103            continue;
104        }
105        if is_method_stub(graph, src) || is_method_stub(graph, tgt) {
106            continue;
107        }
108
109        let src_comm = node_to_community.get(src).copied().unwrap_or(usize::MAX);
110        let tgt_comm = node_to_community.get(tgt).copied().unwrap_or(usize::MAX);
111
112        let mut score = 0.0;
113
114        // Cross-community bonus
115        if src_comm != tgt_comm {
116            score += 2.0;
117        }
118
119        // Cross-file bonus
120        let src_node = graph.get_node(src);
121        let tgt_node = graph.get_node(tgt);
122        if let (Some(sn), Some(tn)) = (src_node, tgt_node)
123            && !sn.source_file.is_empty()
124            && !tn.source_file.is_empty()
125            && sn.source_file != tn.source_file
126        {
127            score += 1.0;
128        }
129
130        // Confidence bonus: AMBIGUOUS > INFERRED > EXTRACTED
131        use graphify_core::confidence::Confidence;
132        match edge.confidence {
133            Confidence::Ambiguous => score += 3.0,
134            Confidence::Inferred => score += 1.5,
135            Confidence::Extracted => {}
136        }
137
138        if score > 0.0 {
139            surprises.push((
140                score,
141                Surprise {
142                    source: src.to_string(),
143                    target: tgt.to_string(),
144                    source_community: src_comm,
145                    target_community: tgt_comm,
146                    relation: edge.relation.clone(),
147                },
148            ));
149        }
150    }
151
152    surprises.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
153    surprises.truncate(top_n);
154    debug!("found {} surprising connections", surprises.len());
155    surprises.into_iter().map(|(_, s)| s).collect()
156}
157
158// ---------------------------------------------------------------------------
159// Suggest questions
160// ---------------------------------------------------------------------------
161
162/// Generate graph-aware questions based on structural patterns.
163///
164/// Categories:
165/// 1. AMBIGUOUS edges → unresolved relationship questions
166/// 2. Bridge nodes (high cross-community degree) → cross-cutting concern questions
167/// 3. God nodes with INFERRED edges → verification questions
168/// 4. Isolated nodes → exploration questions
169/// 5. Low-cohesion communities → structural questions
170pub fn suggest_questions(
171    graph: &KnowledgeGraph,
172    communities: &HashMap<usize, Vec<String>>,
173    community_labels: &HashMap<usize, String>,
174    top_n: usize,
175) -> Vec<HashMap<String, String>> {
176    let mut questions: Vec<HashMap<String, String>> = Vec::new();
177
178    // 1. AMBIGUOUS edges
179    {
180        use graphify_core::confidence::Confidence;
181        for (src, tgt, edge) in graph.edges_with_endpoints() {
182            if edge.confidence == Confidence::Ambiguous {
183                let mut q = HashMap::new();
184                q.insert("category".into(), "ambiguous_relationship".into());
185                q.insert(
186                    "question".into(),
187                    format!(
188                        "What is the exact relationship between '{}' and '{}'? (marked as {})",
189                        src, tgt, edge.relation
190                    ),
191                );
192                q.insert("source".into(), src.to_string());
193                q.insert("target".into(), tgt.to_string());
194                questions.push(q);
195            }
196        }
197    }
198
199    // 2. Bridge nodes (nodes with neighbours in multiple communities)
200    {
201        let node_to_comm: HashMap<&str, usize> = communities
202            .iter()
203            .flat_map(|(&cid, nodes)| nodes.iter().map(move |n| (n.as_str(), cid)))
204            .collect();
205
206        for id in graph.node_ids() {
207            if is_file_node(graph, &id) {
208                continue;
209            }
210            let nbrs = graph.get_neighbors(&id);
211            let nbr_comms: HashSet<usize> = nbrs
212                .iter()
213                .filter_map(|n| node_to_comm.get(n.id.as_str()).copied())
214                .collect();
215            if nbr_comms.len() >= 3 {
216                let comm_names: Vec<String> = nbr_comms
217                    .iter()
218                    .filter_map(|c| community_labels.get(c).cloned())
219                    .collect();
220                let mut q = HashMap::new();
221                q.insert("category".into(), "bridge_node".into());
222                q.insert(
223                    "question".into(),
224                    format!(
225                        "How does '{}' relate to {} different communities{}?",
226                        id,
227                        nbr_comms.len(),
228                        if comm_names.is_empty() {
229                            String::new()
230                        } else {
231                            format!(" ({})", comm_names.join(", "))
232                        }
233                    ),
234                );
235                q.insert("node".into(), id.clone());
236                questions.push(q);
237            }
238        }
239    }
240
241    // 3. God nodes with INFERRED edges
242    {
243        use graphify_core::confidence::Confidence;
244        let gods = god_nodes(graph, 5);
245        for g in &gods {
246            let has_inferred = graph.edges_with_endpoints().iter().any(|(s, t, e)| {
247                (*s == g.id || *t == g.id) && e.confidence == Confidence::Inferred
248            });
249            if has_inferred {
250                let mut q = HashMap::new();
251                q.insert("category".into(), "verification".into());
252                q.insert(
253                    "question".into(),
254                    format!(
255                        "Can you verify the inferred relationships of '{}' (degree {})?",
256                        g.label, g.degree
257                    ),
258                );
259                q.insert("node".into(), g.id.clone());
260                questions.push(q);
261            }
262        }
263    }
264
265    // 4. Isolated nodes (degree 0)
266    {
267        for id in graph.node_ids() {
268            if graph.degree(&id) == 0
269                && !is_file_node(graph, &id)
270                && let Some(node) = graph.get_node(&id)
271            {
272                let mut q = HashMap::new();
273                q.insert("category".into(), "isolated_node".into());
274                q.insert(
275                    "question".into(),
276                    format!(
277                        "What role does '{}' play? It has no connections in the graph.",
278                        node.label
279                    ),
280                );
281                q.insert("node".into(), id.clone());
282                questions.push(q);
283            }
284        }
285    }
286
287    // 5. Low-cohesion communities (< 0.3)
288    {
289        for (&cid, nodes) in communities {
290            let n = nodes.len();
291            if n <= 1 {
292                continue;
293            }
294            let cohesion = compute_cohesion(graph, nodes);
295            if cohesion < 0.3 {
296                let label = community_labels
297                    .get(&cid)
298                    .cloned()
299                    .unwrap_or_else(|| format!("community-{cid}"));
300                let mut q = HashMap::new();
301                q.insert("category".into(), "low_cohesion".into());
302                q.insert(
303                    "question".into(),
304                    format!(
305                        "Why is '{}' ({} nodes) loosely connected (cohesion {:.2})? Should it be split?",
306                        label, n, cohesion
307                    ),
308                );
309                q.insert("community".into(), cid.to_string());
310                questions.push(q);
311            }
312        }
313    }
314
315    questions.truncate(top_n);
316    debug!("generated {} questions", questions.len());
317    questions
318}
319
320// ---------------------------------------------------------------------------
321// Graph diff
322// ---------------------------------------------------------------------------
323
324/// Compare two graph snapshots and return a summary of changes.
325pub fn graph_diff(
326    old: &KnowledgeGraph,
327    new: &KnowledgeGraph,
328) -> HashMap<String, serde_json::Value> {
329    let old_node_ids: HashSet<String> = old.node_ids().into_iter().collect();
330    let new_node_ids: HashSet<String> = new.node_ids().into_iter().collect();
331
332    let added_nodes: Vec<&String> = new_node_ids.difference(&old_node_ids).collect();
333    let removed_nodes: Vec<&String> = old_node_ids.difference(&new_node_ids).collect();
334
335    // Edge keys: (source, target, relation)
336    let old_edge_keys: HashSet<(String, String, String)> = old
337        .edges_with_endpoints()
338        .iter()
339        .map(|(s, t, e)| (s.to_string(), t.to_string(), e.relation.clone()))
340        .collect();
341    let new_edge_keys: HashSet<(String, String, String)> = new
342        .edges_with_endpoints()
343        .iter()
344        .map(|(s, t, e)| (s.to_string(), t.to_string(), e.relation.clone()))
345        .collect();
346
347    let added_edges: Vec<&(String, String, String)> =
348        new_edge_keys.difference(&old_edge_keys).collect();
349    let removed_edges: Vec<&(String, String, String)> =
350        old_edge_keys.difference(&new_edge_keys).collect();
351
352    let mut result = HashMap::new();
353    result.insert("added_nodes".into(), serde_json::json!(added_nodes));
354    result.insert("removed_nodes".into(), serde_json::json!(removed_nodes));
355    result.insert(
356        "added_edges".into(),
357        serde_json::json!(
358            added_edges
359                .iter()
360                .map(|(s, t, r)| { serde_json::json!({"source": s, "target": t, "relation": r}) })
361                .collect::<Vec<_>>()
362        ),
363    );
364    result.insert(
365        "removed_edges".into(),
366        serde_json::json!(
367            removed_edges
368                .iter()
369                .map(|(s, t, r)| { serde_json::json!({"source": s, "target": t, "relation": r}) })
370                .collect::<Vec<_>>()
371        ),
372    );
373    result.insert(
374        "summary".into(),
375        serde_json::json!({
376            "nodes_added": added_nodes.len(),
377            "nodes_removed": removed_nodes.len(),
378            "edges_added": added_edges.len(),
379            "edges_removed": removed_edges.len(),
380            "old_node_count": old.node_count(),
381            "new_node_count": new.node_count(),
382            "old_edge_count": old.edge_count(),
383            "new_edge_count": new.edge_count(),
384        }),
385    );
386
387    result
388}
389
390// ---------------------------------------------------------------------------
391// Community bridges
392// ---------------------------------------------------------------------------
393
394/// Find nodes that bridge multiple communities.
395///
396/// A bridge node has a high ratio of cross-community edges to total edges.
397/// Returns up to `top_n` nodes sorted by bridge ratio descending.
398pub fn community_bridges(
399    graph: &KnowledgeGraph,
400    communities: &HashMap<usize, Vec<String>>,
401    top_n: usize,
402) -> Vec<BridgeNode> {
403    // Build node → community mapping
404    let node_to_community: HashMap<&str, usize> = communities
405        .iter()
406        .flat_map(|(&cid, nodes)| nodes.iter().map(move |n| (n.as_str(), cid)))
407        .collect();
408
409    let mut bridges: Vec<BridgeNode> = graph
410        .node_ids()
411        .into_iter()
412        .filter(|id| !is_file_node(graph, id))
413        .filter_map(|id| {
414            let node = graph.get_node(&id)?;
415            let my_comm = node_to_community.get(id.as_str()).copied()?;
416            let neighbors = graph.neighbor_ids(&id);
417            let total = neighbors.len();
418            if total == 0 {
419                return None;
420            }
421
422            let mut touched: HashSet<usize> = HashSet::new();
423            touched.insert(my_comm);
424            let mut cross = 0usize;
425            for nid in &neighbors {
426                let neighbor_comm = node_to_community
427                    .get(nid.as_str())
428                    .copied()
429                    .unwrap_or(my_comm);
430                if neighbor_comm != my_comm {
431                    cross += 1;
432                    touched.insert(neighbor_comm);
433                }
434            }
435
436            if cross == 0 {
437                return None;
438            }
439
440            let ratio = cross as f64 / total as f64;
441            let mut communities_touched: Vec<usize> = touched.into_iter().collect();
442            communities_touched.sort();
443
444            Some(BridgeNode {
445                id: id.clone(),
446                label: node.label.clone(),
447                total_edges: total,
448                cross_community_edges: cross,
449                bridge_ratio: ratio,
450                communities_touched,
451            })
452        })
453        .collect();
454
455    bridges.sort_by(|a, b| {
456        b.bridge_ratio
457            .partial_cmp(&a.bridge_ratio)
458            .unwrap_or(std::cmp::Ordering::Equal)
459    });
460    bridges.truncate(top_n);
461    bridges
462}
463
464// ---------------------------------------------------------------------------
465// Helpers
466// ---------------------------------------------------------------------------
467
468/// Is this a file-level hub node?
469fn is_file_node(graph: &KnowledgeGraph, node_id: &str) -> bool {
470    if let Some(node) = graph.get_node(node_id) {
471        // label matches source filename
472        if !node.source_file.is_empty()
473            && let Some(fname) = std::path::Path::new(&node.source_file).file_name()
474            && node.label == fname.to_string_lossy()
475        {
476            return true;
477        }
478    }
479    false
480}
481
482/// Is this a method stub (.method_name() or isolated fn()?
483fn is_method_stub(graph: &KnowledgeGraph, node_id: &str) -> bool {
484    if let Some(node) = graph.get_node(node_id) {
485        // Method stub: ".method_name()"
486        if node.label.starts_with('.') && node.label.ends_with("()") {
487            return true;
488        }
489        // Isolated function stub
490        if node.label.ends_with("()") && graph.degree(node_id) <= 1 {
491            return true;
492        }
493    }
494    false
495}
496
497/// Is this a concept node (no file, or no extension)?
498#[cfg(test)]
499fn is_concept_node(graph: &KnowledgeGraph, node_id: &str) -> bool {
500    if let Some(node) = graph.get_node(node_id) {
501        if node.source_file.is_empty() {
502            return true;
503        }
504        let parts: Vec<&str> = node.source_file.split('/').collect();
505        if let Some(last) = parts.last() {
506            if !last.contains('.') {
507                return true;
508            }
509        }
510    }
511    false
512}
513
514/// Compute cohesion for a set of nodes (inline helper).
515fn compute_cohesion(graph: &KnowledgeGraph, community_nodes: &[String]) -> f64 {
516    let n = community_nodes.len();
517    if n <= 1 {
518        return 1.0;
519    }
520    let node_set: HashSet<&str> = community_nodes.iter().map(|s| s.as_str()).collect();
521    let mut actual_edges = 0usize;
522    for node_id in community_nodes {
523        for neighbor in graph.get_neighbors(node_id) {
524            if node_set.contains(neighbor.id.as_str()) {
525                actual_edges += 1;
526            }
527        }
528    }
529    actual_edges /= 2;
530    let possible = n * (n - 1) / 2;
531    if possible == 0 {
532        return 0.0;
533    }
534    actual_edges as f64 / possible as f64
535}
536
537// ---------------------------------------------------------------------------
538// Tests
539// ---------------------------------------------------------------------------
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use graphify_core::confidence::Confidence;
545    use graphify_core::graph::KnowledgeGraph;
546    use graphify_core::model::{GraphEdge, GraphNode, NodeType};
547    use std::collections::HashMap as StdHashMap;
548
549    fn make_node(id: &str, label: &str, source_file: &str) -> GraphNode {
550        GraphNode {
551            id: id.into(),
552            label: label.into(),
553            source_file: source_file.into(),
554            source_location: None,
555            node_type: NodeType::Class,
556            community: None,
557            extra: StdHashMap::new(),
558        }
559    }
560
561    fn make_edge(src: &str, tgt: &str, relation: &str, confidence: Confidence) -> GraphEdge {
562        GraphEdge {
563            source: src.into(),
564            target: tgt.into(),
565            relation: relation.into(),
566            confidence,
567            confidence_score: 1.0,
568            source_file: "test.rs".into(),
569            source_location: None,
570            weight: 1.0,
571            extra: StdHashMap::new(),
572        }
573    }
574
575    fn simple_node(id: &str) -> GraphNode {
576        make_node(id, id, "test.rs")
577    }
578
579    fn simple_edge(src: &str, tgt: &str) -> GraphEdge {
580        make_edge(src, tgt, "calls", Confidence::Extracted)
581    }
582
583    fn build_graph(nodes: &[GraphNode], edges: &[GraphEdge]) -> KnowledgeGraph {
584        let mut g = KnowledgeGraph::new();
585        for n in nodes {
586            let _ = g.add_node(n.clone());
587        }
588        for e in edges {
589            let _ = g.add_edge(e.clone());
590        }
591        g
592    }
593
594    // -- god_nodes ---------------------------------------------------------
595
596    #[test]
597    fn god_nodes_empty_graph() {
598        let g = KnowledgeGraph::new();
599        assert!(god_nodes(&g, 5).is_empty());
600    }
601
602    #[test]
603    fn god_nodes_returns_highest_degree() {
604        let g = build_graph(
605            &[
606                simple_node("hub"),
607                simple_node("a"),
608                simple_node("b"),
609                simple_node("c"),
610                simple_node("leaf"),
611            ],
612            &[
613                simple_edge("hub", "a"),
614                simple_edge("hub", "b"),
615                simple_edge("hub", "c"),
616                simple_edge("a", "leaf"),
617            ],
618        );
619        let gods = god_nodes(&g, 2);
620        assert_eq!(gods.len(), 2);
621        assert_eq!(gods[0].id, "hub");
622        assert_eq!(gods[0].degree, 3);
623    }
624
625    #[test]
626    fn god_nodes_skips_file_nodes() {
627        let g = build_graph(
628            &[
629                make_node("file_hub", "main.rs", "src/main.rs"), // file node
630                simple_node("a"),
631                simple_node("b"),
632            ],
633            &[simple_edge("file_hub", "a"), simple_edge("file_hub", "b")],
634        );
635        let gods = god_nodes(&g, 5);
636        // file_hub should be excluded
637        assert!(gods.iter().all(|g| g.id != "file_hub"));
638    }
639
640    #[test]
641    fn god_nodes_skips_method_stubs() {
642        let g = build_graph(
643            &[
644                make_node("stub", ".init()", "test.rs"), // method stub
645                simple_node("a"),
646            ],
647            &[simple_edge("stub", "a")],
648        );
649        let gods = god_nodes(&g, 5);
650        assert!(gods.iter().all(|g| g.id != "stub"));
651    }
652
653    // -- surprising_connections -------------------------------------------
654
655    #[test]
656    fn surprising_connections_empty() {
657        let g = KnowledgeGraph::new();
658        let communities = HashMap::new();
659        assert!(surprising_connections(&g, &communities, 5).is_empty());
660    }
661
662    #[test]
663    fn cross_community_edge_is_surprising() {
664        let g = build_graph(
665            &[simple_node("a"), simple_node("b")],
666            &[simple_edge("a", "b")],
667        );
668        let mut communities = HashMap::new();
669        communities.insert(0, vec!["a".into()]);
670        communities.insert(1, vec!["b".into()]);
671        let surprises = surprising_connections(&g, &communities, 10);
672        assert!(!surprises.is_empty());
673        assert_eq!(surprises[0].source_community, 0);
674        assert_eq!(surprises[0].target_community, 1);
675    }
676
677    #[test]
678    fn ambiguous_edge_is_surprising() {
679        let g = build_graph(
680            &[simple_node("a"), simple_node("b")],
681            &[make_edge("a", "b", "relates", Confidence::Ambiguous)],
682        );
683        let mut communities = HashMap::new();
684        communities.insert(0, vec!["a".into(), "b".into()]);
685        let surprises = surprising_connections(&g, &communities, 10);
686        assert!(!surprises.is_empty());
687    }
688
689    // -- suggest_questions ------------------------------------------------
690
691    #[test]
692    fn suggest_questions_empty() {
693        let g = KnowledgeGraph::new();
694        let qs = suggest_questions(&g, &HashMap::new(), &HashMap::new(), 10);
695        assert!(qs.is_empty());
696    }
697
698    #[test]
699    fn suggest_questions_ambiguous_edge() {
700        let g = build_graph(
701            &[simple_node("a"), simple_node("b")],
702            &[make_edge("a", "b", "relates", Confidence::Ambiguous)],
703        );
704        let mut communities = HashMap::new();
705        communities.insert(0, vec!["a".into(), "b".into()]);
706        let qs = suggest_questions(&g, &communities, &HashMap::new(), 10);
707        let has_ambiguous = qs.iter().any(|q| {
708            q.get("category")
709                .map(|c| c == "ambiguous_relationship")
710                .unwrap_or(false)
711        });
712        assert!(has_ambiguous);
713    }
714
715    #[test]
716    fn suggest_questions_isolated_node() {
717        let g = build_graph(&[simple_node("lonely")], &[]);
718        let communities = HashMap::new();
719        let qs = suggest_questions(&g, &communities, &HashMap::new(), 10);
720        let has_isolated = qs.iter().any(|q| {
721            q.get("category")
722                .map(|c| c == "isolated_node")
723                .unwrap_or(false)
724        });
725        assert!(has_isolated);
726    }
727
728    // -- graph_diff -------------------------------------------------------
729
730    #[test]
731    fn graph_diff_identical() {
732        let g = build_graph(
733            &[simple_node("a"), simple_node("b")],
734            &[simple_edge("a", "b")],
735        );
736        let diff = graph_diff(&g, &g);
737        let summary = diff.get("summary").unwrap();
738        assert_eq!(summary["nodes_added"], 0);
739        assert_eq!(summary["nodes_removed"], 0);
740    }
741
742    #[test]
743    fn graph_diff_added_node() {
744        let old = build_graph(&[simple_node("a")], &[]);
745        let new = build_graph(&[simple_node("a"), simple_node("b")], &[]);
746        let diff = graph_diff(&old, &new);
747        let summary = diff.get("summary").unwrap();
748        assert_eq!(summary["nodes_added"], 1);
749        assert_eq!(summary["nodes_removed"], 0);
750    }
751
752    #[test]
753    fn graph_diff_removed_node() {
754        let old = build_graph(&[simple_node("a"), simple_node("b")], &[]);
755        let new = build_graph(&[simple_node("a")], &[]);
756        let diff = graph_diff(&old, &new);
757        let summary = diff.get("summary").unwrap();
758        assert_eq!(summary["nodes_removed"], 1);
759    }
760
761    // -- helpers ----------------------------------------------------------
762
763    #[test]
764    fn is_file_node_true() {
765        let g = build_graph(&[make_node("f", "main.rs", "src/main.rs")], &[]);
766        assert!(is_file_node(&g, "f"));
767    }
768
769    #[test]
770    fn is_file_node_false() {
771        let g = build_graph(&[simple_node("a")], &[]);
772        assert!(!is_file_node(&g, "a"));
773    }
774
775    #[test]
776    fn is_method_stub_true() {
777        let g = build_graph(&[make_node("m", ".init()", "test.rs")], &[]);
778        assert!(is_method_stub(&g, "m"));
779    }
780
781    #[test]
782    fn is_concept_node_no_source() {
783        let g = build_graph(&[make_node("c", "SomeConcept", "")], &[]);
784        assert!(is_concept_node(&g, "c"));
785    }
786
787    #[test]
788    fn god_nodes_disambiguates_lib_labels() {
789        let mut n1 = make_node("lib1", "lib", "crates/graphify-export/src/lib.rs");
790        n1.node_type = NodeType::Module;
791        let mut n2 = make_node("lib2", "lib", "crates/graphify-analyze/src/lib.rs");
792        n2.node_type = NodeType::Module;
793        let a = simple_node("a");
794        let b = simple_node("b");
795        let c = simple_node("c");
796
797        let g = build_graph(
798            &[n1, n2, a, b, c],
799            &[
800                simple_edge("lib1", "a"),
801                simple_edge("lib1", "b"),
802                simple_edge("lib1", "c"),
803                simple_edge("lib2", "a"),
804                simple_edge("lib2", "b"),
805            ],
806        );
807
808        let gods = god_nodes(&g, 5);
809        let labels: Vec<&str> = gods.iter().map(|g| g.label.as_str()).collect();
810        // Both should be disambiguated with crate name
811        assert!(
812            labels.contains(&"graphify-export::lib"),
813            "missing graphify-export::lib in {labels:?}"
814        );
815        assert!(
816            labels.contains(&"graphify-analyze::lib"),
817            "missing graphify-analyze::lib in {labels:?}"
818        );
819    }
820
821    #[test]
822    fn god_nodes_preserves_non_generic_labels() {
823        let n = make_node("auth", "AuthService", "src/auth.rs");
824        let a = simple_node("a");
825        let b = simple_node("b");
826
827        let g = build_graph(
828            &[n, a, b],
829            &[simple_edge("auth", "a"), simple_edge("auth", "b")],
830        );
831
832        let gods = god_nodes(&g, 5);
833        assert!(gods.iter().any(|g| g.label == "AuthService"));
834    }
835
836    #[test]
837    fn community_bridges_finds_cross_community_nodes() {
838        let mut a = simple_node("a");
839        a.community = Some(0);
840        let mut b = simple_node("b");
841        b.community = Some(0);
842        let mut c = simple_node("c");
843        c.community = Some(1);
844        let mut bridge = simple_node("bridge");
845        bridge.community = Some(0);
846
847        let g = build_graph(
848            &[a, b, c, bridge.clone()],
849            &[
850                simple_edge("bridge", "a"),
851                simple_edge("bridge", "b"),
852                simple_edge("bridge", "c"),
853            ],
854        );
855
856        let communities: HashMap<usize, Vec<String>> = [
857            (0, vec!["a".into(), "b".into(), "bridge".into()]),
858            (1, vec!["c".into()]),
859        ]
860        .into();
861
862        let bridges = community_bridges(&g, &communities, 10);
863        assert!(!bridges.is_empty(), "should find at least one bridge");
864        // "bridge" and "c" are both bridge nodes; "c" has ratio 1.0, "bridge" has 0.33
865        // Just verify "bridge" appears somewhere
866        assert!(
867            bridges.iter().any(|b| b.id == "bridge"),
868            "bridge node should appear in results"
869        );
870        let bridge_entry = bridges.iter().find(|b| b.id == "bridge").unwrap();
871        assert_eq!(bridge_entry.cross_community_edges, 1);
872        assert_eq!(bridge_entry.total_edges, 3);
873        assert!(bridge_entry.communities_touched.contains(&0));
874        assert!(bridge_entry.communities_touched.contains(&1));
875    }
876
877    #[test]
878    fn community_bridges_empty_when_single_community() {
879        let mut a = simple_node("a");
880        a.community = Some(0);
881        let mut b = simple_node("b");
882        b.community = Some(0);
883
884        let g = build_graph(&[a, b], &[simple_edge("a", "b")]);
885
886        let communities: HashMap<usize, Vec<String>> = [(0, vec!["a".into(), "b".into()])].into();
887
888        let bridges = community_bridges(&g, &communities, 10);
889        assert!(bridges.is_empty(), "no bridges in single community");
890    }
891}