1use std::ops::Range;
4
5use fixedbitset::FixedBitSet;
6use rustc_hash::FxHashSet;
7
8use fallow_types::discover::FileId;
9
10use super::ModuleGraph;
11use super::types::ModuleNode;
12
13impl ModuleGraph {
14 #[must_use]
27 #[expect(
28 clippy::excessive_nesting,
29 reason = "Tarjan's SCC requires deep nesting"
30 )]
31 #[expect(
32 clippy::cast_possible_truncation,
33 reason = "file count is bounded by project size, well under u32::MAX"
34 )]
35 pub fn find_cycles(&self) -> Vec<Vec<FileId>> {
36 let n = self.modules.len();
37 if n == 0 {
38 return Vec::new();
39 }
40
41 let mut index_counter: u32 = 0;
43 let mut indices: Vec<u32> = vec![u32::MAX; n]; let mut lowlinks: Vec<u32> = vec![0; n];
45 let mut on_stack = FixedBitSet::with_capacity(n);
46 let mut stack: Vec<usize> = Vec::new();
47 let mut sccs: Vec<Vec<FileId>> = Vec::new();
48
49 struct Frame {
51 node: usize,
52 succ_pos: usize,
53 succ_end: usize,
54 }
55
56 let mut all_succs: Vec<usize> = Vec::with_capacity(self.edges.len());
58 let mut succ_ranges: Vec<Range<usize>> = Vec::with_capacity(n);
59 let mut seen_set = FxHashSet::default();
60 for module in &self.modules {
61 let start = all_succs.len();
62 seen_set.clear();
63 for edge in &self.edges[module.edge_range.clone()] {
64 let target = edge.target.0 as usize;
65 if target < n && seen_set.insert(target) {
66 all_succs.push(target);
67 }
68 }
69 let end = all_succs.len();
70 succ_ranges.push(start..end);
71 }
72
73 let mut dfs_stack: Vec<Frame> = Vec::new();
74
75 for start_node in 0..n {
76 if indices[start_node] != u32::MAX {
77 continue;
78 }
79
80 indices[start_node] = index_counter;
82 lowlinks[start_node] = index_counter;
83 index_counter += 1;
84 on_stack.insert(start_node);
85 stack.push(start_node);
86
87 let range = &succ_ranges[start_node];
88 dfs_stack.push(Frame {
89 node: start_node,
90 succ_pos: range.start,
91 succ_end: range.end,
92 });
93
94 while let Some(frame) = dfs_stack.last_mut() {
95 if frame.succ_pos < frame.succ_end {
96 let w = all_succs[frame.succ_pos];
97 frame.succ_pos += 1;
98
99 if indices[w] == u32::MAX {
100 indices[w] = index_counter;
102 lowlinks[w] = index_counter;
103 index_counter += 1;
104 on_stack.insert(w);
105 stack.push(w);
106
107 let range = &succ_ranges[w];
108 dfs_stack.push(Frame {
109 node: w,
110 succ_pos: range.start,
111 succ_end: range.end,
112 });
113 } else if on_stack.contains(w) {
114 let v = frame.node;
116 lowlinks[v] = lowlinks[v].min(indices[w]);
117 }
118 } else {
119 let v = frame.node;
121 let v_lowlink = lowlinks[v];
122 let v_index = indices[v];
123 dfs_stack.pop();
124
125 if let Some(parent) = dfs_stack.last_mut() {
127 lowlinks[parent.node] = lowlinks[parent.node].min(v_lowlink);
128 }
129
130 if v_lowlink == v_index {
132 let mut scc = Vec::new();
133 loop {
134 let w = stack.pop().expect("SCC stack should not be empty");
135 on_stack.set(w, false);
136 scc.push(FileId(w as u32));
137 if w == v {
138 break;
139 }
140 }
141 if scc.len() >= 2 {
143 sccs.push(scc);
144 }
145 }
146 }
147 }
148 }
149
150 self.enumerate_cycles_from_sccs(&sccs, &all_succs, &succ_ranges)
151 }
152
153 #[expect(
155 clippy::cast_possible_truncation,
156 reason = "file count is bounded by project size, well under u32::MAX"
157 )]
158 fn enumerate_cycles_from_sccs(
159 &self,
160 sccs: &[Vec<FileId>],
161 all_succs: &[usize],
162 succ_ranges: &[Range<usize>],
163 ) -> Vec<Vec<FileId>> {
164 const MAX_CYCLES_PER_SCC: usize = 20;
165
166 let succs = SuccessorMap {
167 all_succs,
168 succ_ranges,
169 modules: &self.modules,
170 };
171
172 let mut result: Vec<Vec<FileId>> = Vec::new();
173 let mut seen_cycles: FxHashSet<Vec<u32>> = FxHashSet::default();
174
175 for scc in sccs {
176 if scc.len() == 2 {
177 let mut cycle = vec![scc[0].0 as usize, scc[1].0 as usize];
178 if self.modules[cycle[1]].path < self.modules[cycle[0]].path {
180 cycle.swap(0, 1);
181 }
182 let key: Vec<u32> = cycle.iter().map(|&n| n as u32).collect();
183 if seen_cycles.insert(key) {
184 result.push(cycle.into_iter().map(|n| FileId(n as u32)).collect());
185 }
186 continue;
187 }
188
189 let scc_nodes: Vec<usize> = scc.iter().map(|id| id.0 as usize).collect();
190 let elementary = enumerate_elementary_cycles(&scc_nodes, &succs, MAX_CYCLES_PER_SCC);
191
192 for cycle in elementary {
193 let key: Vec<u32> = cycle.iter().map(|&n| n as u32).collect();
194 if seen_cycles.insert(key) {
195 result.push(cycle.into_iter().map(|n| FileId(n as u32)).collect());
196 }
197 }
198 }
199
200 result.sort_by(|a, b| {
202 a.len().cmp(&b.len()).then_with(|| {
203 self.modules[a[0].0 as usize]
204 .path
205 .cmp(&self.modules[b[0].0 as usize].path)
206 })
207 });
208
209 result
210 }
211}
212
213fn canonical_cycle(cycle: &[usize], modules: &[ModuleNode]) -> Vec<usize> {
215 if cycle.is_empty() {
216 return Vec::new();
217 }
218 let min_pos = cycle
219 .iter()
220 .enumerate()
221 .min_by(|(_, a), (_, b)| modules[**a].path.cmp(&modules[**b].path))
222 .map_or(0, |(i, _)| i);
223 let mut result = cycle[min_pos..].to_vec();
224 result.extend_from_slice(&cycle[..min_pos]);
225 result
226}
227
228struct CycleFrame {
230 succ_pos: usize,
231 succ_end: usize,
232}
233
234struct SuccessorMap<'a> {
236 all_succs: &'a [usize],
237 succ_ranges: &'a [Range<usize>],
238 modules: &'a [ModuleNode],
239}
240
241#[expect(
243 clippy::cast_possible_truncation,
244 reason = "file count is bounded by project size, well under u32::MAX"
245)]
246fn try_record_cycle(
247 path: &[usize],
248 modules: &[ModuleNode],
249 seen: &mut FxHashSet<Vec<u32>>,
250 cycles: &mut Vec<Vec<usize>>,
251) {
252 let canonical = canonical_cycle(path, modules);
253 let key: Vec<u32> = canonical.iter().map(|&n| n as u32).collect();
254 if seen.insert(key) {
255 cycles.push(canonical);
256 }
257}
258
259fn dfs_find_cycles_from(
264 start: usize,
265 depth_limit: usize,
266 scc_set: &FxHashSet<usize>,
267 succs: &SuccessorMap<'_>,
268 max_cycles: usize,
269 seen: &mut FxHashSet<Vec<u32>>,
270 cycles: &mut Vec<Vec<usize>>,
271) {
272 let mut path: Vec<usize> = vec![start];
273 let mut path_set = FixedBitSet::with_capacity(succs.modules.len());
274 path_set.insert(start);
275
276 let range = &succs.succ_ranges[start];
277 let mut dfs: Vec<CycleFrame> = vec![CycleFrame {
278 succ_pos: range.start,
279 succ_end: range.end,
280 }];
281
282 while let Some(frame) = dfs.last_mut() {
283 if cycles.len() >= max_cycles {
284 return;
285 }
286
287 if frame.succ_pos >= frame.succ_end {
288 dfs.pop();
290 if path.len() > 1 {
291 let removed = path.pop().unwrap();
292 path_set.set(removed, false);
293 }
294 continue;
295 }
296
297 let w = succs.all_succs[frame.succ_pos];
298 frame.succ_pos += 1;
299
300 if !scc_set.contains(&w) {
302 continue;
303 }
304
305 if w == start && path.len() >= 2 && path.len() == depth_limit {
307 try_record_cycle(&path, succs.modules, seen, cycles);
308 continue;
309 }
310
311 if path_set.contains(w) || path.len() >= depth_limit {
313 continue;
314 }
315
316 path.push(w);
318 path_set.insert(w);
319
320 let range = &succs.succ_ranges[w];
321 dfs.push(CycleFrame {
322 succ_pos: range.start,
323 succ_end: range.end,
324 });
325 }
326}
327
328fn enumerate_elementary_cycles(
334 scc_nodes: &[usize],
335 succs: &SuccessorMap<'_>,
336 max_cycles: usize,
337) -> Vec<Vec<usize>> {
338 let scc_set: FxHashSet<usize> = scc_nodes.iter().copied().collect();
339 let mut cycles: Vec<Vec<usize>> = Vec::new();
340 let mut seen: FxHashSet<Vec<u32>> = FxHashSet::default();
341
342 let mut sorted_nodes: Vec<usize> = scc_nodes.to_vec();
344 sorted_nodes.sort_by(|a, b| succs.modules[*a].path.cmp(&succs.modules[*b].path));
345
346 let max_depth = scc_nodes.len().min(12); for depth_limit in 2..=max_depth {
349 if cycles.len() >= max_cycles {
350 break;
351 }
352
353 for &start in &sorted_nodes {
354 if cycles.len() >= max_cycles {
355 break;
356 }
357
358 dfs_find_cycles_from(
359 start,
360 depth_limit,
361 &scc_set,
362 succs,
363 max_cycles,
364 &mut seen,
365 &mut cycles,
366 );
367 }
368 }
369
370 cycles
371}
372
373#[cfg(test)]
374mod tests {
375 use std::ops::Range;
376 use std::path::PathBuf;
377
378 use rustc_hash::FxHashSet;
379
380 use crate::graph::types::ModuleNode;
381 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
382 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
383 use fallow_types::extract::{ExportName, ImportInfo, ImportedName};
384
385 use super::{
386 ModuleGraph, SuccessorMap, canonical_cycle, dfs_find_cycles_from,
387 enumerate_elementary_cycles, try_record_cycle,
388 };
389
390 #[expect(
392 clippy::cast_possible_truncation,
393 reason = "test file counts are trivially small"
394 )]
395 fn build_cycle_graph(file_count: usize, edges_spec: &[(u32, u32)]) -> ModuleGraph {
396 let files: Vec<DiscoveredFile> = (0..file_count)
397 .map(|i| DiscoveredFile {
398 id: FileId(i as u32),
399 path: PathBuf::from(format!("/project/file{i}.ts")),
400 size_bytes: 100,
401 })
402 .collect();
403
404 let resolved_modules: Vec<ResolvedModule> = (0..file_count)
405 .map(|i| {
406 let imports: Vec<ResolvedImport> = edges_spec
407 .iter()
408 .filter(|(src, _)| *src == i as u32)
409 .map(|(_, tgt)| ResolvedImport {
410 info: ImportInfo {
411 source: format!("./file{tgt}"),
412 imported_name: ImportedName::Named("x".to_string()),
413 local_name: "x".to_string(),
414 is_type_only: false,
415 span: oxc_span::Span::new(0, 10),
416 source_span: oxc_span::Span::default(),
417 },
418 target: ResolveResult::InternalModule(FileId(*tgt)),
419 })
420 .collect();
421
422 ResolvedModule {
423 file_id: FileId(i as u32),
424 path: PathBuf::from(format!("/project/file{i}.ts")),
425 exports: vec![fallow_types::extract::ExportInfo {
426 name: ExportName::Named("x".to_string()),
427 local_name: Some("x".to_string()),
428 is_type_only: false,
429 is_public: false,
430 span: oxc_span::Span::new(0, 20),
431 members: vec![],
432 }],
433 re_exports: vec![],
434 resolved_imports: imports,
435 resolved_dynamic_imports: vec![],
436 resolved_dynamic_patterns: vec![],
437 member_accesses: vec![],
438 whole_object_uses: vec![],
439 has_cjs_exports: false,
440 unused_import_bindings: FxHashSet::default(),
441 }
442 })
443 .collect();
444
445 let entry_points = vec![EntryPoint {
446 path: PathBuf::from("/project/file0.ts"),
447 source: EntryPointSource::PackageJsonMain,
448 }];
449
450 ModuleGraph::build(&resolved_modules, &entry_points, &files)
451 }
452
453 #[test]
454 fn find_cycles_empty_graph() {
455 let graph = ModuleGraph::build(&[], &[], &[]);
456 assert!(graph.find_cycles().is_empty());
457 }
458
459 #[test]
460 fn find_cycles_no_cycles() {
461 let graph = build_cycle_graph(3, &[(0, 1), (1, 2)]);
463 assert!(graph.find_cycles().is_empty());
464 }
465
466 #[test]
467 fn find_cycles_simple_two_node_cycle() {
468 let graph = build_cycle_graph(2, &[(0, 1), (1, 0)]);
470 let cycles = graph.find_cycles();
471 assert_eq!(cycles.len(), 1);
472 assert_eq!(cycles[0].len(), 2);
473 }
474
475 #[test]
476 fn find_cycles_three_node_cycle() {
477 let graph = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
479 let cycles = graph.find_cycles();
480 assert_eq!(cycles.len(), 1);
481 assert_eq!(cycles[0].len(), 3);
482 }
483
484 #[test]
485 fn find_cycles_self_import_ignored() {
486 let graph = build_cycle_graph(1, &[(0, 0)]);
490 let cycles = graph.find_cycles();
491 assert!(
492 cycles.is_empty(),
493 "self-imports should not be reported as cycles"
494 );
495 }
496
497 #[test]
498 fn find_cycles_multiple_independent_cycles() {
499 let graph = build_cycle_graph(4, &[(0, 1), (1, 0), (2, 3), (3, 2)]);
503 let cycles = graph.find_cycles();
504 assert_eq!(cycles.len(), 2);
505 assert!(cycles.iter().all(|c| c.len() == 2));
507 }
508
509 #[test]
510 fn find_cycles_linear_chain_with_back_edge() {
511 let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 3), (3, 1)]);
513 let cycles = graph.find_cycles();
514 assert_eq!(cycles.len(), 1);
515 assert_eq!(cycles[0].len(), 3);
516 let ids: Vec<u32> = cycles[0].iter().map(|f| f.0).collect();
518 assert!(ids.contains(&1));
519 assert!(ids.contains(&2));
520 assert!(ids.contains(&3));
521 assert!(!ids.contains(&0));
522 }
523
524 #[test]
525 fn find_cycles_overlapping_cycles_enumerated() {
526 let graph = build_cycle_graph(3, &[(0, 1), (1, 0), (1, 2), (2, 1)]);
528 let cycles = graph.find_cycles();
529 assert_eq!(
530 cycles.len(),
531 2,
532 "should find 2 elementary cycles, not 1 SCC"
533 );
534 assert!(
535 cycles.iter().all(|c| c.len() == 2),
536 "both cycles should have length 2"
537 );
538 }
539
540 #[test]
541 fn find_cycles_deterministic_ordering() {
542 let graph1 = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
544 let graph2 = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
545 let cycles1 = graph1.find_cycles();
546 let cycles2 = graph2.find_cycles();
547 assert_eq!(cycles1.len(), cycles2.len());
548 for (c1, c2) in cycles1.iter().zip(cycles2.iter()) {
549 let paths1: Vec<&PathBuf> = c1
550 .iter()
551 .map(|f| &graph1.modules[f.0 as usize].path)
552 .collect();
553 let paths2: Vec<&PathBuf> = c2
554 .iter()
555 .map(|f| &graph2.modules[f.0 as usize].path)
556 .collect();
557 assert_eq!(paths1, paths2);
558 }
559 }
560
561 #[test]
562 fn find_cycles_sorted_by_length() {
563 let graph = build_cycle_graph(5, &[(0, 1), (1, 0), (2, 3), (3, 4), (4, 2)]);
565 let cycles = graph.find_cycles();
566 assert_eq!(cycles.len(), 2);
567 assert!(
568 cycles[0].len() <= cycles[1].len(),
569 "cycles should be sorted by length"
570 );
571 }
572
573 #[test]
574 fn find_cycles_large_cycle() {
575 let edges: Vec<(u32, u32)> = (0..10).map(|i| (i, (i + 1) % 10)).collect();
577 let graph = build_cycle_graph(10, &edges);
578 let cycles = graph.find_cycles();
579 assert_eq!(cycles.len(), 1);
580 assert_eq!(cycles[0].len(), 10);
581 }
582
583 #[test]
584 fn find_cycles_complex_scc_multiple_elementary() {
585 let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)]);
588 let cycles = graph.find_cycles();
589 assert!(
591 cycles.len() >= 2,
592 "should find at least 2 elementary cycles, got {}",
593 cycles.len()
594 );
595 assert!(cycles.iter().all(|c| c.len() <= 4));
597 }
598
599 #[test]
600 fn find_cycles_no_duplicate_cycles() {
601 let graph = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
604 let cycles = graph.find_cycles();
605 assert_eq!(cycles.len(), 1, "triangle should produce exactly 1 cycle");
606 assert_eq!(cycles[0].len(), 3);
607 }
608
609 #[expect(
618 clippy::cast_possible_truncation,
619 reason = "test file counts are trivially small"
620 )]
621 fn build_test_succs(
622 file_count: usize,
623 edges_spec: &[(usize, usize)],
624 ) -> (Vec<ModuleNode>, Vec<usize>, Vec<Range<usize>>) {
625 let modules: Vec<ModuleNode> = (0..file_count)
626 .map(|i| ModuleNode {
627 file_id: FileId(i as u32),
628 path: PathBuf::from(format!("/project/file{i}.ts")),
629 edge_range: 0..0,
630 exports: vec![],
631 re_exports: vec![],
632 is_entry_point: i == 0,
633 is_reachable: true,
634 is_runtime_reachable: true,
635 is_test_reachable: false,
636 has_cjs_exports: false,
637 })
638 .collect();
639
640 let mut all_succs: Vec<usize> = Vec::new();
641 let mut succ_ranges: Vec<Range<usize>> = Vec::with_capacity(file_count);
642 for src in 0..file_count {
643 let start = all_succs.len();
644 let mut seen = FxHashSet::default();
645 for &(s, t) in edges_spec {
646 if s == src && t < file_count && seen.insert(t) {
647 all_succs.push(t);
648 }
649 }
650 let end = all_succs.len();
651 succ_ranges.push(start..end);
652 }
653
654 (modules, all_succs, succ_ranges)
655 }
656
657 #[test]
662 fn canonical_cycle_empty() {
663 let modules: Vec<ModuleNode> = vec![];
664 assert!(canonical_cycle(&[], &modules).is_empty());
665 }
666
667 #[test]
668 fn canonical_cycle_rotates_to_smallest_path() {
669 let (modules, _, _) = build_test_succs(3, &[]);
670 let result = canonical_cycle(&[2, 0, 1], &modules);
672 assert_eq!(result, vec![0, 1, 2]);
673 }
674
675 #[test]
676 fn canonical_cycle_already_canonical() {
677 let (modules, _, _) = build_test_succs(3, &[]);
678 let result = canonical_cycle(&[0, 1, 2], &modules);
679 assert_eq!(result, vec![0, 1, 2]);
680 }
681
682 #[test]
683 fn canonical_cycle_single_node() {
684 let (modules, _, _) = build_test_succs(1, &[]);
685 let result = canonical_cycle(&[0], &modules);
686 assert_eq!(result, vec![0]);
687 }
688
689 #[test]
694 fn try_record_cycle_inserts_new_cycle() {
695 let (modules, _, _) = build_test_succs(3, &[]);
696 let mut seen = FxHashSet::default();
697 let mut cycles = Vec::new();
698
699 try_record_cycle(&[0, 1, 2], &modules, &mut seen, &mut cycles);
700 assert_eq!(cycles.len(), 1);
701 assert_eq!(cycles[0], vec![0, 1, 2]);
702 }
703
704 #[test]
705 fn try_record_cycle_deduplicates_rotated_cycle() {
706 let (modules, _, _) = build_test_succs(3, &[]);
709 let mut seen = FxHashSet::default();
710 let mut cycles = Vec::new();
711
712 try_record_cycle(&[0, 1, 2], &modules, &mut seen, &mut cycles);
713 try_record_cycle(&[1, 2, 0], &modules, &mut seen, &mut cycles);
714 try_record_cycle(&[2, 0, 1], &modules, &mut seen, &mut cycles);
715
716 assert_eq!(
717 cycles.len(),
718 1,
719 "rotations of the same cycle should be deduped"
720 );
721 }
722
723 #[test]
724 fn try_record_cycle_single_node_self_loop() {
725 let (modules, _, _) = build_test_succs(1, &[]);
727 let mut seen = FxHashSet::default();
728 let mut cycles = Vec::new();
729
730 try_record_cycle(&[0], &modules, &mut seen, &mut cycles);
731 assert_eq!(cycles.len(), 1);
732 assert_eq!(cycles[0], vec![0]);
733 }
734
735 #[test]
736 fn try_record_cycle_distinct_cycles_both_recorded() {
737 let (modules, _, _) = build_test_succs(4, &[]);
739 let mut seen = FxHashSet::default();
740 let mut cycles = Vec::new();
741
742 try_record_cycle(&[0, 1], &modules, &mut seen, &mut cycles);
743 try_record_cycle(&[2, 3], &modules, &mut seen, &mut cycles);
744
745 assert_eq!(cycles.len(), 2);
746 }
747
748 #[test]
753 fn successor_map_empty_graph() {
754 let (modules, all_succs, succ_ranges) = build_test_succs(0, &[]);
755 let succs = SuccessorMap {
756 all_succs: &all_succs,
757 succ_ranges: &succ_ranges,
758 modules: &modules,
759 };
760 assert!(succs.all_succs.is_empty());
761 assert!(succs.succ_ranges.is_empty());
762 }
763
764 #[test]
765 fn successor_map_single_node_self_edge() {
766 let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
767 let succs = SuccessorMap {
768 all_succs: &all_succs,
769 succ_ranges: &succ_ranges,
770 modules: &modules,
771 };
772 assert_eq!(succs.all_succs.len(), 1);
773 assert_eq!(succs.all_succs[0], 0);
774 assert_eq!(succs.succ_ranges[0], 0..1);
775 }
776
777 #[test]
778 fn successor_map_deduplicates_edges() {
779 let (modules, all_succs, succ_ranges) = build_test_succs(2, &[(0, 1), (0, 1)]);
781 let succs = SuccessorMap {
782 all_succs: &all_succs,
783 succ_ranges: &succ_ranges,
784 modules: &modules,
785 };
786 let range = &succs.succ_ranges[0];
787 assert_eq!(
788 range.end - range.start,
789 1,
790 "duplicate edges should be deduped"
791 );
792 }
793
794 #[test]
795 fn successor_map_multiple_successors() {
796 let (modules, all_succs, succ_ranges) = build_test_succs(4, &[(0, 1), (0, 2), (0, 3)]);
797 let succs = SuccessorMap {
798 all_succs: &all_succs,
799 succ_ranges: &succ_ranges,
800 modules: &modules,
801 };
802 let range = &succs.succ_ranges[0];
803 assert_eq!(range.end - range.start, 3);
804 for i in 1..4 {
806 let r = &succs.succ_ranges[i];
807 assert_eq!(r.end - r.start, 0);
808 }
809 }
810
811 #[test]
816 fn dfs_find_cycles_from_isolated_node() {
817 let (modules, all_succs, succ_ranges) = build_test_succs(1, &[]);
819 let succs = SuccessorMap {
820 all_succs: &all_succs,
821 succ_ranges: &succ_ranges,
822 modules: &modules,
823 };
824 let scc_set: FxHashSet<usize> = std::iter::once(0).collect();
825 let mut seen = FxHashSet::default();
826 let mut cycles = Vec::new();
827
828 dfs_find_cycles_from(0, 2, &scc_set, &succs, 10, &mut seen, &mut cycles);
829 assert!(cycles.is_empty(), "isolated node should have no cycles");
830 }
831
832 #[test]
833 fn dfs_find_cycles_from_simple_two_cycle() {
834 let (modules, all_succs, succ_ranges) = build_test_succs(2, &[(0, 1), (1, 0)]);
836 let succs = SuccessorMap {
837 all_succs: &all_succs,
838 succ_ranges: &succ_ranges,
839 modules: &modules,
840 };
841 let scc_set: FxHashSet<usize> = [0, 1].into_iter().collect();
842 let mut seen = FxHashSet::default();
843 let mut cycles = Vec::new();
844
845 dfs_find_cycles_from(0, 2, &scc_set, &succs, 10, &mut seen, &mut cycles);
846 assert_eq!(cycles.len(), 1);
847 assert_eq!(cycles[0].len(), 2);
848 }
849
850 #[test]
851 fn dfs_find_cycles_from_diamond_graph() {
852 let (modules, all_succs, succ_ranges) =
856 build_test_succs(4, &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 0)]);
857 let succs = SuccessorMap {
858 all_succs: &all_succs,
859 succ_ranges: &succ_ranges,
860 modules: &modules,
861 };
862 let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
863 let mut seen = FxHashSet::default();
864 let mut cycles = Vec::new();
865
866 dfs_find_cycles_from(0, 3, &scc_set, &succs, 10, &mut seen, &mut cycles);
868 assert_eq!(cycles.len(), 2, "diamond should have two 3-node cycles");
869 assert!(cycles.iter().all(|c| c.len() == 3));
870 }
871
872 #[test]
873 fn dfs_find_cycles_from_depth_limit_prevents_longer_cycles() {
874 let (modules, all_succs, succ_ranges) =
877 build_test_succs(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
878 let succs = SuccessorMap {
879 all_succs: &all_succs,
880 succ_ranges: &succ_ranges,
881 modules: &modules,
882 };
883 let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
884 let mut seen = FxHashSet::default();
885 let mut cycles = Vec::new();
886
887 dfs_find_cycles_from(0, 3, &scc_set, &succs, 10, &mut seen, &mut cycles);
888 assert!(
889 cycles.is_empty(),
890 "depth_limit=3 should prevent finding a 4-node cycle"
891 );
892 }
893
894 #[test]
895 fn dfs_find_cycles_from_depth_limit_exact_match() {
896 let (modules, all_succs, succ_ranges) =
899 build_test_succs(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
900 let succs = SuccessorMap {
901 all_succs: &all_succs,
902 succ_ranges: &succ_ranges,
903 modules: &modules,
904 };
905 let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
906 let mut seen = FxHashSet::default();
907 let mut cycles = Vec::new();
908
909 dfs_find_cycles_from(0, 4, &scc_set, &succs, 10, &mut seen, &mut cycles);
910 assert_eq!(
911 cycles.len(),
912 1,
913 "depth_limit=4 should find the 4-node cycle"
914 );
915 assert_eq!(cycles[0].len(), 4);
916 }
917
918 #[test]
919 fn dfs_find_cycles_from_respects_max_cycles() {
920 let edges: Vec<(usize, usize)> = (0..4)
922 .flat_map(|i| (0..4).filter(move |&j| i != j).map(move |j| (i, j)))
923 .collect();
924 let (modules, all_succs, succ_ranges) = build_test_succs(4, &edges);
925 let succs = SuccessorMap {
926 all_succs: &all_succs,
927 succ_ranges: &succ_ranges,
928 modules: &modules,
929 };
930 let scc_set: FxHashSet<usize> = (0..4).collect();
931 let mut seen = FxHashSet::default();
932 let mut cycles = Vec::new();
933
934 dfs_find_cycles_from(0, 2, &scc_set, &succs, 2, &mut seen, &mut cycles);
936 assert!(
937 cycles.len() <= 2,
938 "should respect max_cycles limit, got {}",
939 cycles.len()
940 );
941 }
942
943 #[test]
944 fn dfs_find_cycles_from_ignores_nodes_outside_scc() {
945 let (modules, all_succs, succ_ranges) = build_test_succs(3, &[(0, 1), (1, 2), (2, 0)]);
947 let succs = SuccessorMap {
948 all_succs: &all_succs,
949 succ_ranges: &succ_ranges,
950 modules: &modules,
951 };
952 let scc_set: FxHashSet<usize> = [0, 1].into_iter().collect();
953 let mut seen = FxHashSet::default();
954 let mut cycles = Vec::new();
955
956 for depth in 2..=3 {
957 dfs_find_cycles_from(0, depth, &scc_set, &succs, 10, &mut seen, &mut cycles);
958 }
959 assert!(
960 cycles.is_empty(),
961 "should not find cycles through nodes outside the SCC set"
962 );
963 }
964
965 #[test]
970 fn enumerate_elementary_cycles_empty_scc() {
971 let (modules, all_succs, succ_ranges) = build_test_succs(0, &[]);
972 let succs = SuccessorMap {
973 all_succs: &all_succs,
974 succ_ranges: &succ_ranges,
975 modules: &modules,
976 };
977 let cycles = enumerate_elementary_cycles(&[], &succs, 10);
978 assert!(cycles.is_empty());
979 }
980
981 #[test]
982 fn enumerate_elementary_cycles_max_cycles_limit() {
983 let edges: Vec<(usize, usize)> = (0..4)
985 .flat_map(|i| (0..4).filter(move |&j| i != j).map(move |j| (i, j)))
986 .collect();
987 let (modules, all_succs, succ_ranges) = build_test_succs(4, &edges);
988 let succs = SuccessorMap {
989 all_succs: &all_succs,
990 succ_ranges: &succ_ranges,
991 modules: &modules,
992 };
993 let scc_nodes: Vec<usize> = (0..4).collect();
994
995 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 3);
996 assert!(
997 cycles.len() <= 3,
998 "should respect max_cycles=3 limit, got {}",
999 cycles.len()
1000 );
1001 }
1002
1003 #[test]
1004 fn enumerate_elementary_cycles_finds_all_in_triangle() {
1005 let (modules, all_succs, succ_ranges) = build_test_succs(3, &[(0, 1), (1, 2), (2, 0)]);
1007 let succs = SuccessorMap {
1008 all_succs: &all_succs,
1009 succ_ranges: &succ_ranges,
1010 modules: &modules,
1011 };
1012 let scc_nodes: Vec<usize> = vec![0, 1, 2];
1013
1014 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1015 assert_eq!(cycles.len(), 1);
1016 assert_eq!(cycles[0].len(), 3);
1017 }
1018
1019 #[test]
1020 fn enumerate_elementary_cycles_iterative_deepening_order() {
1021 let (modules, all_succs, succ_ranges) =
1024 build_test_succs(3, &[(0, 1), (1, 0), (1, 2), (2, 0)]);
1025 let succs = SuccessorMap {
1026 all_succs: &all_succs,
1027 succ_ranges: &succ_ranges,
1028 modules: &modules,
1029 };
1030 let scc_nodes: Vec<usize> = vec![0, 1, 2];
1031
1032 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1033 assert!(cycles.len() >= 2, "should find at least 2 cycles");
1034 assert!(
1036 cycles[0].len() <= cycles[cycles.len() - 1].len(),
1037 "shorter cycles should be found before longer ones"
1038 );
1039 }
1040
1041 #[test]
1046 fn find_cycles_max_cycles_per_scc_respected() {
1047 let edges: Vec<(u32, u32)> = (0..5)
1049 .flat_map(|i| (0..5).filter(move |&j| i != j).map(move |j| (i, j)))
1050 .collect();
1051 let graph = build_cycle_graph(5, &edges);
1052 let cycles = graph.find_cycles();
1053 assert!(
1055 cycles.len() <= 20,
1056 "should cap at MAX_CYCLES_PER_SCC, got {}",
1057 cycles.len()
1058 );
1059 assert!(
1060 !cycles.is_empty(),
1061 "dense graph should still find some cycles"
1062 );
1063 }
1064
1065 #[test]
1066 fn find_cycles_graph_with_no_cycles_returns_empty() {
1067 let graph = build_cycle_graph(5, &[(0, 1), (0, 2), (0, 3), (0, 4)]);
1069 assert!(graph.find_cycles().is_empty());
1070 }
1071
1072 #[test]
1073 fn find_cycles_diamond_no_cycle() {
1074 let graph = build_cycle_graph(4, &[(0, 1), (0, 2), (1, 3), (2, 3)]);
1076 assert!(graph.find_cycles().is_empty());
1077 }
1078
1079 #[test]
1080 fn find_cycles_diamond_with_back_edge() {
1081 let graph = build_cycle_graph(4, &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 0)]);
1083 let cycles = graph.find_cycles();
1084 assert!(
1085 cycles.len() >= 2,
1086 "diamond with back-edge should have at least 2 elementary cycles, got {}",
1087 cycles.len()
1088 );
1089 assert_eq!(cycles[0].len(), 3);
1091 }
1092
1093 #[test]
1098 fn canonical_cycle_non_sequential_indices() {
1099 let (modules, _, _) = build_test_succs(5, &[]);
1101 let result = canonical_cycle(&[3, 1, 4], &modules);
1102 assert_eq!(result, vec![1, 4, 3]);
1104 }
1105
1106 #[test]
1107 fn canonical_cycle_different_starting_points_same_result() {
1108 let (modules, _, _) = build_test_succs(4, &[]);
1111 let r1 = canonical_cycle(&[0, 1, 2, 3], &modules);
1112 let r2 = canonical_cycle(&[1, 2, 3, 0], &modules);
1113 let r3 = canonical_cycle(&[2, 3, 0, 1], &modules);
1114 let r4 = canonical_cycle(&[3, 0, 1, 2], &modules);
1115 assert_eq!(r1, r2);
1116 assert_eq!(r2, r3);
1117 assert_eq!(r3, r4);
1118 assert_eq!(r1, vec![0, 1, 2, 3]);
1119 }
1120
1121 #[test]
1122 fn canonical_cycle_two_node_both_rotations() {
1123 let (modules, _, _) = build_test_succs(2, &[]);
1125 assert_eq!(canonical_cycle(&[0, 1], &modules), vec![0, 1]);
1126 assert_eq!(canonical_cycle(&[1, 0], &modules), vec![0, 1]);
1127 }
1128
1129 #[test]
1134 fn dfs_find_cycles_from_self_loop_not_found() {
1135 let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
1138 let succs = SuccessorMap {
1139 all_succs: &all_succs,
1140 succ_ranges: &succ_ranges,
1141 modules: &modules,
1142 };
1143 let scc_set: FxHashSet<usize> = std::iter::once(0).collect();
1144 let mut seen = FxHashSet::default();
1145 let mut cycles = Vec::new();
1146
1147 for depth in 1..=3 {
1148 dfs_find_cycles_from(0, depth, &scc_set, &succs, 10, &mut seen, &mut cycles);
1149 }
1150 assert!(
1151 cycles.is_empty(),
1152 "self-loop should not be detected as a cycle by dfs_find_cycles_from"
1153 );
1154 }
1155
1156 #[test]
1157 fn enumerate_elementary_cycles_self_loop_not_found() {
1158 let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
1160 let succs = SuccessorMap {
1161 all_succs: &all_succs,
1162 succ_ranges: &succ_ranges,
1163 modules: &modules,
1164 };
1165 let cycles = enumerate_elementary_cycles(&[0], &succs, 20);
1166 assert!(
1167 cycles.is_empty(),
1168 "self-loop should not produce elementary cycles"
1169 );
1170 }
1171
1172 #[test]
1177 fn find_cycles_two_cycles_sharing_edge() {
1178 let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 0), (1, 3), (3, 0)]);
1181 let cycles = graph.find_cycles();
1182 assert_eq!(
1183 cycles.len(),
1184 2,
1185 "two cycles sharing edge A->B should both be found, got {}",
1186 cycles.len()
1187 );
1188 assert!(
1189 cycles.iter().all(|c| c.len() == 3),
1190 "both cycles should have length 3"
1191 );
1192 }
1193
1194 #[test]
1195 fn enumerate_elementary_cycles_shared_edge() {
1196 let (modules, all_succs, succ_ranges) =
1198 build_test_succs(4, &[(0, 1), (1, 2), (2, 0), (1, 3), (3, 0)]);
1199 let succs = SuccessorMap {
1200 all_succs: &all_succs,
1201 succ_ranges: &succ_ranges,
1202 modules: &modules,
1203 };
1204 let scc_nodes: Vec<usize> = vec![0, 1, 2, 3];
1205 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1206 assert_eq!(
1207 cycles.len(),
1208 2,
1209 "should find exactly 2 elementary cycles sharing edge 0->1, got {}",
1210 cycles.len()
1211 );
1212 }
1213
1214 #[test]
1219 fn enumerate_elementary_cycles_pentagon_with_chords() {
1220 let (modules, all_succs, succ_ranges) =
1228 build_test_succs(5, &[(0, 1), (1, 2), (2, 3), (3, 4), (4, 0), (0, 2), (0, 3)]);
1229 let succs = SuccessorMap {
1230 all_succs: &all_succs,
1231 succ_ranges: &succ_ranges,
1232 modules: &modules,
1233 };
1234 let scc_nodes: Vec<usize> = vec![0, 1, 2, 3, 4];
1235 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1236
1237 assert!(
1239 cycles.len() >= 3,
1240 "pentagon with chords should have at least 3 elementary cycles, got {}",
1241 cycles.len()
1242 );
1243 let unique: FxHashSet<Vec<usize>> = cycles.iter().cloned().collect();
1245 assert_eq!(
1246 unique.len(),
1247 cycles.len(),
1248 "all enumerated cycles should be unique"
1249 );
1250 assert_eq!(
1252 cycles[0].len(),
1253 3,
1254 "shortest cycle in pentagon with chords should be length 3"
1255 );
1256 }
1257
1258 #[test]
1259 fn find_cycles_large_scc_complete_graph_k6() {
1260 let edges: Vec<(u32, u32)> = (0..6)
1263 .flat_map(|i| (0..6).filter(move |&j| i != j).map(move |j| (i, j)))
1264 .collect();
1265 let graph = build_cycle_graph(6, &edges);
1266 let cycles = graph.find_cycles();
1267
1268 assert!(
1270 cycles.len() <= 20,
1271 "should cap at MAX_CYCLES_PER_SCC (20), got {}",
1272 cycles.len()
1273 );
1274 assert_eq!(
1275 cycles.len(),
1276 20,
1277 "K6 has far more than 20 elementary cycles, so we should hit the cap"
1278 );
1279 assert_eq!(cycles[0].len(), 2, "shortest cycles in K6 should be 2-node");
1281 }
1282
1283 #[test]
1288 fn enumerate_elementary_cycles_respects_depth_cap_of_12() {
1289 let edges: Vec<(usize, usize)> = (0..15).map(|i| (i, (i + 1) % 15)).collect();
1293 let (modules, all_succs, succ_ranges) = build_test_succs(15, &edges);
1294 let succs = SuccessorMap {
1295 all_succs: &all_succs,
1296 succ_ranges: &succ_ranges,
1297 modules: &modules,
1298 };
1299 let scc_nodes: Vec<usize> = (0..15).collect();
1300 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1301
1302 assert!(
1303 cycles.is_empty(),
1304 "a pure 15-node cycle should not be found with depth cap of 12, got {} cycles",
1305 cycles.len()
1306 );
1307 }
1308
1309 #[test]
1310 fn enumerate_elementary_cycles_finds_cycle_at_depth_cap_boundary() {
1311 let edges: Vec<(usize, usize)> = (0..12).map(|i| (i, (i + 1) % 12)).collect();
1314 let (modules, all_succs, succ_ranges) = build_test_succs(12, &edges);
1315 let succs = SuccessorMap {
1316 all_succs: &all_succs,
1317 succ_ranges: &succ_ranges,
1318 modules: &modules,
1319 };
1320 let scc_nodes: Vec<usize> = (0..12).collect();
1321 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1322
1323 assert_eq!(
1324 cycles.len(),
1325 1,
1326 "a pure 12-node cycle should be found at the depth cap boundary"
1327 );
1328 assert_eq!(cycles[0].len(), 12);
1329 }
1330
1331 #[test]
1332 fn enumerate_elementary_cycles_13_node_pure_cycle_not_found() {
1333 let edges: Vec<(usize, usize)> = (0..13).map(|i| (i, (i + 1) % 13)).collect();
1335 let (modules, all_succs, succ_ranges) = build_test_succs(13, &edges);
1336 let succs = SuccessorMap {
1337 all_succs: &all_succs,
1338 succ_ranges: &succ_ranges,
1339 modules: &modules,
1340 };
1341 let scc_nodes: Vec<usize> = (0..13).collect();
1342 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1343
1344 assert!(
1345 cycles.is_empty(),
1346 "13-node pure cycle exceeds depth cap of 12"
1347 );
1348 }
1349
1350 #[test]
1355 fn find_cycles_max_cycles_per_scc_enforced_on_k7() {
1356 let edges: Vec<(u32, u32)> = (0..7)
1359 .flat_map(|i| (0..7).filter(move |&j| i != j).map(move |j| (i, j)))
1360 .collect();
1361 let graph = build_cycle_graph(7, &edges);
1362 let cycles = graph.find_cycles();
1363
1364 assert!(
1365 cycles.len() <= 20,
1366 "K7 should cap at MAX_CYCLES_PER_SCC (20), got {}",
1367 cycles.len()
1368 );
1369 assert_eq!(
1370 cycles.len(),
1371 20,
1372 "K7 has far more than 20 elementary cycles, should hit the cap exactly"
1373 );
1374 }
1375
1376 #[test]
1377 fn find_cycles_two_dense_sccs_each_capped() {
1378 let mut edges: Vec<(u32, u32)> = Vec::new();
1381 for i in 0..4 {
1383 for j in 0..4 {
1384 if i != j {
1385 edges.push((i, j));
1386 }
1387 }
1388 }
1389 for i in 4..8 {
1391 for j in 4..8 {
1392 if i != j {
1393 edges.push((i, j));
1394 }
1395 }
1396 }
1397 let graph = build_cycle_graph(8, &edges);
1398 let cycles = graph.find_cycles();
1399
1400 assert!(!cycles.is_empty(), "two dense SCCs should produce cycles");
1403 assert!(
1407 cycles.len() > 2,
1408 "should find multiple cycles across both SCCs, got {}",
1409 cycles.len()
1410 );
1411 }
1412
1413 mod proptests {
1414 use super::*;
1415 use proptest::prelude::*;
1416
1417 proptest! {
1418 #[test]
1421 fn dag_has_no_cycles(
1422 file_count in 2..20usize,
1423 edge_pairs in prop::collection::vec((0..19u32, 0..19u32), 0..30),
1424 ) {
1425 let dag_edges: Vec<(u32, u32)> = edge_pairs
1427 .into_iter()
1428 .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a < b)
1429 .collect();
1430
1431 let graph = build_cycle_graph(file_count, &dag_edges);
1432 let cycles = graph.find_cycles();
1433 prop_assert!(
1434 cycles.is_empty(),
1435 "DAG should have no cycles, but found {}",
1436 cycles.len()
1437 );
1438 }
1439
1440 #[test]
1442 fn mutual_edges_always_detect_cycle(extra_nodes in 0..10usize) {
1443 let file_count = 2 + extra_nodes;
1444 let graph = build_cycle_graph(file_count, &[(0, 1), (1, 0)]);
1445 let cycles = graph.find_cycles();
1446 prop_assert!(
1447 !cycles.is_empty(),
1448 "A->B->A should always produce at least one cycle"
1449 );
1450 let has_pair_cycle = cycles.iter().any(|c| {
1452 c.contains(&FileId(0)) && c.contains(&FileId(1))
1453 });
1454 prop_assert!(has_pair_cycle, "Should find a cycle containing nodes 0 and 1");
1455 }
1456
1457 #[test]
1459 fn cycle_members_are_valid_indices(
1460 file_count in 2..15usize,
1461 edge_pairs in prop::collection::vec((0..14u32, 0..14u32), 1..20),
1462 ) {
1463 let edges: Vec<(u32, u32)> = edge_pairs
1464 .into_iter()
1465 .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a != b)
1466 .collect();
1467
1468 let graph = build_cycle_graph(file_count, &edges);
1469 let cycles = graph.find_cycles();
1470 for cycle in &cycles {
1471 prop_assert!(cycle.len() >= 2, "Cycles must have at least 2 nodes");
1472 for file_id in cycle {
1473 prop_assert!(
1474 (file_id.0 as usize) < file_count,
1475 "FileId {} exceeds file count {}",
1476 file_id.0, file_count
1477 );
1478 }
1479 }
1480 }
1481
1482 #[test]
1484 fn cycles_sorted_by_length(
1485 file_count in 3..12usize,
1486 edge_pairs in prop::collection::vec((0..11u32, 0..11u32), 2..25),
1487 ) {
1488 let edges: Vec<(u32, u32)> = edge_pairs
1489 .into_iter()
1490 .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a != b)
1491 .collect();
1492
1493 let graph = build_cycle_graph(file_count, &edges);
1494 let cycles = graph.find_cycles();
1495 for window in cycles.windows(2) {
1496 prop_assert!(
1497 window[0].len() <= window[1].len(),
1498 "Cycles should be sorted by length: {} > {}",
1499 window[0].len(), window[1].len()
1500 );
1501 }
1502 }
1503 }
1504 }
1505}