Skip to main content

aptu_coder_core/graph/
structural.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Structural knowledge graph over petgraph DiGraph with BFS blast-radius traversal.
4
5use crate::analyze::FileAnalysisOutput;
6use crate::graph::call_graph::CallGraph;
7use petgraph::Direction;
8use petgraph::graph::{DiGraph, NodeIndex};
9use petgraph::visit::EdgeRef;
10use serde::{Deserialize, Serialize};
11use std::collections::{BTreeMap, HashMap, HashSet};
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub enum SymbolKind {
15    Function,
16    Class,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub enum Node {
21    File {
22        path: String,
23    },
24    Symbol {
25        name: String,
26        kind: SymbolKind,
27        file_path: String,
28        line: usize,
29    },
30    Module {
31        path: String,
32    },
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
36pub enum Edge {
37    Contains,
38    Calls,
39    Imports,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct StructuralGraph {
44    pub graph: DiGraph<Node, Edge>,
45    #[serde(skip)]
46    symbol_index: HashMap<String, Vec<NodeIndex>>,
47}
48
49type BuildNodesResult = (
50    DiGraph<Node, Edge>,
51    HashSet<(NodeIndex, NodeIndex)>,
52    HashMap<String, Vec<NodeIndex>>,
53    HashMap<NodeIndex, usize>,
54);
55
56impl StructuralGraph {
57    fn build_symbol_index(graph: &DiGraph<Node, Edge>) -> HashMap<String, Vec<NodeIndex>> {
58        let mut index: HashMap<String, Vec<NodeIndex>> = HashMap::new();
59        for idx in graph.node_indices() {
60            if let Node::Symbol { name, .. } = &graph[idx] {
61                index.entry(name.clone()).or_default().push(idx);
62            }
63        }
64        index
65    }
66
67    pub fn from_graph(graph: DiGraph<Node, Edge>) -> Self {
68        let symbol_index = Self::build_symbol_index(&graph);
69        StructuralGraph {
70            graph,
71            symbol_index,
72        }
73    }
74
75    pub(crate) fn rebuild_symbol_index(&mut self) {
76        self.symbol_index = Self::build_symbol_index(&self.graph);
77    }
78
79    /// Disambiguate a list of candidate nodes for a symbol using the heuristic:
80    /// a. Return immediately if 0 or 1 candidates.
81    /// b. Same-file preference: filter to candidates in call_file; if non-empty, use that pool.
82    /// c. Line-proximity: keep only the candidate(s) with minimum distance to call_line.
83    /// d. Arg-count match: if call_arg_count is Some(n), prefer a candidate matching that param count.
84    /// e. Fallback: return first candidate (first-definition-wins).
85    fn resolve_candidate(
86        candidates: &[NodeIndex],
87        graph: &DiGraph<Node, Edge>,
88        call_file: &str,
89        call_line: usize,
90        call_arg_count: Option<usize>,
91        param_counts: &HashMap<NodeIndex, usize>,
92    ) -> Option<NodeIndex> {
93        if candidates.is_empty() {
94            return None;
95        }
96        if candidates.len() == 1 {
97            return candidates.first().copied();
98        }
99
100        // Stage b: Same-file preference
101        let same_file: Vec<NodeIndex> = candidates
102            .iter()
103            .filter(|idx| {
104                if let Node::Symbol { file_path, .. } = &graph[**idx] {
105                    file_path == call_file
106                } else {
107                    false
108                }
109            })
110            .copied()
111            .collect();
112
113        let mut pool: Vec<NodeIndex> = if same_file.is_empty() {
114            candidates.to_vec()
115        } else {
116            same_file
117        };
118
119        if pool.len() == 1 {
120            return pool.first().copied();
121        }
122
123        // Stage c: Line-proximity
124        let min_line_distance = pool
125            .iter()
126            .filter_map(|idx| {
127                if let Node::Symbol { line, .. } = &graph[*idx] {
128                    Some(line.abs_diff(call_line))
129                } else {
130                    None
131                }
132            })
133            .min()?;
134
135        pool.retain(|idx| {
136            if let Node::Symbol { line, .. } = &graph[*idx] {
137                line.abs_diff(call_line) == min_line_distance
138            } else {
139                false
140            }
141        });
142
143        if pool.len() == 1 {
144            return pool.first().copied();
145        }
146
147        // Stage d: Arg-count match
148        if let Some(arg_count) = call_arg_count
149            && let Some(matching) = pool
150                .iter()
151                .find(|idx| param_counts.get(idx) == Some(&arg_count))
152        {
153            return Some(*matching);
154        }
155
156        // Stage e: Fallback (first-definition-wins)
157        pool.first().copied()
158    }
159
160    fn build_nodes(entries: &[FileAnalysisOutput]) -> BuildNodesResult {
161        let mut graph = DiGraph::new();
162        let mut seen: HashSet<(NodeIndex, NodeIndex)> = HashSet::new();
163        let mut symbol_index: HashMap<String, Vec<NodeIndex>> = HashMap::new();
164        let mut param_counts: HashMap<NodeIndex, usize> = HashMap::new();
165
166        for entry in entries {
167            let fp = &entry.path;
168            let file = graph.add_node(Node::File {
169                path: fp.to_string(),
170            });
171
172            for f in &entry.semantic.functions {
173                let n = graph.add_node(Node::Symbol {
174                    name: f.name.clone(),
175                    kind: SymbolKind::Function,
176                    file_path: fp.to_string(),
177                    line: f.line,
178                });
179                if seen.insert((file, n)) {
180                    graph.add_edge(file, n, Edge::Contains);
181                }
182                symbol_index.entry(f.name.clone()).or_default().push(n);
183                param_counts.insert(n, f.parameters.len());
184            }
185            for c in &entry.semantic.classes {
186                let n = graph.add_node(Node::Symbol {
187                    name: c.name.clone(),
188                    kind: SymbolKind::Class,
189                    file_path: fp.to_string(),
190                    line: c.line,
191                });
192                if seen.insert((file, n)) {
193                    graph.add_edge(file, n, Edge::Contains);
194                }
195                symbol_index.entry(c.name.clone()).or_default().push(n);
196            }
197            for im in &entry.semantic.imports {
198                if !im.module.is_empty() {
199                    let n = graph.add_node(Node::Module {
200                        path: im.module.clone(),
201                    });
202                    if seen.insert((file, n)) {
203                        graph.add_edge(file, n, Edge::Imports);
204                    }
205                }
206            }
207        }
208
209        (graph, seen, symbol_index, param_counts)
210    }
211
212    pub fn build_from_analysis(entries: &[FileAnalysisOutput]) -> Self {
213        let (mut graph, mut seen, symbol_index, param_counts) = Self::build_nodes(entries);
214
215        // Pass 2: Resolve call edges against the now-complete symbol_index using disambiguation.
216        for entry in entries {
217            for cl in &entry.semantic.calls {
218                let caller_candidates = symbol_index
219                    .get(&cl.caller)
220                    .map(|v| v.as_slice())
221                    .unwrap_or(&[]);
222                let callee_candidates = symbol_index
223                    .get(&cl.callee)
224                    .map(|v| v.as_slice())
225                    .unwrap_or(&[]);
226
227                let caller = Self::resolve_candidate(
228                    caller_candidates,
229                    &graph,
230                    entry.path.as_str(),
231                    cl.line,
232                    None,
233                    &param_counts,
234                );
235                let callee = Self::resolve_candidate(
236                    callee_candidates,
237                    &graph,
238                    entry.path.as_str(),
239                    cl.line,
240                    cl.arg_count,
241                    &param_counts,
242                );
243
244                if let (Some(c), Some(e)) = (caller, callee)
245                    && seen.insert((c, e))
246                {
247                    graph.add_edge(c, e, Edge::Calls);
248                }
249            }
250        }
251
252        StructuralGraph {
253            graph,
254            symbol_index,
255        }
256    }
257
258    /// Build a StructuralGraph from an already-built CallGraph plus the same entries used to
259    /// build it. Reuses CallGraph::callees (already-resolved caller/callee names, including
260    /// scope-prefix stripping) instead of re-deriving Calls edges from entry.semantic.calls, so
261    /// the expensive edge-resolution pass runs exactly once across both graphs. Node/symbol_index
262    /// construction (Pass 1) is unavoidable since CallGraph does not track SymbolKind or imports.
263    /// Note: unlike build_from_analysis, this does not have per-call arg_count available (CallEdge
264    /// does not carry it), so candidate disambiguation falls back to same-file preference and line
265    /// proximity only, without the arg-count tie-break stage.
266    pub fn from_call_graph(entries: &[FileAnalysisOutput], call_graph: &CallGraph) -> Self {
267        let (mut graph, mut seen, symbol_index, param_counts) = Self::build_nodes(entries);
268
269        for (caller_name, edges) in &call_graph.callees {
270            let caller_candidates = symbol_index
271                .get(caller_name)
272                .map(|v| v.as_slice())
273                .unwrap_or(&[]);
274
275            for edge in edges {
276                let callee_candidates = symbol_index
277                    .get(&edge.neighbor_name)
278                    .map(|v| v.as_slice())
279                    .unwrap_or(&[]);
280                let call_file = edge.path.to_string_lossy();
281
282                let caller = Self::resolve_candidate(
283                    caller_candidates,
284                    &graph,
285                    &call_file,
286                    edge.line,
287                    None,
288                    &param_counts,
289                );
290                let callee = Self::resolve_candidate(
291                    callee_candidates,
292                    &graph,
293                    &call_file,
294                    edge.line,
295                    None,
296                    &param_counts,
297                );
298
299                if let (Some(c), Some(e)) = (caller, callee)
300                    && seen.insert((c, e))
301                {
302                    graph.add_edge(c, e, Edge::Calls);
303                }
304            }
305        }
306
307        StructuralGraph {
308            graph,
309            symbol_index,
310        }
311    }
312
313    /// BFS traversal returning both the visited set (including start) and the tail
314    /// (neighbors discovered, excluding start).
315    ///
316    /// The visited set contains all nodes reached up to the specified depth.
317    /// The tail is the BFS-order sequence of nodes discovered, not including start.
318    fn bfs_frontier(&self, start: NodeIndex, depth: usize) -> (HashSet<NodeIndex>, Vec<NodeIndex>) {
319        let mut visited = HashSet::new();
320        let mut result = Vec::new();
321        let mut frontier = vec![start];
322        visited.insert(start);
323        for _ in 0..depth {
324            if frontier.is_empty() {
325                break;
326            }
327            let mut next = Vec::new();
328            for node in frontier {
329                for nb in self.graph.neighbors(node) {
330                    if visited.insert(nb) {
331                        result.push(nb);
332                        next.push(nb);
333                    }
334                }
335            }
336            frontier = next;
337        }
338        (visited, result)
339    }
340
341    pub fn bfs_blast_radius(&self, symbol: &str, depth: usize) -> Vec<NodeIndex> {
342        let Some(start) = self
343            .symbol_index
344            .get(symbol)
345            .and_then(|v| v.first())
346            .copied()
347        else {
348            return vec![];
349        };
350        self.bfs_frontier(start, depth).1
351    }
352
353    /// Blast-radius subgraph including both nodes and edges.
354    ///
355    /// Returns a tuple of (nodes, edges) where:
356    /// - nodes: Vec<NodeIndex> with the start symbol first, followed by all discovered nodes in BFS order
357    /// - edges: Vec<(NodeIndex, NodeIndex, Edge)> containing every edge whose source and target
358    ///   are both in the visited set (not just edges walked by the BFS tree), allowing clients
359    ///   to fully reconstruct the subgraph's connectivity
360    ///
361    /// If the symbol is not found, returns (vec![], vec![]).
362    pub fn blast_radius_subgraph(
363        &self,
364        symbol: &str,
365        depth: usize,
366    ) -> (Vec<NodeIndex>, Vec<(NodeIndex, NodeIndex, Edge)>) {
367        let Some(start) = self
368            .symbol_index
369            .get(symbol)
370            .and_then(|v| v.first())
371            .copied()
372        else {
373            return (vec![], vec![]);
374        };
375
376        let (visited, tail) = self.bfs_frontier(start, depth);
377
378        // Build node list: start first, then all discovered nodes in BFS order
379        let mut nodes = vec![start];
380        nodes.extend(tail);
381
382        // Collect all edges whose both source and target are in the visited set
383        let edges: Vec<(NodeIndex, NodeIndex, Edge)> = self
384            .graph
385            .edge_references()
386            .filter(|e| visited.contains(&e.source()) && visited.contains(&e.target()))
387            .map(|e| (e.source(), e.target(), e.weight().clone()))
388            .collect();
389
390        (nodes, edges)
391    }
392
393    /// Renders a subgraph defined by `nodes` into a prompt-ready string representation.
394    ///
395    /// Formats function symbols grouped by file path:
396    /// ```text
397    /// // path/to/file.rs
398    /// fn name [calls: a, b] [callers: c]
399    /// ```
400    ///
401    /// Files are sorted lexicographically, functions within files are sorted by name.
402    /// Non-`Symbol` nodes and non-`Function` `SymbolKind`s are skipped.
403    pub fn render_subgraph_text(&self, nodes: &[NodeIndex]) -> String {
404        let node_set: HashSet<NodeIndex> = nodes.iter().copied().collect();
405
406        // Adjacency maps for Calls edges within the given node set
407        let mut calls_map: HashMap<NodeIndex, Vec<String>> = HashMap::new();
408        let mut callers_map: HashMap<NodeIndex, Vec<String>> = HashMap::new();
409
410        for &idx in &node_set {
411            if idx.index() >= self.graph.node_count() {
412                continue;
413            }
414
415            // Outgoing Calls edges -> callees
416            for edge in self.graph.edges_directed(idx, Direction::Outgoing) {
417                if *edge.weight() == Edge::Calls
418                    && node_set.contains(&edge.target())
419                    && let Node::Symbol { name, .. } = &self.graph[edge.target()]
420                {
421                    calls_map.entry(idx).or_default().push(name.clone());
422                }
423            }
424
425            // Incoming Calls edges -> callers
426            for edge in self.graph.edges_directed(idx, Direction::Incoming) {
427                if *edge.weight() == Edge::Calls
428                    && node_set.contains(&edge.source())
429                    && let Node::Symbol { name, .. } = &self.graph[edge.source()]
430                {
431                    callers_map.entry(idx).or_default().push(name.clone());
432                }
433            }
434        }
435
436        // Group function symbols by file path (BTreeMap for deterministic file order)
437        let mut file_groups: BTreeMap<String, Vec<(String, NodeIndex)>> = BTreeMap::new();
438        for &idx in &node_set {
439            if idx.index() >= self.graph.node_count() {
440                continue;
441            }
442            if let Node::Symbol {
443                name,
444                kind: SymbolKind::Function,
445                file_path,
446                ..
447            } = &self.graph[idx]
448            {
449                file_groups
450                    .entry(file_path.clone())
451                    .or_default()
452                    .push((name.clone(), idx));
453            }
454        }
455
456        let mut output = String::new();
457        for (file_path, mut funcs) in file_groups {
458            funcs.sort_by(|a, b| a.0.cmp(&b.0));
459            funcs.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
460
461            if !output.is_empty() {
462                output.push('\n');
463            }
464            output.push_str(&format!("// {}\n", file_path));
465
466            for (name, idx) in funcs {
467                let mut line = format!("fn {}", name);
468
469                if let Some(mut callees) = calls_map.remove(&idx) {
470                    callees.sort();
471                    callees.dedup();
472                    if !callees.is_empty() {
473                        line.push_str(&format!(" [calls: {}]", callees.join(", ")));
474                    }
475                }
476
477                if let Some(mut callers) = callers_map.remove(&idx) {
478                    callers.sort();
479                    callers.dedup();
480                    if !callers.is_empty() {
481                        line.push_str(&format!(" [callers: {}]", callers.join(", ")));
482                    }
483                }
484
485                output.push_str(&line);
486                output.push('\n');
487            }
488        }
489
490        output
491    }
492
493    /// Resolves multiple symbol names to their first matching `NodeIndex` in the symbol index.
494    pub fn find_symbols(&self, names: &[&str]) -> Vec<NodeIndex> {
495        let mut indices = Vec::new();
496        for name in names {
497            if let Some(first) = self
498                .symbol_index
499                .get(*name)
500                .and_then(|v| v.first().copied())
501            {
502                indices.push(first);
503            }
504        }
505        indices
506    }
507
508    /// Bidirectional blast-radius traversal discovering both callers and callees.
509    ///
510    /// Walks both `Direction::Incoming` and `Direction::Outgoing` edges filtered to `Edge::Calls`.
511    /// Caps the visited set at `max_nodes` and traversal depth at `max_depth`.
512    /// Returns `(nodes, edges)` for the induced subgraph where all edges between visited nodes
513    /// are included.
514    pub fn blast_radius_bidirectional(
515        &self,
516        seeds: &[NodeIndex],
517        max_nodes: usize,
518        max_depth: usize,
519    ) -> (Vec<NodeIndex>, Vec<(NodeIndex, NodeIndex, Edge)>) {
520        if seeds.is_empty() || max_nodes == 0 || max_depth == 0 {
521            return (vec![], vec![]);
522        }
523
524        let mut visited: HashSet<NodeIndex> = HashSet::new();
525        let mut result: Vec<NodeIndex> = Vec::new();
526        let mut frontier: Vec<NodeIndex> = Vec::new();
527
528        // Initialize with valid seeds up to max_nodes
529        for &seed in seeds {
530            if seed.index() < self.graph.node_count() && visited.insert(seed) {
531                result.push(seed);
532                frontier.push(seed);
533                if result.len() >= max_nodes {
534                    break;
535                }
536            }
537        }
538
539        let mut depth = 0;
540        while depth < max_depth && !frontier.is_empty() && result.len() < max_nodes {
541            depth += 1;
542
543            // Collect this level's candidates first and sort by NodeIndex so
544            // traversal order (and therefore truncation on max_nodes) is
545            // deterministic regardless of the graph's internal edge order.
546            let mut candidates: Vec<NodeIndex> = Vec::new();
547            for &node in &frontier {
548                for edge in self.graph.edges_directed(node, Direction::Outgoing) {
549                    if *edge.weight() == Edge::Calls && !visited.contains(&edge.target()) {
550                        candidates.push(edge.target());
551                    }
552                }
553                for edge in self.graph.edges_directed(node, Direction::Incoming) {
554                    if *edge.weight() == Edge::Calls && !visited.contains(&edge.source()) {
555                        candidates.push(edge.source());
556                    }
557                }
558            }
559            candidates.sort_by_key(|n| n.index());
560            candidates.dedup();
561
562            let mut next = Vec::new();
563            for candidate in candidates {
564                if visited.insert(candidate) {
565                    result.push(candidate);
566                    next.push(candidate);
567                    if result.len() >= max_nodes {
568                        break;
569                    }
570                }
571            }
572            frontier = next;
573        }
574
575        // Collect induced subgraph edges (all edges where both endpoints are in visited)
576        let edges: Vec<(NodeIndex, NodeIndex, Edge)> = self
577            .graph
578            .edge_references()
579            .filter(|e| visited.contains(&e.source()) && visited.contains(&e.target()))
580            .map(|e| (e.source(), e.target(), e.weight().clone()))
581            .collect();
582
583        (result, edges)
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use crate::types::{CallInfo, ClassInfo, FunctionInfo, ImportInfo, SemanticAnalysis};
591    use std::path::PathBuf;
592
593    fn make_output(
594        path: &str,
595        funcs: Vec<&str>,
596        classes: Vec<&str>,
597        imports: Vec<&str>,
598        calls: Vec<(&str, &str)>,
599    ) -> FileAnalysisOutput {
600        FileAnalysisOutput::new(
601            path.to_string(),
602            format!("{}:1:1:1", path),
603            SemanticAnalysis {
604                functions: funcs
605                    .into_iter()
606                    .map(|n| FunctionInfo {
607                        name: n.to_string(),
608                        line: 1,
609                        end_line: 6,
610                        parameters: vec![],
611                        return_type: None,
612                    })
613                    .collect(),
614                classes: classes
615                    .into_iter()
616                    .map(|n| ClassInfo {
617                        name: n.to_string(),
618                        line: 1,
619                        end_line: 10,
620                        methods: vec![],
621                        fields: vec![],
622                        inherits: vec![],
623                    })
624                    .collect(),
625                imports: imports
626                    .into_iter()
627                    .map(|m| ImportInfo {
628                        module: m.to_string(),
629                        items: vec![],
630                        line: 1,
631                    })
632                    .collect(),
633                references: vec![],
634                call_frequency: Default::default(),
635                calls: calls
636                    .into_iter()
637                    .map(|(c, e)| CallInfo {
638                        caller: c.to_string(),
639                        callee: e.to_string(),
640                        line: 1,
641                        column: 0,
642                        arg_count: None,
643                    })
644                    .collect(),
645                impl_traits: vec![],
646                def_use_sites: vec![],
647            },
648            10,
649            None,
650        )
651    }
652
653    /// Test helper for creating custom FunctionInfo with explicit line numbers and parameters.
654    fn make_function(name: &str, line: usize, param_count: usize) -> FunctionInfo {
655        FunctionInfo {
656            name: name.to_string(),
657            line,
658            end_line: line + 5,
659            parameters: (0..param_count).map(|i| format!("p{}", i)).collect(),
660            return_type: None,
661        }
662    }
663
664    /// Test helper for creating custom CallInfo with explicit call and definition lines and arg count.
665    fn make_call(
666        caller: &str,
667        callee: &str,
668        call_line: usize,
669        arg_count: Option<usize>,
670    ) -> CallInfo {
671        CallInfo {
672            caller: caller.to_string(),
673            callee: callee.to_string(),
674            line: call_line,
675            column: 0,
676            arg_count,
677        }
678    }
679
680    /// Test helper for building a FileAnalysisOutput with custom FunctionInfo and CallInfo.
681    fn make_output_custom(
682        path: &str,
683        functions: Vec<FunctionInfo>,
684        calls: Vec<CallInfo>,
685    ) -> FileAnalysisOutput {
686        FileAnalysisOutput::new(
687            path.to_string(),
688            format!("{}:1:1:1", path),
689            SemanticAnalysis {
690                functions,
691                classes: vec![],
692                imports: vec![],
693                references: vec![],
694                call_frequency: Default::default(),
695                calls,
696                impl_traits: vec![],
697                def_use_sites: vec![],
698            },
699            10,
700            None,
701        )
702    }
703
704    #[test]
705    fn test_build_happy_path() {
706        let e = make_output(
707            "src/main.rs",
708            vec!["main", "helper"],
709            vec!["Config"],
710            vec!["std::collections"],
711            vec![("main", "helper")],
712        );
713        let g = StructuralGraph::build_from_analysis(&[e]);
714        assert!(g.graph.node_count() >= 4, "nodes={}", g.graph.node_count());
715        assert!(g.graph.edge_count() >= 5, "edges={}", g.graph.edge_count());
716        assert!(g.graph.edge_indices().any(|i| g.graph[i] == Edge::Calls));
717    }
718
719    #[test]
720    fn test_build_empty_input() {
721        let e = make_output("src/e.rs", vec![], vec![], vec![], vec![]);
722        let g = StructuralGraph::build_from_analysis(&[e]);
723        assert_eq!(g.graph.node_count(), 1);
724        assert_eq!(g.graph.edge_count(), 0);
725    }
726
727    #[test]
728    /// Two files with the same call edge now produce 2 Calls edges because
729    /// same-file preference resolves each file's "main -> helper" call within its own file.
730    /// This test verifies that no edge crosses from one file's main to the other file's helper.
731    fn test_build_no_cross_file_collision() {
732        let e1 = make_output(
733            "src/a.rs",
734            vec!["main", "helper"],
735            vec![],
736            vec![],
737            vec![("main", "helper")],
738        );
739        let e2 = make_output(
740            "src/b.rs",
741            vec!["main", "helper"],
742            vec![],
743            vec![],
744            vec![("main", "helper")],
745        );
746        let g = StructuralGraph::build_from_analysis(&[e1, e2]);
747        let calls_edges: Vec<_> = g
748            .graph
749            .edge_indices()
750            .filter(|i| g.graph[*i] == Edge::Calls)
751            .collect();
752        assert_eq!(
753            calls_edges.len(),
754            2,
755            "expected 2 Calls edges (same-file preference), got {}",
756            calls_edges.len()
757        );
758
759        // Verify that each edge's source and target are from the same file
760        for edge_idx in calls_edges {
761            let (source, target) = g.graph.edge_endpoints(edge_idx).unwrap();
762            let source_file = match &g.graph[source] {
763                Node::Symbol { file_path, .. } => file_path,
764                _ => panic!("source must be Symbol"),
765            };
766            let target_file = match &g.graph[target] {
767                Node::Symbol { file_path, .. } => file_path,
768                _ => panic!("target must be Symbol"),
769            };
770            assert_eq!(
771                source_file, target_file,
772                "call edge must not cross files: {} -> {}",
773                source_file, target_file
774            );
775        }
776    }
777
778    #[test]
779    fn test_bfs_diamond() {
780        let mut g = DiGraph::new();
781        let mut sym = |n: &str| {
782            g.add_node(Node::Symbol {
783                name: n.into(),
784                kind: SymbolKind::Function,
785                file_path: "t.rs".into(),
786                line: 1,
787            })
788        };
789        let a = sym("A");
790        let b = sym("B");
791        let c = sym("C");
792        let d = sym("D");
793        g.add_edge(a, b, Edge::Calls);
794        g.add_edge(a, c, Edge::Calls);
795        g.add_edge(b, d, Edge::Calls);
796        g.add_edge(c, d, Edge::Calls);
797        let graph = StructuralGraph::from_graph(g);
798        let r = graph.bfs_blast_radius("A", 2);
799        assert_eq!(r.len(), 3, "expected 3 nodes, got {:?}", r);
800    }
801
802    #[test]
803    fn test_bfs_symbol_not_found() {
804        let graph = StructuralGraph::from_graph(DiGraph::new());
805        assert!(graph.bfs_blast_radius("x", 3).is_empty());
806    }
807
808    #[test]
809    fn test_build_uses_explicit_path_field() {
810        // Regression test: ensure build_from_analysis uses entry.path,
811        // not the first line of formatted text, when they differ.
812        let mut entry = make_output("correct.rs", vec!["foo"], vec![], vec![], vec![]);
813        entry.formatted = "WRONG_PATH\nsome details".to_string();
814
815        let graph = StructuralGraph::build_from_analysis(&[entry]);
816
817        // File node must use correct.rs
818        let file_paths: Vec<&str> = graph
819            .graph
820            .node_weights()
821            .filter_map(|n| match n {
822                Node::File { path } => Some(path.as_str()),
823                _ => None,
824            })
825            .collect();
826        assert_eq!(file_paths, vec!["correct.rs"]);
827
828        // Symbol node must use correct.rs
829        let symbol_file_paths: Vec<&str> = graph
830            .graph
831            .node_weights()
832            .filter_map(|n| match n {
833                Node::Symbol { file_path, .. } => Some(file_path.as_str()),
834                _ => None,
835            })
836            .collect();
837        assert_eq!(symbol_file_paths, vec!["correct.rs"]);
838    }
839
840    #[test]
841    /// A single file with two identical (caller, callee) entries in the calls list
842    /// must still collapse to exactly 1 Calls edge due to the `seen` HashSet.
843    fn test_build_dedup_identical_call_within_one_file() {
844        let e = make_output(
845            "src/a.rs",
846            vec!["main", "helper"],
847            vec![],
848            vec![],
849            vec![("main", "helper"), ("main", "helper")], // duplicate call
850        );
851        let g = StructuralGraph::build_from_analysis(&[e]);
852        let n = g
853            .graph
854            .edge_indices()
855            .filter(|i| g.graph[*i] == Edge::Calls)
856            .count();
857        assert_eq!(
858            n, 1,
859            "expected 1 Calls edge (dedup identical calls), got {}",
860            n
861        );
862    }
863
864    #[test]
865    /// Test same-file preference: when two files each define a same-named callee
866    /// at the same line (so line-proximity doesn't break the tie), the caller's own
867    /// file is preferred. This isolates the same-file-preference stage.
868    fn test_resolve_same_file_preference() {
869        // File a.rs defines helper at line 50
870        // File b.rs defines helper at line 50 (same line distance to call at line 50)
871        // Call in a.rs at line 50 should resolve to a.rs's helper (same file), not b.rs's
872        let e_a = make_output_custom(
873            "src/a.rs",
874            vec![make_function("main", 1, 0), make_function("helper", 50, 0)],
875            vec![make_call("main", "helper", 50, None)],
876        );
877        let e_b = make_output_custom("src/b.rs", vec![make_function("helper", 50, 0)], vec![]);
878
879        let g = StructuralGraph::build_from_analysis(&[e_a, e_b]);
880
881        // Find the Calls edge
882        let calls_edges: Vec<_> = g
883            .graph
884            .edge_indices()
885            .filter(|i| g.graph[*i] == Edge::Calls)
886            .collect();
887        assert_eq!(calls_edges.len(), 1);
888
889        // Extract source and target
890        let (_source, target) = g.graph.edge_endpoints(calls_edges[0]).unwrap();
891        let target_file = match &g.graph[target] {
892            Node::Symbol { file_path, .. } => file_path,
893            _ => panic!("target must be Symbol"),
894        };
895        assert_eq!(
896            target_file, "src/a.rs",
897            "call should resolve to helper in same file"
898        );
899    }
900
901    #[test]
902    /// Test line-proximity fallback: when same-file preference doesn't narrow to one
903    /// candidate, the candidate whose definition line is closest to the call line wins.
904    /// This test puts the call in a third file so same-file preference does not apply.
905    fn test_resolve_line_proximity_fallback() {
906        // File a.rs defines helper at line 45 (5 away from call at line 50)
907        // File b.rs defines helper at line 30 (20 away from call at line 50)
908        // Call in c.rs (neutral file) should prefer a.rs based on line proximity alone
909        let e_a = make_output_custom("src/a.rs", vec![make_function("helper", 45, 0)], vec![]);
910        let e_b = make_output_custom("src/b.rs", vec![make_function("helper", 30, 0)], vec![]);
911        let e_c = make_output_custom(
912            "src/c.rs",
913            vec![make_function("caller", 1, 0)],
914            vec![make_call("caller", "helper", 50, None)],
915        );
916
917        let g = StructuralGraph::build_from_analysis(&[e_a, e_b, e_c]);
918
919        // Find the Calls edge
920        let calls_edges: Vec<_> = g
921            .graph
922            .edge_indices()
923            .filter(|i| g.graph[*i] == Edge::Calls)
924            .collect();
925        assert_eq!(calls_edges.len(), 1);
926
927        let (_, target) = g.graph.edge_endpoints(calls_edges[0]).unwrap();
928        let target_line = match &g.graph[target] {
929            Node::Symbol { line, .. } => *line,
930            _ => panic!("target must be Symbol"),
931        };
932        assert_eq!(
933            target_line, 45,
934            "call should resolve to closest definition line"
935        );
936    }
937
938    #[test]
939    /// Test arg-count fallback: when same-file preference doesn't reduce to one
940    /// candidate and line-proximity produces a tie (equal distances), the candidate
941    /// whose parameter count matches the call's arg_count is preferred.
942    fn test_resolve_arg_count_fallback() {
943        // File a.rs defines two overloads of "helper":
944        // - helper_v1 at line 5 with 1 param (distance 5 from call at line 10)
945        // - helper_v2 at line 15 with 2 params (distance 5 from call at line 10)
946        // Call at line 10 with 2 args should prefer helper_v2 (param count match)
947        // even though both are equidistant via line-proximity
948        let e_a = make_output_custom(
949            "src/a.rs",
950            vec![
951                make_function("main", 1, 0),
952                make_function("helper", 5, 1), // 1-param version at line 5
953                make_function("helper", 15, 2), // 2-param version at line 15
954            ],
955            vec![make_call("main", "helper", 10, Some(2))],
956        );
957
958        let g = StructuralGraph::build_from_analysis(&[e_a]);
959
960        let calls_edges: Vec<_> = g
961            .graph
962            .edge_indices()
963            .filter(|i| g.graph[*i] == Edge::Calls)
964            .collect();
965        assert_eq!(calls_edges.len(), 1);
966
967        let (_, target) = g.graph.edge_endpoints(calls_edges[0]).unwrap();
968        let target_line = match &g.graph[target] {
969            Node::Symbol { line, .. } => *line,
970            _ => panic!("target must be Symbol"),
971        };
972        assert_eq!(
973            target_line, 15,
974            "call should resolve to 2-param version (line 15) via arg-count match"
975        );
976    }
977
978    #[test]
979    /// Test fallback to first-definition-wins: when all disambiguation heuristics
980    /// fail to narrow down to one candidate, the first candidate in insertion order
981    /// (first NodeIndex added to the symbol_index vector) wins.
982    fn test_resolve_true_ambiguity_first_definition_wins() {
983        // File a.rs defines two overloads of helper at the same line and with the same param count:
984        // - helper_first at line 20 with 0 params (inserted first)
985        // - helper_second at line 20 with 0 params (inserted second)
986        // Call at line 20 with no arg_count matches both equally.
987        // Same-file and line-proximity don't narrow it down.
988        // Arg-count doesn't apply (no match criteria or both match).
989        // First-definition-wins: the first-inserted wins.
990        let e_a = make_output_custom(
991            "src/a.rs",
992            vec![
993                make_function("main", 1, 0),
994                make_function("helper", 20, 0), // inserted first
995                make_function("helper", 20, 0), // inserted second
996            ],
997            vec![make_call("main", "helper", 20, None)],
998        );
999
1000        let g = StructuralGraph::build_from_analysis(&[e_a]);
1001
1002        let calls_edges: Vec<_> = g
1003            .graph
1004            .edge_indices()
1005            .filter(|i| g.graph[*i] == Edge::Calls)
1006            .collect();
1007        assert_eq!(calls_edges.len(), 1);
1008
1009        // Both candidates are identical in all observable ways (line, file, param count)
1010        // so we can't directly distinguish which was picked from the graph alone.
1011        // Just verify that a call edge was created (the resolver didn't fail).
1012        let (_, target) = g.graph.edge_endpoints(calls_edges[0]).unwrap();
1013        match &g.graph[target] {
1014            Node::Symbol { name, .. } => {
1015                assert_eq!(name, "helper", "call should resolve to a helper symbol");
1016            }
1017            _ => panic!("target must be Symbol"),
1018        }
1019    }
1020
1021    #[test]
1022    /// Documents the accepted divergence between `build_from_analysis()` and
1023    /// `from_call_graph()` on ambiguous (same-name, differing-param-count) symbols:
1024    /// `from_call_graph()`'s fast path reuses `CallGraph::callees`, whose `CallEdge` does not
1025    /// carry `arg_count` (see the comment on `from_call_graph`), so it cannot apply the
1026    /// arg-count tie-break stage that `build_from_analysis()`'s `resolve_candidate()` uses.
1027    /// When line-proximity also ties (as in this fixture), the two builders resolve to
1028    /// different candidates: `build_from_analysis` picks the arg-count match, while
1029    /// `from_call_graph` falls back to first-definition-wins.
1030    fn test_from_call_graph_diverges_from_build_from_analysis_on_arg_count_tie() {
1031        // helper_v1 (1 param, line 5) and helper_v2 (2 params, line 15) are equidistant
1032        // (5 lines) from the call at line 10, so line-proximity alone can't disambiguate.
1033        let entry = make_output_custom(
1034            "src/a.rs",
1035            vec![
1036                make_function("main", 1, 0),
1037                make_function("helper", 5, 1),
1038                make_function("helper", 15, 2),
1039            ],
1040            vec![make_call("main", "helper", 10, Some(2))],
1041        );
1042
1043        fn calls_target_line(g: &StructuralGraph) -> usize {
1044            let calls: Vec<_> = g
1045                .graph
1046                .edge_indices()
1047                .filter(|i| g.graph[*i] == Edge::Calls)
1048                .collect();
1049            assert_eq!(calls.len(), 1);
1050            let (_, target) = g.graph.edge_endpoints(calls[0]).unwrap();
1051            match &g.graph[target] {
1052                Node::Symbol { line, .. } => *line,
1053                _ => panic!("target must be Symbol"),
1054            }
1055        }
1056
1057        let full = StructuralGraph::build_from_analysis(std::slice::from_ref(&entry));
1058        assert_eq!(
1059            calls_target_line(&full),
1060            15,
1061            "build_from_analysis should use arg-count to pick the 2-param overload"
1062        );
1063
1064        let call_graph = CallGraph::build_from_results(
1065            vec![(PathBuf::from("src/a.rs"), entry.semantic.clone())],
1066            &[],
1067            false,
1068        )
1069        .expect("call graph build should succeed for this fixture");
1070
1071        let fast = StructuralGraph::from_call_graph(std::slice::from_ref(&entry), &call_graph);
1072        assert_eq!(
1073            calls_target_line(&fast),
1074            5,
1075            "from_call_graph lacks arg_count on CallEdge, so on a line-proximity tie it falls \
1076             back to first-definition-wins instead of matching the call's arg count"
1077        );
1078    }
1079
1080    #[test]
1081    fn test_render_subgraph_text_basic() {
1082        // Arrange
1083        let f1 = make_output(
1084            "src/a.rs",
1085            vec!["caller_fn", "callee_fn"],
1086            vec![],
1087            vec![],
1088            vec![("caller_fn", "callee_fn")],
1089        );
1090        let f2 = make_output(
1091            "src/b.rs",
1092            vec!["other_fn"],
1093            vec![],
1094            vec![],
1095            vec![("other_fn", "caller_fn")],
1096        );
1097        let g = StructuralGraph::build_from_analysis(&[f1, f2]);
1098        let nodes = g.find_symbols(&["caller_fn", "callee_fn", "other_fn"]);
1099
1100        // Act
1101        let rendered = g.render_subgraph_text(&nodes);
1102
1103        // Assert
1104        let expected = "// src/a.rs\nfn callee_fn [callers: caller_fn]\nfn caller_fn [calls: callee_fn] [callers: other_fn]\n\n// src/b.rs\nfn other_fn [calls: caller_fn]\n";
1105        assert_eq!(rendered, expected);
1106    }
1107
1108    #[test]
1109    fn test_render_subgraph_text_empty_and_non_function() {
1110        // Arrange
1111        let f1 = make_output("src/a.rs", vec!["fn_a"], vec!["ClassA"], vec![], vec![]);
1112        let g = StructuralGraph::build_from_analysis(&[f1]);
1113
1114        // Act & Assert: empty nodes returns empty string
1115        assert_eq!(g.render_subgraph_text(&[]), "");
1116
1117        // Act & Assert: Class symbol is skipped, only functions rendered
1118        let class_nodes = g.find_symbols(&["ClassA"]);
1119        assert_eq!(g.render_subgraph_text(&class_nodes), "");
1120    }
1121
1122    #[test]
1123    fn test_blast_radius_bidirectional_includes_callers() {
1124        // Arrange: A calls B calls C
1125        let f = make_output(
1126            "src/lib.rs",
1127            vec!["fn_a", "fn_b", "fn_c"],
1128            vec![],
1129            vec![],
1130            vec![("fn_a", "fn_b"), ("fn_b", "fn_c")],
1131        );
1132        let g = StructuralGraph::build_from_analysis(&[f]);
1133        let b_nodes = g.find_symbols(&["fn_b"]);
1134        assert_eq!(b_nodes.len(), 1);
1135
1136        // Act: BFS from fn_b with depth 1
1137        let (nodes, edges) = g.blast_radius_bidirectional(&b_nodes, 10, 1);
1138
1139        // Assert: should discover fn_b (seed), fn_c (callee / outgoing), and fn_a (caller / incoming)
1140        assert_eq!(nodes.len(), 3);
1141        assert_eq!(nodes[0], b_nodes[0]); // seed first
1142
1143        // Edges should include both (fn_a -> fn_b) and (fn_b -> fn_c)
1144        assert_eq!(edges.len(), 2);
1145    }
1146
1147    #[test]
1148    fn test_blast_radius_bidirectional_max_nodes_cap() {
1149        // Arrange: A calls B calls C calls D
1150        let f = make_output(
1151            "src/lib.rs",
1152            vec!["fn_a", "fn_b", "fn_c", "fn_d"],
1153            vec![],
1154            vec![],
1155            vec![("fn_a", "fn_b"), ("fn_b", "fn_c"), ("fn_c", "fn_d")],
1156        );
1157        let g = StructuralGraph::build_from_analysis(&[f]);
1158        let a_nodes = g.find_symbols(&["fn_a"]);
1159
1160        // Act: cap at 2 nodes
1161        let (nodes, edges) = g.blast_radius_bidirectional(&a_nodes, 2, 5);
1162
1163        // Assert
1164        assert_eq!(nodes.len(), 2);
1165        assert_eq!(edges.len(), 1);
1166    }
1167
1168    #[test]
1169    fn test_blast_radius_bidirectional_multi_seed() {
1170        // Arrange: A calls B, C calls D
1171        let f = make_output(
1172            "src/lib.rs",
1173            vec!["fn_a", "fn_b", "fn_c", "fn_d"],
1174            vec![],
1175            vec![],
1176            vec![("fn_a", "fn_b"), ("fn_c", "fn_d")],
1177        );
1178        let g = StructuralGraph::build_from_analysis(&[f]);
1179        let seeds = g.find_symbols(&["fn_a", "fn_c"]);
1180        assert_eq!(seeds.len(), 2);
1181
1182        // Act
1183        let (nodes, edges) = g.blast_radius_bidirectional(&seeds, 10, 1);
1184
1185        // Assert: should discover all 4 nodes
1186        assert_eq!(nodes.len(), 4);
1187        assert_eq!(edges.len(), 2);
1188    }
1189
1190    #[test]
1191    fn test_blast_radius_bidirectional_deterministic_order() {
1192        // Arrange: fn_root calls three siblings at the same BFS depth
1193        let f = make_output(
1194            "src/lib.rs",
1195            vec!["fn_root", "fn_z", "fn_y", "fn_x"],
1196            vec![],
1197            vec![],
1198            vec![
1199                ("fn_root", "fn_z"),
1200                ("fn_root", "fn_y"),
1201                ("fn_root", "fn_x"),
1202            ],
1203        );
1204        let g = StructuralGraph::build_from_analysis(&[f]);
1205        let seeds = g.find_symbols(&["fn_root"]);
1206
1207        // Act: run twice to confirm the traversal is stable, not just non-empty
1208        let (nodes_a, _) = g.blast_radius_bidirectional(&seeds, 10, 1);
1209        let (nodes_b, _) = g.blast_radius_bidirectional(&seeds, 10, 1);
1210
1211        // Assert: same-depth siblings come back in NodeIndex order, every run
1212        assert_eq!(nodes_a, nodes_b);
1213        assert_eq!(nodes_a[0], seeds[0]);
1214        let siblings = &nodes_a[1..];
1215        let mut sorted_siblings = siblings.to_vec();
1216        sorted_siblings.sort_by_key(|n| n.index());
1217        assert_eq!(siblings, sorted_siblings);
1218    }
1219
1220    #[test]
1221    fn test_blast_radius_bidirectional_empty_seeds_or_zero_limits() {
1222        // Arrange
1223        let f = make_output("src/lib.rs", vec!["fn_a"], vec![], vec![], vec![]);
1224        let g = StructuralGraph::build_from_analysis(&[f]);
1225        let seeds = g.find_symbols(&["fn_a"]);
1226
1227        // Act & Assert: empty seeds
1228        let (nodes, edges) = g.blast_radius_bidirectional(&[], 10, 2);
1229        assert!(nodes.is_empty());
1230        assert!(edges.is_empty());
1231
1232        // Act & Assert: max_nodes = 0
1233        let (nodes, edges) = g.blast_radius_bidirectional(&seeds, 0, 2);
1234        assert!(nodes.is_empty());
1235        assert!(edges.is_empty());
1236
1237        // Act & Assert: max_depth = 0
1238        let (nodes, edges) = g.blast_radius_bidirectional(&seeds, 10, 0);
1239        assert!(nodes.is_empty());
1240        assert!(edges.is_empty());
1241    }
1242}