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