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    /// Resolves symbol names to every matching `NodeIndex` in the symbol index.
509    pub fn find_symbols_all(&self, names: &[&str]) -> Vec<NodeIndex> {
510        let mut indices = Vec::new();
511        for name in names {
512            if let Some(bucket) = self.symbol_index.get(*name) {
513                indices.extend(bucket.iter().copied());
514            }
515        }
516        indices
517    }
518
519    /// Bidirectional blast-radius traversal discovering both callers and callees.
520    ///
521    /// Walks both `Direction::Incoming` and `Direction::Outgoing` edges filtered to `Edge::Calls`.
522    /// Caps the visited set at `max_nodes` and traversal depth at `max_depth`.
523    /// Returns `(nodes, edges)` for the induced subgraph where all edges between visited nodes
524    /// are included.
525    pub fn blast_radius_bidirectional(
526        &self,
527        seeds: &[NodeIndex],
528        max_nodes: usize,
529        max_depth: usize,
530    ) -> (Vec<NodeIndex>, Vec<(NodeIndex, NodeIndex, Edge)>) {
531        if seeds.is_empty() || max_nodes == 0 || max_depth == 0 {
532            return (vec![], vec![]);
533        }
534
535        let mut visited: HashSet<NodeIndex> = HashSet::new();
536        let mut result: Vec<NodeIndex> = Vec::new();
537        let mut frontier: Vec<NodeIndex> = Vec::new();
538
539        // Initialize with valid seeds up to max_nodes
540        for &seed in seeds {
541            if seed.index() < self.graph.node_count() && visited.insert(seed) {
542                result.push(seed);
543                frontier.push(seed);
544                if result.len() >= max_nodes {
545                    break;
546                }
547            }
548        }
549
550        let mut depth = 0;
551        while depth < max_depth && !frontier.is_empty() && result.len() < max_nodes {
552            depth += 1;
553
554            // Collect this level's candidates first and sort by NodeIndex so
555            // traversal order (and therefore truncation on max_nodes) is
556            // deterministic regardless of the graph's internal edge order.
557            let mut candidates: Vec<NodeIndex> = Vec::new();
558            for &node in &frontier {
559                for edge in self.graph.edges_directed(node, Direction::Outgoing) {
560                    if *edge.weight() == Edge::Calls && !visited.contains(&edge.target()) {
561                        candidates.push(edge.target());
562                    }
563                }
564                for edge in self.graph.edges_directed(node, Direction::Incoming) {
565                    if *edge.weight() == Edge::Calls && !visited.contains(&edge.source()) {
566                        candidates.push(edge.source());
567                    }
568                }
569            }
570            candidates.sort_by_key(|n| n.index());
571            candidates.dedup();
572
573            let mut next = Vec::new();
574            for candidate in candidates {
575                if visited.insert(candidate) {
576                    result.push(candidate);
577                    next.push(candidate);
578                    if result.len() >= max_nodes {
579                        break;
580                    }
581                }
582            }
583            frontier = next;
584        }
585
586        // Collect induced subgraph edges (all edges where both endpoints are in visited)
587        let edges: Vec<(NodeIndex, NodeIndex, Edge)> = self
588            .graph
589            .edge_references()
590            .filter(|e| visited.contains(&e.source()) && visited.contains(&e.target()))
591            .map(|e| (e.source(), e.target(), e.weight().clone()))
592            .collect();
593
594        (result, edges)
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601    use crate::types::{CallInfo, ClassInfo, FunctionInfo, ImportInfo, SemanticAnalysis};
602    use std::path::PathBuf;
603
604    fn make_output(
605        path: &str,
606        funcs: Vec<&str>,
607        classes: Vec<&str>,
608        imports: Vec<&str>,
609        calls: Vec<(&str, &str)>,
610    ) -> FileAnalysisOutput {
611        FileAnalysisOutput::new(
612            path.to_string(),
613            format!("{}:1:1:1", path),
614            SemanticAnalysis {
615                functions: funcs
616                    .into_iter()
617                    .map(|n| FunctionInfo {
618                        name: n.to_string(),
619                        line: 1,
620                        end_line: 6,
621                        parameters: vec![],
622                        return_type: None,
623                    })
624                    .collect(),
625                classes: classes
626                    .into_iter()
627                    .map(|n| ClassInfo {
628                        name: n.to_string(),
629                        line: 1,
630                        end_line: 10,
631                        methods: vec![],
632                        fields: vec![],
633                        inherits: vec![],
634                    })
635                    .collect(),
636                imports: imports
637                    .into_iter()
638                    .map(|m| ImportInfo {
639                        module: m.to_string(),
640                        items: vec![],
641                        line: 1,
642                    })
643                    .collect(),
644                references: vec![],
645                call_frequency: Default::default(),
646                calls: calls
647                    .into_iter()
648                    .map(|(c, e)| CallInfo {
649                        caller: c.to_string(),
650                        callee: e.to_string(),
651                        line: 1,
652                        column: 0,
653                        arg_count: None,
654                    })
655                    .collect(),
656                impl_traits: vec![],
657                def_use_sites: vec![],
658            },
659            10,
660            None,
661        )
662    }
663
664    /// Test helper for creating custom FunctionInfo with explicit line numbers and parameters.
665    fn make_function(name: &str, line: usize, param_count: usize) -> FunctionInfo {
666        FunctionInfo {
667            name: name.to_string(),
668            line,
669            end_line: line + 5,
670            parameters: (0..param_count).map(|i| format!("p{}", i)).collect(),
671            return_type: None,
672        }
673    }
674
675    /// Test helper for creating custom CallInfo with explicit call and definition lines and arg count.
676    fn make_call(
677        caller: &str,
678        callee: &str,
679        call_line: usize,
680        arg_count: Option<usize>,
681    ) -> CallInfo {
682        CallInfo {
683            caller: caller.to_string(),
684            callee: callee.to_string(),
685            line: call_line,
686            column: 0,
687            arg_count,
688        }
689    }
690
691    /// Test helper for building a FileAnalysisOutput with custom FunctionInfo and CallInfo.
692    fn make_output_custom(
693        path: &str,
694        functions: Vec<FunctionInfo>,
695        calls: Vec<CallInfo>,
696    ) -> FileAnalysisOutput {
697        FileAnalysisOutput::new(
698            path.to_string(),
699            format!("{}:1:1:1", path),
700            SemanticAnalysis {
701                functions,
702                classes: vec![],
703                imports: vec![],
704                references: vec![],
705                call_frequency: Default::default(),
706                calls,
707                impl_traits: vec![],
708                def_use_sites: vec![],
709            },
710            10,
711            None,
712        )
713    }
714
715    #[test]
716    fn test_build_happy_path() {
717        let e = make_output(
718            "src/main.rs",
719            vec!["main", "helper"],
720            vec!["Config"],
721            vec!["std::collections"],
722            vec![("main", "helper")],
723        );
724        let g = StructuralGraph::build_from_analysis(&[e]);
725        assert!(g.graph.node_count() >= 4, "nodes={}", g.graph.node_count());
726        assert!(g.graph.edge_count() >= 5, "edges={}", g.graph.edge_count());
727        assert!(g.graph.edge_indices().any(|i| g.graph[i] == Edge::Calls));
728    }
729
730    #[test]
731    fn test_build_empty_input() {
732        let e = make_output("src/e.rs", vec![], vec![], vec![], vec![]);
733        let g = StructuralGraph::build_from_analysis(&[e]);
734        assert_eq!(g.graph.node_count(), 1);
735        assert_eq!(g.graph.edge_count(), 0);
736    }
737
738    #[test]
739    /// Two files with the same call edge now produce 2 Calls edges because
740    /// same-file preference resolves each file's "main -> helper" call within its own file.
741    /// This test verifies that no edge crosses from one file's main to the other file's helper.
742    fn test_build_no_cross_file_collision() {
743        let e1 = make_output(
744            "src/a.rs",
745            vec!["main", "helper"],
746            vec![],
747            vec![],
748            vec![("main", "helper")],
749        );
750        let e2 = make_output(
751            "src/b.rs",
752            vec!["main", "helper"],
753            vec![],
754            vec![],
755            vec![("main", "helper")],
756        );
757        let g = StructuralGraph::build_from_analysis(&[e1, e2]);
758        let calls_edges: Vec<_> = g
759            .graph
760            .edge_indices()
761            .filter(|i| g.graph[*i] == Edge::Calls)
762            .collect();
763        assert_eq!(
764            calls_edges.len(),
765            2,
766            "expected 2 Calls edges (same-file preference), got {}",
767            calls_edges.len()
768        );
769
770        // Verify that each edge's source and target are from the same file
771        for edge_idx in calls_edges {
772            let (source, target) = g.graph.edge_endpoints(edge_idx).unwrap();
773            let source_file = match &g.graph[source] {
774                Node::Symbol { file_path, .. } => file_path,
775                _ => panic!("source must be Symbol"),
776            };
777            let target_file = match &g.graph[target] {
778                Node::Symbol { file_path, .. } => file_path,
779                _ => panic!("target must be Symbol"),
780            };
781            assert_eq!(
782                source_file, target_file,
783                "call edge must not cross files: {} -> {}",
784                source_file, target_file
785            );
786        }
787    }
788
789    #[test]
790    fn test_bfs_diamond() {
791        let mut g = DiGraph::new();
792        let mut sym = |n: &str| {
793            g.add_node(Node::Symbol {
794                name: n.into(),
795                kind: SymbolKind::Function,
796                file_path: "t.rs".into(),
797                line: 1,
798            })
799        };
800        let a = sym("A");
801        let b = sym("B");
802        let c = sym("C");
803        let d = sym("D");
804        g.add_edge(a, b, Edge::Calls);
805        g.add_edge(a, c, Edge::Calls);
806        g.add_edge(b, d, Edge::Calls);
807        g.add_edge(c, d, Edge::Calls);
808        let graph = StructuralGraph::from_graph(g);
809        let r = graph.bfs_blast_radius("A", 2);
810        assert_eq!(r.len(), 3, "expected 3 nodes, got {:?}", r);
811    }
812
813    #[test]
814    fn test_bfs_symbol_not_found() {
815        let graph = StructuralGraph::from_graph(DiGraph::new());
816        assert!(graph.bfs_blast_radius("x", 3).is_empty());
817    }
818
819    #[test]
820    fn test_build_uses_explicit_path_field() {
821        // Regression test: ensure build_from_analysis uses entry.path,
822        // not the first line of formatted text, when they differ.
823        let mut entry = make_output("correct.rs", vec!["foo"], vec![], vec![], vec![]);
824        entry.formatted = "WRONG_PATH\nsome details".to_string();
825
826        let graph = StructuralGraph::build_from_analysis(&[entry]);
827
828        // File node must use correct.rs
829        let file_paths: Vec<&str> = graph
830            .graph
831            .node_weights()
832            .filter_map(|n| match n {
833                Node::File { path } => Some(path.as_str()),
834                _ => None,
835            })
836            .collect();
837        assert_eq!(file_paths, vec!["correct.rs"]);
838
839        // Symbol node must use correct.rs
840        let symbol_file_paths: Vec<&str> = graph
841            .graph
842            .node_weights()
843            .filter_map(|n| match n {
844                Node::Symbol { file_path, .. } => Some(file_path.as_str()),
845                _ => None,
846            })
847            .collect();
848        assert_eq!(symbol_file_paths, vec!["correct.rs"]);
849    }
850
851    #[test]
852    /// A single file with two identical (caller, callee) entries in the calls list
853    /// must still collapse to exactly 1 Calls edge due to the `seen` HashSet.
854    fn test_build_dedup_identical_call_within_one_file() {
855        let e = make_output(
856            "src/a.rs",
857            vec!["main", "helper"],
858            vec![],
859            vec![],
860            vec![("main", "helper"), ("main", "helper")], // duplicate call
861        );
862        let g = StructuralGraph::build_from_analysis(&[e]);
863        let n = g
864            .graph
865            .edge_indices()
866            .filter(|i| g.graph[*i] == Edge::Calls)
867            .count();
868        assert_eq!(
869            n, 1,
870            "expected 1 Calls edge (dedup identical calls), got {}",
871            n
872        );
873    }
874
875    #[test]
876    /// Test same-file preference: when two files each define a same-named callee
877    /// at the same line (so line-proximity doesn't break the tie), the caller's own
878    /// file is preferred. This isolates the same-file-preference stage.
879    fn test_resolve_same_file_preference() {
880        // File a.rs defines helper at line 50
881        // File b.rs defines helper at line 50 (same line distance to call at line 50)
882        // Call in a.rs at line 50 should resolve to a.rs's helper (same file), not b.rs's
883        let e_a = make_output_custom(
884            "src/a.rs",
885            vec![make_function("main", 1, 0), make_function("helper", 50, 0)],
886            vec![make_call("main", "helper", 50, None)],
887        );
888        let e_b = make_output_custom("src/b.rs", vec![make_function("helper", 50, 0)], vec![]);
889
890        let g = StructuralGraph::build_from_analysis(&[e_a, e_b]);
891
892        // Find the Calls edge
893        let calls_edges: Vec<_> = g
894            .graph
895            .edge_indices()
896            .filter(|i| g.graph[*i] == Edge::Calls)
897            .collect();
898        assert_eq!(calls_edges.len(), 1);
899
900        // Extract source and target
901        let (_source, target) = g.graph.edge_endpoints(calls_edges[0]).unwrap();
902        let target_file = match &g.graph[target] {
903            Node::Symbol { file_path, .. } => file_path,
904            _ => panic!("target must be Symbol"),
905        };
906        assert_eq!(
907            target_file, "src/a.rs",
908            "call should resolve to helper in same file"
909        );
910    }
911
912    #[test]
913    /// Test line-proximity fallback: when same-file preference doesn't narrow to one
914    /// candidate, the candidate whose definition line is closest to the call line wins.
915    /// This test puts the call in a third file so same-file preference does not apply.
916    fn test_resolve_line_proximity_fallback() {
917        // File a.rs defines helper at line 45 (5 away from call at line 50)
918        // File b.rs defines helper at line 30 (20 away from call at line 50)
919        // Call in c.rs (neutral file) should prefer a.rs based on line proximity alone
920        let e_a = make_output_custom("src/a.rs", vec![make_function("helper", 45, 0)], vec![]);
921        let e_b = make_output_custom("src/b.rs", vec![make_function("helper", 30, 0)], vec![]);
922        let e_c = make_output_custom(
923            "src/c.rs",
924            vec![make_function("caller", 1, 0)],
925            vec![make_call("caller", "helper", 50, None)],
926        );
927
928        let g = StructuralGraph::build_from_analysis(&[e_a, e_b, e_c]);
929
930        // Find the Calls edge
931        let calls_edges: Vec<_> = g
932            .graph
933            .edge_indices()
934            .filter(|i| g.graph[*i] == Edge::Calls)
935            .collect();
936        assert_eq!(calls_edges.len(), 1);
937
938        let (_, target) = g.graph.edge_endpoints(calls_edges[0]).unwrap();
939        let target_line = match &g.graph[target] {
940            Node::Symbol { line, .. } => *line,
941            _ => panic!("target must be Symbol"),
942        };
943        assert_eq!(
944            target_line, 45,
945            "call should resolve to closest definition line"
946        );
947    }
948
949    #[test]
950    /// Test arg-count fallback: when same-file preference doesn't reduce to one
951    /// candidate and line-proximity produces a tie (equal distances), the candidate
952    /// whose parameter count matches the call's arg_count is preferred.
953    fn test_resolve_arg_count_fallback() {
954        // File a.rs defines two overloads of "helper":
955        // - helper_v1 at line 5 with 1 param (distance 5 from call at line 10)
956        // - helper_v2 at line 15 with 2 params (distance 5 from call at line 10)
957        // Call at line 10 with 2 args should prefer helper_v2 (param count match)
958        // even though both are equidistant via line-proximity
959        let e_a = make_output_custom(
960            "src/a.rs",
961            vec![
962                make_function("main", 1, 0),
963                make_function("helper", 5, 1), // 1-param version at line 5
964                make_function("helper", 15, 2), // 2-param version at line 15
965            ],
966            vec![make_call("main", "helper", 10, Some(2))],
967        );
968
969        let g = StructuralGraph::build_from_analysis(&[e_a]);
970
971        let calls_edges: Vec<_> = g
972            .graph
973            .edge_indices()
974            .filter(|i| g.graph[*i] == Edge::Calls)
975            .collect();
976        assert_eq!(calls_edges.len(), 1);
977
978        let (_, target) = g.graph.edge_endpoints(calls_edges[0]).unwrap();
979        let target_line = match &g.graph[target] {
980            Node::Symbol { line, .. } => *line,
981            _ => panic!("target must be Symbol"),
982        };
983        assert_eq!(
984            target_line, 15,
985            "call should resolve to 2-param version (line 15) via arg-count match"
986        );
987    }
988
989    #[test]
990    /// Test fallback to first-definition-wins: when all disambiguation heuristics
991    /// fail to narrow down to one candidate, the first candidate in insertion order
992    /// (first NodeIndex added to the symbol_index vector) wins.
993    fn test_resolve_true_ambiguity_first_definition_wins() {
994        // File a.rs defines two overloads of helper at the same line and with the same param count:
995        // - helper_first at line 20 with 0 params (inserted first)
996        // - helper_second at line 20 with 0 params (inserted second)
997        // Call at line 20 with no arg_count matches both equally.
998        // Same-file and line-proximity don't narrow it down.
999        // Arg-count doesn't apply (no match criteria or both match).
1000        // First-definition-wins: the first-inserted wins.
1001        let e_a = make_output_custom(
1002            "src/a.rs",
1003            vec![
1004                make_function("main", 1, 0),
1005                make_function("helper", 20, 0), // inserted first
1006                make_function("helper", 20, 0), // inserted second
1007            ],
1008            vec![make_call("main", "helper", 20, None)],
1009        );
1010
1011        let g = StructuralGraph::build_from_analysis(&[e_a]);
1012
1013        let calls_edges: Vec<_> = g
1014            .graph
1015            .edge_indices()
1016            .filter(|i| g.graph[*i] == Edge::Calls)
1017            .collect();
1018        assert_eq!(calls_edges.len(), 1);
1019
1020        // Both candidates are identical in all observable ways (line, file, param count)
1021        // so we can't directly distinguish which was picked from the graph alone.
1022        // Just verify that a call edge was created (the resolver didn't fail).
1023        let (_, target) = g.graph.edge_endpoints(calls_edges[0]).unwrap();
1024        match &g.graph[target] {
1025            Node::Symbol { name, .. } => {
1026                assert_eq!(name, "helper", "call should resolve to a helper symbol");
1027            }
1028            _ => panic!("target must be Symbol"),
1029        }
1030    }
1031
1032    #[test]
1033    /// Documents the accepted divergence between `build_from_analysis()` and
1034    /// `from_call_graph()` on ambiguous (same-name, differing-param-count) symbols:
1035    /// `from_call_graph()`'s fast path reuses `CallGraph::callees`, whose `CallEdge` does not
1036    /// carry `arg_count` (see the comment on `from_call_graph`), so it cannot apply the
1037    /// arg-count tie-break stage that `build_from_analysis()`'s `resolve_candidate()` uses.
1038    /// When line-proximity also ties (as in this fixture), the two builders resolve to
1039    /// different candidates: `build_from_analysis` picks the arg-count match, while
1040    /// `from_call_graph` falls back to first-definition-wins.
1041    fn test_from_call_graph_diverges_from_build_from_analysis_on_arg_count_tie() {
1042        // helper_v1 (1 param, line 5) and helper_v2 (2 params, line 15) are equidistant
1043        // (5 lines) from the call at line 10, so line-proximity alone can't disambiguate.
1044        let entry = make_output_custom(
1045            "src/a.rs",
1046            vec![
1047                make_function("main", 1, 0),
1048                make_function("helper", 5, 1),
1049                make_function("helper", 15, 2),
1050            ],
1051            vec![make_call("main", "helper", 10, Some(2))],
1052        );
1053
1054        fn calls_target_line(g: &StructuralGraph) -> usize {
1055            let calls: Vec<_> = g
1056                .graph
1057                .edge_indices()
1058                .filter(|i| g.graph[*i] == Edge::Calls)
1059                .collect();
1060            assert_eq!(calls.len(), 1);
1061            let (_, target) = g.graph.edge_endpoints(calls[0]).unwrap();
1062            match &g.graph[target] {
1063                Node::Symbol { line, .. } => *line,
1064                _ => panic!("target must be Symbol"),
1065            }
1066        }
1067
1068        let full = StructuralGraph::build_from_analysis(std::slice::from_ref(&entry));
1069        assert_eq!(
1070            calls_target_line(&full),
1071            15,
1072            "build_from_analysis should use arg-count to pick the 2-param overload"
1073        );
1074
1075        let call_graph = CallGraph::build_from_results(
1076            vec![(PathBuf::from("src/a.rs"), entry.semantic.clone())],
1077            &[],
1078            false,
1079        )
1080        .expect("call graph build should succeed for this fixture");
1081
1082        let fast = StructuralGraph::from_call_graph(std::slice::from_ref(&entry), &call_graph);
1083        assert_eq!(
1084            calls_target_line(&fast),
1085            5,
1086            "from_call_graph lacks arg_count on CallEdge, so on a line-proximity tie it falls \
1087             back to first-definition-wins instead of matching the call's arg count"
1088        );
1089    }
1090
1091    #[test]
1092    fn test_render_subgraph_text_basic() {
1093        // Arrange
1094        let f1 = make_output(
1095            "src/a.rs",
1096            vec!["caller_fn", "callee_fn"],
1097            vec![],
1098            vec![],
1099            vec![("caller_fn", "callee_fn")],
1100        );
1101        let f2 = make_output(
1102            "src/b.rs",
1103            vec!["other_fn"],
1104            vec![],
1105            vec![],
1106            vec![("other_fn", "caller_fn")],
1107        );
1108        let g = StructuralGraph::build_from_analysis(&[f1, f2]);
1109        let nodes = g.find_symbols(&["caller_fn", "callee_fn", "other_fn"]);
1110
1111        // Act
1112        let rendered = g.render_subgraph_text(&nodes);
1113
1114        // Assert
1115        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";
1116        assert_eq!(rendered, expected);
1117    }
1118
1119    #[test]
1120    fn test_render_subgraph_text_empty_and_non_function() {
1121        // Arrange
1122        let f1 = make_output("src/a.rs", vec!["fn_a"], vec!["ClassA"], vec![], vec![]);
1123        let g = StructuralGraph::build_from_analysis(&[f1]);
1124
1125        // Act & Assert: empty nodes returns empty string
1126        assert_eq!(g.render_subgraph_text(&[]), "");
1127
1128        // Act & Assert: Class symbol is skipped, only functions rendered
1129        let class_nodes = g.find_symbols(&["ClassA"]);
1130        assert_eq!(g.render_subgraph_text(&class_nodes), "");
1131    }
1132
1133    #[test]
1134    fn test_blast_radius_bidirectional_includes_callers() {
1135        // Arrange: A calls B calls C
1136        let f = make_output(
1137            "src/lib.rs",
1138            vec!["fn_a", "fn_b", "fn_c"],
1139            vec![],
1140            vec![],
1141            vec![("fn_a", "fn_b"), ("fn_b", "fn_c")],
1142        );
1143        let g = StructuralGraph::build_from_analysis(&[f]);
1144        let b_nodes = g.find_symbols(&["fn_b"]);
1145        assert_eq!(b_nodes.len(), 1);
1146
1147        // Act: BFS from fn_b with depth 1
1148        let (nodes, edges) = g.blast_radius_bidirectional(&b_nodes, 10, 1);
1149
1150        // Assert: should discover fn_b (seed), fn_c (callee / outgoing), and fn_a (caller / incoming)
1151        assert_eq!(nodes.len(), 3);
1152        assert_eq!(nodes[0], b_nodes[0]); // seed first
1153
1154        // Edges should include both (fn_a -> fn_b) and (fn_b -> fn_c)
1155        assert_eq!(edges.len(), 2);
1156    }
1157
1158    #[test]
1159    fn test_blast_radius_bidirectional_max_nodes_cap() {
1160        // Arrange: A calls B calls C calls D
1161        let f = make_output(
1162            "src/lib.rs",
1163            vec!["fn_a", "fn_b", "fn_c", "fn_d"],
1164            vec![],
1165            vec![],
1166            vec![("fn_a", "fn_b"), ("fn_b", "fn_c"), ("fn_c", "fn_d")],
1167        );
1168        let g = StructuralGraph::build_from_analysis(&[f]);
1169        let a_nodes = g.find_symbols(&["fn_a"]);
1170
1171        // Act: cap at 2 nodes
1172        let (nodes, edges) = g.blast_radius_bidirectional(&a_nodes, 2, 5);
1173
1174        // Assert
1175        assert_eq!(nodes.len(), 2);
1176        assert_eq!(edges.len(), 1);
1177    }
1178
1179    #[test]
1180    fn test_blast_radius_bidirectional_multi_seed() {
1181        // Arrange: A calls B, C calls D
1182        let f = make_output(
1183            "src/lib.rs",
1184            vec!["fn_a", "fn_b", "fn_c", "fn_d"],
1185            vec![],
1186            vec![],
1187            vec![("fn_a", "fn_b"), ("fn_c", "fn_d")],
1188        );
1189        let g = StructuralGraph::build_from_analysis(&[f]);
1190        let seeds = g.find_symbols(&["fn_a", "fn_c"]);
1191        assert_eq!(seeds.len(), 2);
1192
1193        // Act
1194        let (nodes, edges) = g.blast_radius_bidirectional(&seeds, 10, 1);
1195
1196        // Assert: should discover all 4 nodes
1197        assert_eq!(nodes.len(), 4);
1198        assert_eq!(edges.len(), 2);
1199    }
1200
1201    #[test]
1202    fn test_blast_radius_bidirectional_deterministic_order() {
1203        // Arrange: fn_root calls three siblings at the same BFS depth
1204        let f = make_output(
1205            "src/lib.rs",
1206            vec!["fn_root", "fn_z", "fn_y", "fn_x"],
1207            vec![],
1208            vec![],
1209            vec![
1210                ("fn_root", "fn_z"),
1211                ("fn_root", "fn_y"),
1212                ("fn_root", "fn_x"),
1213            ],
1214        );
1215        let g = StructuralGraph::build_from_analysis(&[f]);
1216        let seeds = g.find_symbols(&["fn_root"]);
1217
1218        // Act: run twice to confirm the traversal is stable, not just non-empty
1219        let (nodes_a, _) = g.blast_radius_bidirectional(&seeds, 10, 1);
1220        let (nodes_b, _) = g.blast_radius_bidirectional(&seeds, 10, 1);
1221
1222        // Assert: same-depth siblings come back in NodeIndex order, every run
1223        assert_eq!(nodes_a, nodes_b);
1224        assert_eq!(nodes_a[0], seeds[0]);
1225        let siblings = &nodes_a[1..];
1226        let mut sorted_siblings = siblings.to_vec();
1227        sorted_siblings.sort_by_key(|n| n.index());
1228        assert_eq!(siblings, sorted_siblings);
1229    }
1230
1231    #[test]
1232    fn test_blast_radius_bidirectional_empty_seeds_or_zero_limits() {
1233        // Arrange
1234        let f = make_output("src/lib.rs", vec!["fn_a"], vec![], vec![], vec![]);
1235        let g = StructuralGraph::build_from_analysis(&[f]);
1236        let seeds = g.find_symbols(&["fn_a"]);
1237
1238        // Act & Assert: empty seeds
1239        let (nodes, edges) = g.blast_radius_bidirectional(&[], 10, 2);
1240        assert!(nodes.is_empty());
1241        assert!(edges.is_empty());
1242
1243        // Act & Assert: max_nodes = 0
1244        let (nodes, edges) = g.blast_radius_bidirectional(&seeds, 0, 2);
1245        assert!(nodes.is_empty());
1246        assert!(edges.is_empty());
1247
1248        // Act & Assert: max_depth = 0
1249        let (nodes, edges) = g.blast_radius_bidirectional(&seeds, 10, 0);
1250        assert!(nodes.is_empty());
1251        assert!(edges.is_empty());
1252    }
1253
1254    #[test]
1255    fn test_find_symbols_all_multiple_matches() {
1256        // Arrange: two files, each with a function named "shared" plus another function
1257        let f1 = make_output(
1258            "src/a.rs",
1259            vec!["shared", "helper_a"],
1260            vec![],
1261            vec![],
1262            vec![],
1263        );
1264        let f2 = make_output(
1265            "src/b.rs",
1266            vec!["shared", "helper_b"],
1267            vec![],
1268            vec![],
1269            vec![],
1270        );
1271        let g = StructuralGraph::build_from_analysis(&[f1, f2]);
1272
1273        // Act: find_symbols_all should return all "shared" symbols
1274        let all_shared = g.find_symbols_all(&["shared"]);
1275        // Act: find_symbols should return only the first "shared" symbol
1276        let first_shared = g.find_symbols(&["shared"]);
1277
1278        // Assert: find_symbols_all returns both matches
1279        assert_eq!(
1280            all_shared.len(),
1281            2,
1282            "find_symbols_all should return 2 'shared' symbols from 2 files"
1283        );
1284
1285        // Assert: find_symbols returns only 1 match (the first)
1286        assert_eq!(
1287            first_shared.len(),
1288            1,
1289            "find_symbols should return 1 'shared' symbol (first only)"
1290        );
1291
1292        // Assert: the single match from find_symbols is included in find_symbols_all
1293        assert!(
1294            all_shared.contains(&first_shared[0]),
1295            "find_symbols result should be a subset of find_symbols_all"
1296        );
1297    }
1298}