Skip to main content

sqry_core/query/executor/
graph_cycles.rs

1//! Cycle detection for unified graph.
2//!
3//! This module provides cycle detection using the unified graph API,
4//! replacing the legacy index-based cycle detection.
5
6use crate::graph::unified::concurrent::CodeGraph;
7use crate::graph::unified::edge::EdgeKind;
8use crate::graph::unified::node::NodeId;
9use std::collections::{HashMap, HashSet};
10
11/// Type of circular dependency to detect
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum CircularType {
14    /// Function/method call cycles (A calls B calls C calls A)
15    Calls,
16    /// File import cycles (a.rs imports b.rs imports a.rs)
17    Imports,
18    /// Module-level cycles (aggregated from import graph)
19    Modules,
20}
21
22impl CircularType {
23    /// Parse circular type from query value string
24    #[must_use]
25    pub fn try_parse(s: &str) -> Option<Self> {
26        match s.to_lowercase().as_str() {
27            "calls" | "call" => Some(Self::Calls),
28            "imports" | "import" => Some(Self::Imports),
29            "modules" | "module" => Some(Self::Modules),
30            _ => None,
31        }
32    }
33}
34
35/// Configuration for circular dependency detection
36#[derive(Debug, Clone)]
37pub struct CircularConfig {
38    /// Minimum cycle depth to report (default: 2)
39    pub min_depth: usize,
40    /// Maximum cycle depth to report (default: unbounded)
41    pub max_depth: Option<usize>,
42    /// Maximum results to return (default: 100)
43    pub max_results: usize,
44    /// Include self-loops (A -> A) in results (default: false)
45    pub should_include_self_loops: bool,
46}
47
48impl Default for CircularConfig {
49    fn default() -> Self {
50        Self {
51            min_depth: 2,
52            max_depth: None,
53            max_results: 100,
54            should_include_self_loops: false,
55        }
56    }
57}
58
59/// Frame for iterative Tarjan's algorithm (simulates recursion stack)
60struct TarjanFrame {
61    /// Current node being processed
62    node: NodeId,
63    /// Index into successor list (tracks which successors have been visited)
64    successor_idx: usize,
65    /// Phase: false = pre-visit, true = post-visit
66    is_post_visit: bool,
67}
68
69/// Tarjan's SCC algorithm state for graph nodes
70struct GraphTarjanState {
71    /// Current index counter
72    index: usize,
73    /// Stack of nodes in current SCC discovery
74    stack: Vec<NodeId>,
75    /// Set of nodes on stack (for O(1) lookup)
76    on_stack: HashSet<NodeId>,
77    /// Index assigned to each node
78    indices: HashMap<NodeId, usize>,
79    /// Lowlink value for each node
80    lowlinks: HashMap<NodeId, usize>,
81    /// Discovered SCCs
82    sccs: Vec<Vec<NodeId>>,
83}
84
85impl GraphTarjanState {
86    fn new() -> Self {
87        Self {
88            index: 0,
89            stack: Vec::new(),
90            on_stack: HashSet::new(),
91            indices: HashMap::new(),
92            lowlinks: HashMap::new(),
93            sccs: Vec::new(),
94        }
95    }
96
97    fn init_node(&mut self, v: NodeId) {
98        self.indices.insert(v, self.index);
99        self.lowlinks.insert(v, self.index);
100        self.index += 1;
101        self.stack.push(v);
102        self.on_stack.insert(v);
103    }
104
105    fn handle_post_visit(&mut self, v: NodeId, work_stack: &[TarjanFrame]) {
106        if let Some(parent_frame) = work_stack.last() {
107            let v_lowlink = *self.lowlinks.get(&v).unwrap();
108            let parent_lowlink = self.lowlinks.get_mut(&parent_frame.node).unwrap();
109            *parent_lowlink = (*parent_lowlink).min(v_lowlink);
110        }
111
112        let v_index = *self.indices.get(&v).unwrap();
113        let v_lowlink = *self.lowlinks.get(&v).unwrap();
114        if v_lowlink == v_index {
115            self.extract_scc(v);
116        }
117    }
118
119    fn extract_scc(&mut self, root: NodeId) {
120        let mut scc = Vec::new();
121        loop {
122            let w = self.stack.pop().unwrap();
123            self.on_stack.remove(&w);
124            scc.push(w);
125            if w == root {
126                break;
127            }
128        }
129        self.sccs.push(scc);
130    }
131}
132
133/// Collect call graph adjacency from unified graph edges
134fn collect_call_adjacency(
135    graph: &CodeGraph,
136) -> (
137    HashSet<NodeId>,
138    HashMap<NodeId, Vec<NodeId>>,
139    HashSet<NodeId>,
140) {
141    let mut all_nodes: HashSet<NodeId> = HashSet::new();
142    let mut adjacency: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
143    let mut self_loops: HashSet<NodeId> = HashSet::new();
144
145    // Iterate over all nodes
146    for (node_id, _entry) in graph.nodes().iter() {
147        all_nodes.insert(node_id);
148
149        // Get outgoing call edges
150        for edge in graph.edges().edges_from(node_id) {
151            if matches!(edge.kind, EdgeKind::Calls { .. }) {
152                adjacency.entry(node_id).or_default().push(edge.target);
153
154                if node_id == edge.target {
155                    self_loops.insert(node_id);
156                }
157            }
158        }
159    }
160
161    (all_nodes, adjacency, self_loops)
162}
163
164/// Collect import graph adjacency from unified graph edges
165fn collect_import_adjacency(
166    graph: &CodeGraph,
167) -> (
168    HashSet<NodeId>,
169    HashMap<NodeId, Vec<NodeId>>,
170    HashSet<NodeId>,
171) {
172    let mut all_nodes: HashSet<NodeId> = HashSet::new();
173    let mut adjacency: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
174    let mut self_loops: HashSet<NodeId> = HashSet::new();
175
176    // Iterate over all nodes
177    for (node_id, _entry) in graph.nodes().iter() {
178        all_nodes.insert(node_id);
179
180        // Get outgoing import edges
181        for edge in graph.edges().edges_from(node_id) {
182            if matches!(edge.kind, EdgeKind::Imports { .. }) {
183                adjacency.entry(node_id).or_default().push(edge.target);
184
185                if node_id == edge.target {
186                    self_loops.insert(node_id);
187                }
188            }
189        }
190    }
191
192    (all_nodes, adjacency, self_loops)
193}
194
195/// Iterative Tarjan's strongconnect for graph nodes
196fn tarjan_strongconnect_iterative(
197    start: NodeId,
198    adjacency: &HashMap<NodeId, Vec<NodeId>>,
199    state: &mut GraphTarjanState,
200) {
201    let mut work_stack: Vec<TarjanFrame> = vec![TarjanFrame {
202        node: start,
203        successor_idx: 0,
204        is_post_visit: false,
205    }];
206
207    while let Some(frame) = work_stack.last_mut() {
208        let v = frame.node;
209
210        if frame.is_post_visit {
211            work_stack.pop();
212            state.handle_post_visit(v, &work_stack);
213            continue;
214        }
215
216        if !state.indices.contains_key(&v) {
217            state.init_node(v);
218        }
219
220        let mut successor_idx = frame.successor_idx;
221        let has_pushed_child =
222            process_successors(v, adjacency, &mut successor_idx, &mut work_stack, state);
223
224        if let Some(current_frame) = work_stack.iter_mut().rev().find(|f| f.node == v) {
225            current_frame.successor_idx = successor_idx;
226            if !has_pushed_child {
227                current_frame.is_post_visit = true;
228            }
229        }
230    }
231}
232
233fn process_successors(
234    v: NodeId,
235    adjacency: &HashMap<NodeId, Vec<NodeId>>,
236    successor_idx: &mut usize,
237    work_stack: &mut Vec<TarjanFrame>,
238    state: &mut GraphTarjanState,
239) -> bool {
240    let Some(successors) = adjacency.get(&v) else {
241        return false;
242    };
243
244    while *successor_idx < successors.len() {
245        let w = successors[*successor_idx];
246        *successor_idx += 1;
247
248        if !state.indices.contains_key(&w) {
249            work_stack.push(TarjanFrame {
250                node: w,
251                successor_idx: 0,
252                is_post_visit: false,
253            });
254            return true;
255        }
256
257        if state.on_stack.contains(&w) {
258            let w_index = *state.indices.get(&w).unwrap();
259            let v_lowlink = state.lowlinks.get_mut(&v).unwrap();
260            *v_lowlink = (*v_lowlink).min(w_index);
261        }
262    }
263
264    false
265}
266
267fn should_include_scc(size: usize, is_self_loop: bool, config: &CircularConfig) -> bool {
268    // Self-loops (size 1) need explicit inclusion via config
269    if is_self_loop {
270        return config.should_include_self_loops;
271    }
272
273    // Single-node non-self-loop SCCs aren't cycles
274    if size == 1 {
275        return false;
276    }
277
278    // Check min_depth bound
279    if size < config.min_depth {
280        return false;
281    }
282
283    // Check max_depth bound
284    if config.max_depth.is_some_and(|max| size > max) {
285        return false;
286    }
287
288    true
289}
290
291/// Find all cycles in the graph using call or import edges.
292///
293/// Uses Tarjan's algorithm to find strongly connected components (SCCs),
294/// then filters to those with more than one node or self-loops.
295///
296/// # Arguments
297///
298/// * `circular_type` - The type of edges to follow (Calls, Imports, or Modules)
299/// * `graph` - The code graph to analyze
300/// * `config` - Configuration for min/max depth, self-loop inclusion, and result limits
301///
302/// # Returns
303///
304/// A vector of cycles, where each cycle is a vector of qualified symbol names.
305/// Cycles are sorted by size (largest first) and limited by `config.max_results`.
306#[must_use]
307pub fn find_all_cycles_graph(
308    circular_type: CircularType,
309    graph: &CodeGraph,
310    config: &CircularConfig,
311) -> Vec<Vec<String>> {
312    let (all_nodes, adjacency, self_loops) = match circular_type {
313        CircularType::Calls => collect_call_adjacency(graph),
314        CircularType::Imports | CircularType::Modules => collect_import_adjacency(graph),
315    };
316
317    let mut state = GraphTarjanState::new();
318
319    // Run Tarjan's algorithm from each unvisited node
320    for node in &all_nodes {
321        if !state.indices.contains_key(node) {
322            tarjan_strongconnect_iterative(*node, &adjacency, &mut state);
323        }
324    }
325
326    // Filter and convert SCCs to qualified names
327    let strings = graph.strings();
328    let mut result = Vec::new();
329
330    for scc in state.sccs {
331        let size = scc.len();
332        let is_self_loop = size == 1 && self_loops.contains(&scc[0]);
333
334        if !should_include_scc(size, is_self_loop, config) {
335            continue;
336        }
337
338        // Convert NodeIds to qualified names
339        let names: Vec<String> = scc
340            .iter()
341            .filter_map(|&node_id| {
342                let entry = graph.nodes().get(node_id)?;
343                let name = entry
344                    .qualified_name
345                    .and_then(|id| strings.resolve(id))
346                    .or_else(|| strings.resolve(entry.name))
347                    .map(|s| s.to_string())?;
348                Some(name)
349            })
350            .collect();
351
352        if !names.is_empty() {
353            result.push(names);
354        }
355
356        if result.len() >= config.max_results {
357            break;
358        }
359    }
360
361    result
362}
363
364/// Check if a specific node is part of a cycle.
365///
366/// Uses SCC-based semantics consistent with `find_all_cycles_graph`. The node
367/// is considered in a cycle if it belongs to a strongly connected component (SCC)
368/// whose size meets the `min_depth`/`max_depth` criteria, or if it has a self-loop
369/// and `include_self_loops` is true.
370///
371/// # Arguments
372///
373/// * `node_id` - The node to check for cycle membership
374/// * `circular_type` - The type of edges to follow (Calls, Imports, or Modules)
375/// * `graph` - The code graph to analyze
376/// * `config` - Configuration for min/max depth and self-loop inclusion
377///
378/// # Returns
379///
380/// `true` if the node is part of a cycle that meets the config criteria, `false` otherwise.
381/// Returns `false` for invalid or non-existent nodes.
382#[must_use]
383pub fn is_node_in_cycle(
384    node_id: NodeId,
385    circular_type: CircularType,
386    graph: &CodeGraph,
387    config: &CircularConfig,
388) -> bool {
389    // Early return if the node doesn't exist
390    if graph.nodes().get(node_id).is_none() {
391        return false;
392    }
393
394    // Build adjacency list and collect self-loops based on cycle type
395    let (adjacency, self_loops) = match circular_type {
396        CircularType::Calls => collect_call_adjacency_with_self_loops(graph),
397        CircularType::Imports | CircularType::Modules => {
398            collect_import_adjacency_with_self_loops(graph)
399        }
400    };
401
402    // Check for self-loop using same semantics as find_all_cycles_graph:
403    // Self-loops are included when `should_include_self_loops` is true,
404    // regardless of min_depth (they're treated as a special case)
405    if self_loops.contains(&node_id) && config.should_include_self_loops {
406        return true;
407    }
408
409    // Find the SCC containing this node using targeted Tarjan's algorithm
410    find_scc_containing_node(node_id, &adjacency, config)
411}
412
413/// Collect call graph adjacency with self-loop tracking.
414fn collect_call_adjacency_with_self_loops(
415    graph: &CodeGraph,
416) -> (HashMap<NodeId, Vec<NodeId>>, HashSet<NodeId>) {
417    let mut adjacency: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
418    let mut self_loops: HashSet<NodeId> = HashSet::new();
419
420    for (node_id, _entry) in graph.nodes().iter() {
421        for edge in graph.edges().edges_from(node_id) {
422            if matches!(edge.kind, EdgeKind::Calls { .. }) {
423                adjacency.entry(node_id).or_default().push(edge.target);
424                if node_id == edge.target {
425                    self_loops.insert(node_id);
426                }
427            }
428        }
429    }
430
431    (adjacency, self_loops)
432}
433
434/// Collect import graph adjacency with self-loop tracking.
435fn collect_import_adjacency_with_self_loops(
436    graph: &CodeGraph,
437) -> (HashMap<NodeId, Vec<NodeId>>, HashSet<NodeId>) {
438    let mut adjacency: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
439    let mut self_loops: HashSet<NodeId> = HashSet::new();
440
441    for (node_id, _entry) in graph.nodes().iter() {
442        for edge in graph.edges().edges_from(node_id) {
443            if matches!(edge.kind, EdgeKind::Imports { .. }) {
444                adjacency.entry(node_id).or_default().push(edge.target);
445                if node_id == edge.target {
446                    self_loops.insert(node_id);
447                }
448            }
449        }
450    }
451
452    (adjacency, self_loops)
453}
454
455/// State for the targeted Tarjan's algorithm used by `find_scc_containing_node`.
456struct TargetedTarjanState {
457    index_counter: usize,
458    indices: HashMap<NodeId, usize>,
459    lowlinks: HashMap<NodeId, usize>,
460    on_stack: HashSet<NodeId>,
461    stack: Vec<NodeId>,
462}
463
464impl TargetedTarjanState {
465    fn new() -> Self {
466        Self {
467            index_counter: 0,
468            indices: HashMap::new(),
469            lowlinks: HashMap::new(),
470            on_stack: HashSet::new(),
471            stack: Vec::new(),
472        }
473    }
474
475    fn init_node(&mut self, node: NodeId) {
476        if let std::collections::hash_map::Entry::Vacant(e) = self.indices.entry(node) {
477            e.insert(self.index_counter);
478            self.lowlinks.insert(node, self.index_counter);
479            self.index_counter += 1;
480            self.stack.push(node);
481            self.on_stack.insert(node);
482        }
483    }
484
485    /// Handle post-visit: update parent lowlink and check for SCC root.
486    ///
487    /// Returns `true` if the target node was found in an SCC that meets the config criteria.
488    fn handle_post_visit(
489        &mut self,
490        node: NodeId,
491        target: NodeId,
492        work_stack: &[(NodeId, usize, bool)],
493        config: &CircularConfig,
494    ) -> bool {
495        // Update parent's lowlink
496        if let Some(&(parent_node, _, _)) = work_stack.last() {
497            let node_lowlink = *self.lowlinks.get(&node).unwrap_or(&usize::MAX);
498            if let Some(parent_lowlink) = self.lowlinks.get_mut(&parent_node) {
499                *parent_lowlink = (*parent_lowlink).min(node_lowlink);
500            }
501        }
502
503        // Check if this node is the root of an SCC
504        let node_index = *self.indices.get(&node).unwrap_or(&usize::MAX);
505        let node_lowlink = *self.lowlinks.get(&node).unwrap_or(&usize::MAX);
506
507        if node_lowlink == node_index {
508            return self.extract_and_check_scc(node, target, config);
509        }
510
511        false
512    }
513
514    /// Extract the SCC rooted at `root` and check if it contains `target` and meets size criteria.
515    fn extract_and_check_scc(
516        &mut self,
517        root: NodeId,
518        target: NodeId,
519        config: &CircularConfig,
520    ) -> bool {
521        let mut scc_size = 0;
522        let mut contains_target = false;
523        loop {
524            let w = self.stack.pop().unwrap();
525            self.on_stack.remove(&w);
526            scc_size += 1;
527            if w == target {
528                contains_target = true;
529            }
530            if w == root {
531                break;
532            }
533        }
534
535        // Single-node SCCs (self-loops) are handled separately in is_node_in_cycle,
536        // so we only consider multi-node cycles here (size >= 2)
537        contains_target
538            && scc_size >= 2
539            && scc_size >= config.min_depth
540            && config.max_depth.is_none_or(|max| scc_size <= max)
541    }
542
543    /// Process successors for a node during iterative Tarjan traversal.
544    ///
545    /// Returns `true` if a child was pushed onto the work stack (need to recurse).
546    fn process_node_successors(
547        &mut self,
548        node: NodeId,
549        succ_idx: &mut usize,
550        adjacency: &HashMap<NodeId, Vec<NodeId>>,
551        work_stack: &mut Vec<(NodeId, usize, bool)>,
552    ) -> bool {
553        let Some(succs) = adjacency.get(&node) else {
554            return false;
555        };
556
557        while *succ_idx < succs.len() {
558            let w = succs[*succ_idx];
559            *succ_idx += 1;
560
561            if !self.indices.contains_key(&w) {
562                work_stack.push((node, *succ_idx, false));
563                work_stack.push((w, 0, false));
564                return true;
565            } else if self.on_stack.contains(&w) {
566                let w_index = *self.indices.get(&w).unwrap();
567                let node_lowlink = self.lowlinks.get_mut(&node).unwrap();
568                *node_lowlink = (*node_lowlink).min(w_index);
569            }
570        }
571
572        false
573    }
574}
575
576/// Find if a node is in an SCC that meets the size criteria.
577///
578/// Uses a targeted version of Tarjan's algorithm that starts from the target node
579/// and only explores nodes reachable from it. This is more efficient than running
580/// full SCC detection when checking a single node.
581fn find_scc_containing_node(
582    target: NodeId,
583    adjacency: &HashMap<NodeId, Vec<NodeId>>,
584    config: &CircularConfig,
585) -> bool {
586    let mut state = TargetedTarjanState::new();
587    let mut work_stack: Vec<(NodeId, usize, bool)> = vec![(target, 0, false)];
588
589    while let Some((node, mut succ_idx, is_post)) = work_stack.pop() {
590        if is_post {
591            if state.handle_post_visit(node, target, &work_stack, config) {
592                return true;
593            }
594            continue;
595        }
596
597        state.init_node(node);
598
599        let pushed_child =
600            state.process_node_successors(node, &mut succ_idx, adjacency, &mut work_stack);
601
602        if !pushed_child {
603            work_stack.push((node, succ_idx, true));
604        }
605    }
606
607    false
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use crate::graph::unified::node::NodeKind;
614    use crate::graph::unified::storage::arena::NodeEntry;
615    use std::path::Path;
616
617    /// Helper to create a test graph with nodes and call edges.
618    fn create_test_graph(
619        nodes: &[(&str, NodeKind)],
620        edges: &[(usize, usize)],
621    ) -> (CodeGraph, Vec<NodeId>) {
622        let mut graph = CodeGraph::new();
623        let file_id = graph.files_mut().register(Path::new("test.rs")).unwrap();
624        let mut node_ids = Vec::new();
625
626        // Create nodes
627        for (name, kind) in nodes {
628            let name_id = graph.strings_mut().intern(name).unwrap();
629            let entry = NodeEntry::new(*kind, name_id, file_id).with_qualified_name(name_id);
630            let node_id = graph.nodes_mut().alloc(entry).unwrap();
631            node_ids.push(node_id);
632        }
633
634        // Create call edges
635        for (source_idx, target_idx) in edges {
636            let source = node_ids[*source_idx];
637            let target = node_ids[*target_idx];
638            graph.edges_mut().add_edge(
639                source,
640                target,
641                EdgeKind::Calls {
642                    argument_count: 0,
643                    is_async: false,
644                },
645                file_id,
646            );
647        }
648
649        (graph, node_ids)
650    }
651
652    /// Helper to create a graph with import edges.
653    fn create_import_graph(
654        nodes: &[(&str, NodeKind)],
655        edges: &[(usize, usize)],
656    ) -> (CodeGraph, Vec<NodeId>) {
657        let mut graph = CodeGraph::new();
658        let file_id = graph.files_mut().register(Path::new("test.rs")).unwrap();
659        let mut node_ids = Vec::new();
660
661        for (name, kind) in nodes {
662            let name_id = graph.strings_mut().intern(name).unwrap();
663            let entry = NodeEntry::new(*kind, name_id, file_id).with_qualified_name(name_id);
664            let node_id = graph.nodes_mut().alloc(entry).unwrap();
665            node_ids.push(node_id);
666        }
667
668        for (source_idx, target_idx) in edges {
669            let source = node_ids[*source_idx];
670            let target = node_ids[*target_idx];
671            graph.edges_mut().add_edge(
672                source,
673                target,
674                EdgeKind::Imports {
675                    alias: None,
676                    is_wildcard: false,
677                },
678                file_id,
679            );
680        }
681
682        (graph, node_ids)
683    }
684
685    #[test]
686    fn test_empty_graph() {
687        let graph = CodeGraph::new();
688        let config = CircularConfig::default();
689
690        let cycles = find_all_cycles_graph(CircularType::Calls, &graph, &config);
691        assert!(cycles.is_empty());
692    }
693
694    #[test]
695    fn test_no_cycles_linear_chain() {
696        // A -> B -> C (no cycles)
697        let nodes = [
698            ("func_a", NodeKind::Function),
699            ("func_b", NodeKind::Function),
700            ("func_c", NodeKind::Function),
701        ];
702        let edges = [(0, 1), (1, 2)];
703        let (graph, _) = create_test_graph(&nodes, &edges);
704        let config = CircularConfig::default();
705
706        let cycles = find_all_cycles_graph(CircularType::Calls, &graph, &config);
707        assert!(cycles.is_empty(), "Linear chain should have no cycles");
708    }
709
710    #[test]
711    fn test_simple_two_node_cycle() {
712        // A -> B -> A (cycle)
713        let nodes = [
714            ("func_a", NodeKind::Function),
715            ("func_b", NodeKind::Function),
716        ];
717        let edges = [(0, 1), (1, 0)];
718        let (graph, _) = create_test_graph(&nodes, &edges);
719        let config = CircularConfig::default();
720
721        let cycles = find_all_cycles_graph(CircularType::Calls, &graph, &config);
722        assert_eq!(cycles.len(), 1, "Should find exactly one cycle");
723        assert_eq!(cycles[0].len(), 2, "Cycle should have 2 nodes");
724        assert!(
725            cycles[0].contains(&"func_a".to_string()) && cycles[0].contains(&"func_b".to_string()),
726            "Cycle should contain func_a and func_b"
727        );
728    }
729
730    #[test]
731    fn test_three_node_cycle() {
732        // A -> B -> C -> A (cycle)
733        let nodes = [
734            ("func_a", NodeKind::Function),
735            ("func_b", NodeKind::Function),
736            ("func_c", NodeKind::Function),
737        ];
738        let edges = [(0, 1), (1, 2), (2, 0)];
739        let (graph, _) = create_test_graph(&nodes, &edges);
740        let config = CircularConfig::default();
741
742        let cycles = find_all_cycles_graph(CircularType::Calls, &graph, &config);
743        assert_eq!(cycles.len(), 1, "Should find exactly one cycle");
744        assert_eq!(cycles[0].len(), 3, "Cycle should have 3 nodes");
745    }
746
747    #[test]
748    fn test_self_loop_excluded_by_default() {
749        // A -> A (self-loop)
750        let nodes = [("func_a", NodeKind::Function)];
751        let edges = [(0, 0)];
752        let (graph, _) = create_test_graph(&nodes, &edges);
753        let config = CircularConfig::default();
754
755        let cycles = find_all_cycles_graph(CircularType::Calls, &graph, &config);
756        assert!(
757            cycles.is_empty(),
758            "Self-loops should be excluded by default"
759        );
760    }
761
762    #[test]
763    fn test_self_loop_included_when_enabled() {
764        // A -> A (self-loop)
765        let nodes = [("func_a", NodeKind::Function)];
766        let edges = [(0, 0)];
767        let (graph, _) = create_test_graph(&nodes, &edges);
768        let config = CircularConfig {
769            should_include_self_loops: true,
770            min_depth: 1, // Must be 1 to include self-loops
771            ..Default::default()
772        };
773
774        let cycles = find_all_cycles_graph(CircularType::Calls, &graph, &config);
775        assert_eq!(cycles.len(), 1, "Should find self-loop when enabled");
776        assert_eq!(cycles[0].len(), 1, "Self-loop has 1 node");
777        assert_eq!(cycles[0][0], "func_a");
778    }
779
780    #[test]
781    fn test_min_depth_filter() {
782        // Two cycles: A -> B -> A (size 2) and X -> Y -> Z -> X (size 3)
783        let nodes = [
784            ("func_a", NodeKind::Function),
785            ("func_b", NodeKind::Function),
786            ("func_x", NodeKind::Function),
787            ("func_y", NodeKind::Function),
788            ("func_z", NodeKind::Function),
789        ];
790        let edges = [(0, 1), (1, 0), (2, 3), (3, 4), (4, 2)];
791        let (graph, _) = create_test_graph(&nodes, &edges);
792        let config = CircularConfig {
793            min_depth: 3, // Only cycles with 3+ nodes
794            ..Default::default()
795        };
796
797        let cycles = find_all_cycles_graph(CircularType::Calls, &graph, &config);
798        assert_eq!(cycles.len(), 1, "Should only find the 3-node cycle");
799        assert_eq!(cycles[0].len(), 3);
800    }
801
802    #[test]
803    fn test_max_depth_filter() {
804        // Two cycles: A -> B -> A (size 2) and X -> Y -> Z -> W -> X (size 4)
805        let nodes = [
806            ("func_a", NodeKind::Function),
807            ("func_b", NodeKind::Function),
808            ("func_x", NodeKind::Function),
809            ("func_y", NodeKind::Function),
810            ("func_z", NodeKind::Function),
811            ("func_w", NodeKind::Function),
812        ];
813        let edges = [(0, 1), (1, 0), (2, 3), (3, 4), (4, 5), (5, 2)];
814        let (graph, _) = create_test_graph(&nodes, &edges);
815        let config = CircularConfig {
816            max_depth: Some(3), // Only cycles with 3 or fewer nodes
817            ..Default::default()
818        };
819
820        let cycles = find_all_cycles_graph(CircularType::Calls, &graph, &config);
821        assert_eq!(cycles.len(), 1, "Should only find the 2-node cycle");
822        assert_eq!(cycles[0].len(), 2);
823    }
824
825    #[test]
826    fn test_max_results_limit() {
827        // Create 10 independent 2-node cycles
828        let mut nodes = Vec::new();
829        let mut edges = Vec::new();
830        for i in 0..10 {
831            nodes.push((format!("func_a{i}"), NodeKind::Function));
832            nodes.push((format!("func_b{i}"), NodeKind::Function));
833            edges.push((i * 2, i * 2 + 1));
834            edges.push((i * 2 + 1, i * 2));
835        }
836
837        let nodes_ref: Vec<(&str, NodeKind)> =
838            nodes.iter().map(|(s, k)| (s.as_str(), *k)).collect();
839        let (graph, _) = create_test_graph(&nodes_ref, &edges);
840        let config = CircularConfig {
841            max_results: 3,
842            ..Default::default()
843        };
844
845        let cycles = find_all_cycles_graph(CircularType::Calls, &graph, &config);
846        assert_eq!(cycles.len(), 3, "Should respect max_results limit");
847    }
848
849    #[test]
850    fn test_import_cycles() {
851        // Module A imports Module B imports Module A
852        let nodes = [
853            ("module_a", NodeKind::Module),
854            ("module_b", NodeKind::Module),
855        ];
856        let edges = [(0, 1), (1, 0)];
857        let (graph, _) = create_import_graph(&nodes, &edges);
858        let config = CircularConfig::default();
859
860        let cycles = find_all_cycles_graph(CircularType::Imports, &graph, &config);
861        assert_eq!(cycles.len(), 1, "Should find import cycle");
862    }
863
864    #[test]
865    fn test_is_node_in_cycle_positive() {
866        // A -> B -> A (A is in a cycle)
867        let nodes = [
868            ("func_a", NodeKind::Function),
869            ("func_b", NodeKind::Function),
870        ];
871        let edges = [(0, 1), (1, 0)];
872        let (graph, node_ids) = create_test_graph(&nodes, &edges);
873        let config = CircularConfig::default();
874
875        assert!(
876            is_node_in_cycle(node_ids[0], CircularType::Calls, &graph, &config),
877            "func_a should be in a cycle"
878        );
879        assert!(
880            is_node_in_cycle(node_ids[1], CircularType::Calls, &graph, &config),
881            "func_b should be in a cycle"
882        );
883    }
884
885    #[test]
886    fn test_is_node_in_cycle_negative() {
887        // A -> B -> C (no cycles, C is not in a cycle)
888        let nodes = [
889            ("func_a", NodeKind::Function),
890            ("func_b", NodeKind::Function),
891            ("func_c", NodeKind::Function),
892        ];
893        let edges = [(0, 1), (1, 2)];
894        let (graph, node_ids) = create_test_graph(&nodes, &edges);
895        let config = CircularConfig::default();
896
897        assert!(
898            !is_node_in_cycle(node_ids[2], CircularType::Calls, &graph, &config),
899            "func_c should not be in a cycle"
900        );
901    }
902
903    #[test]
904    fn test_is_node_in_cycle_invalid_node() {
905        let graph = CodeGraph::new();
906        let config = CircularConfig::default();
907        let invalid_node = NodeId::new(999, 0);
908
909        assert!(
910            !is_node_in_cycle(invalid_node, CircularType::Calls, &graph, &config),
911            "Invalid node should return false"
912        );
913    }
914
915    #[test]
916    fn test_multiple_independent_cycles() {
917        // Cycle 1: A -> B -> A
918        // Cycle 2: X -> Y -> Z -> X (separate component)
919        let nodes = [
920            ("func_a", NodeKind::Function),
921            ("func_b", NodeKind::Function),
922            ("func_x", NodeKind::Function),
923            ("func_y", NodeKind::Function),
924            ("func_z", NodeKind::Function),
925        ];
926        let edges = [(0, 1), (1, 0), (2, 3), (3, 4), (4, 2)];
927        let (graph, _) = create_test_graph(&nodes, &edges);
928        let config = CircularConfig::default();
929
930        let cycles = find_all_cycles_graph(CircularType::Calls, &graph, &config);
931        assert_eq!(cycles.len(), 2, "Should find both independent cycles");
932    }
933
934    #[test]
935    fn test_cycle_with_branches() {
936        // A -> B -> C -> A (cycle) with D -> B (branch into cycle)
937        let nodes = [
938            ("func_a", NodeKind::Function),
939            ("func_b", NodeKind::Function),
940            ("func_c", NodeKind::Function),
941            ("func_d", NodeKind::Function),
942        ];
943        let edges = [(0, 1), (1, 2), (2, 0), (3, 1)];
944        let (graph, node_ids) = create_test_graph(&nodes, &edges);
945        let config = CircularConfig::default();
946
947        let cycles = find_all_cycles_graph(CircularType::Calls, &graph, &config);
948        assert_eq!(cycles.len(), 1, "Should find the main cycle");
949
950        // D is not in the cycle, it just points into it
951        assert!(
952            !is_node_in_cycle(node_ids[3], CircularType::Calls, &graph, &config),
953            "func_d should not be in the cycle"
954        );
955    }
956
957    #[test]
958    fn test_is_node_in_cycle_min_depth_3_scc_semantics() {
959        // Codex review scenario: T->A, T->B, B->A, A->T
960        // This forms an SCC of size 3: {T, A, B}
961        // With min_depth=3, both find_all_cycles_graph and is_node_in_cycle
962        // should agree that T is in a cycle (SCC size = 3 >= min_depth)
963        let nodes = [
964            ("func_t", NodeKind::Function), // 0
965            ("func_a", NodeKind::Function), // 1
966            ("func_b", NodeKind::Function), // 2
967        ];
968        // T->A, T->B, B->A, A->T forms SCC {T, A, B}
969        let edges = [(0, 1), (0, 2), (2, 1), (1, 0)];
970        let (graph, node_ids) = create_test_graph(&nodes, &edges);
971
972        let config = CircularConfig {
973            min_depth: 3,
974            ..Default::default()
975        };
976
977        // find_all_cycles_graph should find the SCC
978        let cycles = find_all_cycles_graph(CircularType::Calls, &graph, &config);
979        assert_eq!(cycles.len(), 1, "Should find the 3-node SCC");
980        assert_eq!(cycles[0].len(), 3, "SCC should have 3 nodes");
981
982        // is_node_in_cycle should agree for all nodes in the SCC
983        assert!(
984            is_node_in_cycle(node_ids[0], CircularType::Calls, &graph, &config),
985            "func_t should be in cycle (SCC size 3 >= min_depth 3)"
986        );
987        assert!(
988            is_node_in_cycle(node_ids[1], CircularType::Calls, &graph, &config),
989            "func_a should be in cycle (SCC size 3 >= min_depth 3)"
990        );
991        assert!(
992            is_node_in_cycle(node_ids[2], CircularType::Calls, &graph, &config),
993            "func_b should be in cycle (SCC size 3 >= min_depth 3)"
994        );
995    }
996
997    #[test]
998    fn test_self_loop_semantics_consistency() {
999        // Self-loop: A -> A
1000        // With include_self_loops=true and min_depth>1, both functions should agree
1001        let nodes = [("func_a", NodeKind::Function)];
1002        let edges = [(0, 0)];
1003        let (graph, node_ids) = create_test_graph(&nodes, &edges);
1004
1005        // Case 1: include_self_loops=true, min_depth=2
1006        // find_all_cycles_graph includes self-loops even when min_depth > 1
1007        // is_node_in_cycle should do the same
1008        let config = CircularConfig {
1009            should_include_self_loops: true,
1010            min_depth: 2,
1011            ..Default::default()
1012        };
1013
1014        let cycles = find_all_cycles_graph(CircularType::Calls, &graph, &config);
1015        let is_in_cycle = is_node_in_cycle(node_ids[0], CircularType::Calls, &graph, &config);
1016
1017        // Both should agree (self-loops are special-cased when include_self_loops is true)
1018        assert_eq!(
1019            !cycles.is_empty(),
1020            is_in_cycle,
1021            "find_all_cycles_graph and is_node_in_cycle should agree on self-loop inclusion"
1022        );
1023
1024        // Case 2: include_self_loops=false
1025        let config_no_self = CircularConfig {
1026            should_include_self_loops: false,
1027            min_depth: 1,
1028            ..Default::default()
1029        };
1030
1031        let cycles_no_self = find_all_cycles_graph(CircularType::Calls, &graph, &config_no_self);
1032        let is_in_cycle_no_self =
1033            is_node_in_cycle(node_ids[0], CircularType::Calls, &graph, &config_no_self);
1034
1035        assert!(cycles_no_self.is_empty(), "Self-loop should be excluded");
1036        assert!(
1037            !is_in_cycle_no_self,
1038            "is_node_in_cycle should also exclude self-loop"
1039        );
1040    }
1041
1042    #[test]
1043    fn test_is_node_in_cycle_with_max_depth() {
1044        // 4-node cycle: A -> B -> C -> D -> A
1045        let nodes = [
1046            ("func_a", NodeKind::Function),
1047            ("func_b", NodeKind::Function),
1048            ("func_c", NodeKind::Function),
1049            ("func_d", NodeKind::Function),
1050        ];
1051        let edges = [(0, 1), (1, 2), (2, 3), (3, 0)];
1052        let (graph, node_ids) = create_test_graph(&nodes, &edges);
1053
1054        // max_depth=3 should exclude this 4-node cycle
1055        let config = CircularConfig {
1056            max_depth: Some(3),
1057            ..Default::default()
1058        };
1059
1060        let cycles = find_all_cycles_graph(CircularType::Calls, &graph, &config);
1061        assert!(cycles.is_empty(), "4-node cycle exceeds max_depth=3");
1062
1063        assert!(
1064            !is_node_in_cycle(node_ids[0], CircularType::Calls, &graph, &config),
1065            "is_node_in_cycle should also respect max_depth"
1066        );
1067
1068        // max_depth=4 should include it
1069        let config_larger = CircularConfig {
1070            max_depth: Some(4),
1071            ..Default::default()
1072        };
1073
1074        let cycles_larger = find_all_cycles_graph(CircularType::Calls, &graph, &config_larger);
1075        assert_eq!(cycles_larger.len(), 1, "4-node cycle fits max_depth=4");
1076
1077        assert!(
1078            is_node_in_cycle(node_ids[0], CircularType::Calls, &graph, &config_larger),
1079            "is_node_in_cycle should find node in 4-node cycle with max_depth=4"
1080        );
1081    }
1082}