1use crate::graph::unified::concurrent::CodeGraph;
7use crate::graph::unified::edge::EdgeKind;
8use crate::graph::unified::node::NodeId;
9use std::collections::{HashMap, HashSet};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum CircularType {
14 Calls,
16 Imports,
18 Modules,
20}
21
22impl CircularType {
23 #[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#[derive(Debug, Clone)]
37pub struct CircularConfig {
38 pub min_depth: usize,
40 pub max_depth: Option<usize>,
42 pub max_results: usize,
44 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
59struct TarjanFrame {
61 node: NodeId,
63 successor_idx: usize,
65 is_post_visit: bool,
67}
68
69struct GraphTarjanState {
71 index: usize,
73 stack: Vec<NodeId>,
75 on_stack: HashSet<NodeId>,
77 indices: HashMap<NodeId, usize>,
79 lowlinks: HashMap<NodeId, usize>,
81 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
133fn 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 for (node_id, _entry) in graph.nodes().iter() {
147 all_nodes.insert(node_id);
148
149 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
164fn 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 for (node_id, _entry) in graph.nodes().iter() {
178 all_nodes.insert(node_id);
179
180 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
195fn 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 if is_self_loop {
270 return config.should_include_self_loops;
271 }
272
273 if size == 1 {
275 return false;
276 }
277
278 if size < config.min_depth {
280 return false;
281 }
282
283 if config.max_depth.is_some_and(|max| size > max) {
285 return false;
286 }
287
288 true
289}
290
291#[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 for node in &all_nodes {
321 if !state.indices.contains_key(node) {
322 tarjan_strongconnect_iterative(*node, &adjacency, &mut state);
323 }
324 }
325
326 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 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#[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 if graph.nodes().get(node_id).is_none() {
391 return false;
392 }
393
394 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 if self_loops.contains(&node_id) && config.should_include_self_loops {
406 return true;
407 }
408
409 find_scc_containing_node(node_id, &adjacency, config)
411}
412
413fn 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
434fn 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
455struct 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 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 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 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 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 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 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
576fn 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 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 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 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 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 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 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 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 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 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, ..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 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, ..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 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), ..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 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 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 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 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 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 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 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 let nodes = [
964 ("func_t", NodeKind::Function), ("func_a", NodeKind::Function), ("func_b", NodeKind::Function), ];
968 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 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 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 let nodes = [("func_a", NodeKind::Function)];
1002 let edges = [(0, 0)];
1003 let (graph, node_ids) = create_test_graph(&nodes, &edges);
1004
1005 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 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 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 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 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 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}