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 has_cjs_exports: false,
635 })
636 .collect();
637
638 let mut all_succs: Vec<usize> = Vec::new();
639 let mut succ_ranges: Vec<Range<usize>> = Vec::with_capacity(file_count);
640 for src in 0..file_count {
641 let start = all_succs.len();
642 let mut seen = FxHashSet::default();
643 for &(s, t) in edges_spec {
644 if s == src && t < file_count && seen.insert(t) {
645 all_succs.push(t);
646 }
647 }
648 let end = all_succs.len();
649 succ_ranges.push(start..end);
650 }
651
652 (modules, all_succs, succ_ranges)
653 }
654
655 #[test]
660 fn canonical_cycle_empty() {
661 let modules: Vec<ModuleNode> = vec![];
662 assert!(canonical_cycle(&[], &modules).is_empty());
663 }
664
665 #[test]
666 fn canonical_cycle_rotates_to_smallest_path() {
667 let (modules, _, _) = build_test_succs(3, &[]);
668 let result = canonical_cycle(&[2, 0, 1], &modules);
670 assert_eq!(result, vec![0, 1, 2]);
671 }
672
673 #[test]
674 fn canonical_cycle_already_canonical() {
675 let (modules, _, _) = build_test_succs(3, &[]);
676 let result = canonical_cycle(&[0, 1, 2], &modules);
677 assert_eq!(result, vec![0, 1, 2]);
678 }
679
680 #[test]
681 fn canonical_cycle_single_node() {
682 let (modules, _, _) = build_test_succs(1, &[]);
683 let result = canonical_cycle(&[0], &modules);
684 assert_eq!(result, vec![0]);
685 }
686
687 #[test]
692 fn try_record_cycle_inserts_new_cycle() {
693 let (modules, _, _) = build_test_succs(3, &[]);
694 let mut seen = FxHashSet::default();
695 let mut cycles = Vec::new();
696
697 try_record_cycle(&[0, 1, 2], &modules, &mut seen, &mut cycles);
698 assert_eq!(cycles.len(), 1);
699 assert_eq!(cycles[0], vec![0, 1, 2]);
700 }
701
702 #[test]
703 fn try_record_cycle_deduplicates_rotated_cycle() {
704 let (modules, _, _) = build_test_succs(3, &[]);
707 let mut seen = FxHashSet::default();
708 let mut cycles = Vec::new();
709
710 try_record_cycle(&[0, 1, 2], &modules, &mut seen, &mut cycles);
711 try_record_cycle(&[1, 2, 0], &modules, &mut seen, &mut cycles);
712 try_record_cycle(&[2, 0, 1], &modules, &mut seen, &mut cycles);
713
714 assert_eq!(
715 cycles.len(),
716 1,
717 "rotations of the same cycle should be deduped"
718 );
719 }
720
721 #[test]
722 fn try_record_cycle_single_node_self_loop() {
723 let (modules, _, _) = build_test_succs(1, &[]);
725 let mut seen = FxHashSet::default();
726 let mut cycles = Vec::new();
727
728 try_record_cycle(&[0], &modules, &mut seen, &mut cycles);
729 assert_eq!(cycles.len(), 1);
730 assert_eq!(cycles[0], vec![0]);
731 }
732
733 #[test]
734 fn try_record_cycle_distinct_cycles_both_recorded() {
735 let (modules, _, _) = build_test_succs(4, &[]);
737 let mut seen = FxHashSet::default();
738 let mut cycles = Vec::new();
739
740 try_record_cycle(&[0, 1], &modules, &mut seen, &mut cycles);
741 try_record_cycle(&[2, 3], &modules, &mut seen, &mut cycles);
742
743 assert_eq!(cycles.len(), 2);
744 }
745
746 #[test]
751 fn successor_map_empty_graph() {
752 let (modules, all_succs, succ_ranges) = build_test_succs(0, &[]);
753 let succs = SuccessorMap {
754 all_succs: &all_succs,
755 succ_ranges: &succ_ranges,
756 modules: &modules,
757 };
758 assert!(succs.all_succs.is_empty());
759 assert!(succs.succ_ranges.is_empty());
760 }
761
762 #[test]
763 fn successor_map_single_node_self_edge() {
764 let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
765 let succs = SuccessorMap {
766 all_succs: &all_succs,
767 succ_ranges: &succ_ranges,
768 modules: &modules,
769 };
770 assert_eq!(succs.all_succs.len(), 1);
771 assert_eq!(succs.all_succs[0], 0);
772 assert_eq!(succs.succ_ranges[0], 0..1);
773 }
774
775 #[test]
776 fn successor_map_deduplicates_edges() {
777 let (modules, all_succs, succ_ranges) = build_test_succs(2, &[(0, 1), (0, 1)]);
779 let succs = SuccessorMap {
780 all_succs: &all_succs,
781 succ_ranges: &succ_ranges,
782 modules: &modules,
783 };
784 let range = &succs.succ_ranges[0];
785 assert_eq!(
786 range.end - range.start,
787 1,
788 "duplicate edges should be deduped"
789 );
790 }
791
792 #[test]
793 fn successor_map_multiple_successors() {
794 let (modules, all_succs, succ_ranges) = build_test_succs(4, &[(0, 1), (0, 2), (0, 3)]);
795 let succs = SuccessorMap {
796 all_succs: &all_succs,
797 succ_ranges: &succ_ranges,
798 modules: &modules,
799 };
800 let range = &succs.succ_ranges[0];
801 assert_eq!(range.end - range.start, 3);
802 for i in 1..4 {
804 let r = &succs.succ_ranges[i];
805 assert_eq!(r.end - r.start, 0);
806 }
807 }
808
809 #[test]
814 fn dfs_find_cycles_from_isolated_node() {
815 let (modules, all_succs, succ_ranges) = build_test_succs(1, &[]);
817 let succs = SuccessorMap {
818 all_succs: &all_succs,
819 succ_ranges: &succ_ranges,
820 modules: &modules,
821 };
822 let scc_set: FxHashSet<usize> = std::iter::once(0).collect();
823 let mut seen = FxHashSet::default();
824 let mut cycles = Vec::new();
825
826 dfs_find_cycles_from(0, 2, &scc_set, &succs, 10, &mut seen, &mut cycles);
827 assert!(cycles.is_empty(), "isolated node should have no cycles");
828 }
829
830 #[test]
831 fn dfs_find_cycles_from_simple_two_cycle() {
832 let (modules, all_succs, succ_ranges) = build_test_succs(2, &[(0, 1), (1, 0)]);
834 let succs = SuccessorMap {
835 all_succs: &all_succs,
836 succ_ranges: &succ_ranges,
837 modules: &modules,
838 };
839 let scc_set: FxHashSet<usize> = [0, 1].into_iter().collect();
840 let mut seen = FxHashSet::default();
841 let mut cycles = Vec::new();
842
843 dfs_find_cycles_from(0, 2, &scc_set, &succs, 10, &mut seen, &mut cycles);
844 assert_eq!(cycles.len(), 1);
845 assert_eq!(cycles[0].len(), 2);
846 }
847
848 #[test]
849 fn dfs_find_cycles_from_diamond_graph() {
850 let (modules, all_succs, succ_ranges) =
854 build_test_succs(4, &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 0)]);
855 let succs = SuccessorMap {
856 all_succs: &all_succs,
857 succ_ranges: &succ_ranges,
858 modules: &modules,
859 };
860 let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
861 let mut seen = FxHashSet::default();
862 let mut cycles = Vec::new();
863
864 dfs_find_cycles_from(0, 3, &scc_set, &succs, 10, &mut seen, &mut cycles);
866 assert_eq!(cycles.len(), 2, "diamond should have two 3-node cycles");
867 assert!(cycles.iter().all(|c| c.len() == 3));
868 }
869
870 #[test]
871 fn dfs_find_cycles_from_depth_limit_prevents_longer_cycles() {
872 let (modules, all_succs, succ_ranges) =
875 build_test_succs(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
876 let succs = SuccessorMap {
877 all_succs: &all_succs,
878 succ_ranges: &succ_ranges,
879 modules: &modules,
880 };
881 let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
882 let mut seen = FxHashSet::default();
883 let mut cycles = Vec::new();
884
885 dfs_find_cycles_from(0, 3, &scc_set, &succs, 10, &mut seen, &mut cycles);
886 assert!(
887 cycles.is_empty(),
888 "depth_limit=3 should prevent finding a 4-node cycle"
889 );
890 }
891
892 #[test]
893 fn dfs_find_cycles_from_depth_limit_exact_match() {
894 let (modules, all_succs, succ_ranges) =
897 build_test_succs(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
898 let succs = SuccessorMap {
899 all_succs: &all_succs,
900 succ_ranges: &succ_ranges,
901 modules: &modules,
902 };
903 let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
904 let mut seen = FxHashSet::default();
905 let mut cycles = Vec::new();
906
907 dfs_find_cycles_from(0, 4, &scc_set, &succs, 10, &mut seen, &mut cycles);
908 assert_eq!(
909 cycles.len(),
910 1,
911 "depth_limit=4 should find the 4-node cycle"
912 );
913 assert_eq!(cycles[0].len(), 4);
914 }
915
916 #[test]
917 fn dfs_find_cycles_from_respects_max_cycles() {
918 let edges: Vec<(usize, usize)> = (0..4)
920 .flat_map(|i| (0..4).filter(move |&j| i != j).map(move |j| (i, j)))
921 .collect();
922 let (modules, all_succs, succ_ranges) = build_test_succs(4, &edges);
923 let succs = SuccessorMap {
924 all_succs: &all_succs,
925 succ_ranges: &succ_ranges,
926 modules: &modules,
927 };
928 let scc_set: FxHashSet<usize> = (0..4).collect();
929 let mut seen = FxHashSet::default();
930 let mut cycles = Vec::new();
931
932 dfs_find_cycles_from(0, 2, &scc_set, &succs, 2, &mut seen, &mut cycles);
934 assert!(
935 cycles.len() <= 2,
936 "should respect max_cycles limit, got {}",
937 cycles.len()
938 );
939 }
940
941 #[test]
942 fn dfs_find_cycles_from_ignores_nodes_outside_scc() {
943 let (modules, all_succs, succ_ranges) = build_test_succs(3, &[(0, 1), (1, 2), (2, 0)]);
945 let succs = SuccessorMap {
946 all_succs: &all_succs,
947 succ_ranges: &succ_ranges,
948 modules: &modules,
949 };
950 let scc_set: FxHashSet<usize> = [0, 1].into_iter().collect();
951 let mut seen = FxHashSet::default();
952 let mut cycles = Vec::new();
953
954 for depth in 2..=3 {
955 dfs_find_cycles_from(0, depth, &scc_set, &succs, 10, &mut seen, &mut cycles);
956 }
957 assert!(
958 cycles.is_empty(),
959 "should not find cycles through nodes outside the SCC set"
960 );
961 }
962
963 #[test]
968 fn enumerate_elementary_cycles_empty_scc() {
969 let (modules, all_succs, succ_ranges) = build_test_succs(0, &[]);
970 let succs = SuccessorMap {
971 all_succs: &all_succs,
972 succ_ranges: &succ_ranges,
973 modules: &modules,
974 };
975 let cycles = enumerate_elementary_cycles(&[], &succs, 10);
976 assert!(cycles.is_empty());
977 }
978
979 #[test]
980 fn enumerate_elementary_cycles_max_cycles_limit() {
981 let edges: Vec<(usize, usize)> = (0..4)
983 .flat_map(|i| (0..4).filter(move |&j| i != j).map(move |j| (i, j)))
984 .collect();
985 let (modules, all_succs, succ_ranges) = build_test_succs(4, &edges);
986 let succs = SuccessorMap {
987 all_succs: &all_succs,
988 succ_ranges: &succ_ranges,
989 modules: &modules,
990 };
991 let scc_nodes: Vec<usize> = (0..4).collect();
992
993 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 3);
994 assert!(
995 cycles.len() <= 3,
996 "should respect max_cycles=3 limit, got {}",
997 cycles.len()
998 );
999 }
1000
1001 #[test]
1002 fn enumerate_elementary_cycles_finds_all_in_triangle() {
1003 let (modules, all_succs, succ_ranges) = build_test_succs(3, &[(0, 1), (1, 2), (2, 0)]);
1005 let succs = SuccessorMap {
1006 all_succs: &all_succs,
1007 succ_ranges: &succ_ranges,
1008 modules: &modules,
1009 };
1010 let scc_nodes: Vec<usize> = vec![0, 1, 2];
1011
1012 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1013 assert_eq!(cycles.len(), 1);
1014 assert_eq!(cycles[0].len(), 3);
1015 }
1016
1017 #[test]
1018 fn enumerate_elementary_cycles_iterative_deepening_order() {
1019 let (modules, all_succs, succ_ranges) =
1022 build_test_succs(3, &[(0, 1), (1, 0), (1, 2), (2, 0)]);
1023 let succs = SuccessorMap {
1024 all_succs: &all_succs,
1025 succ_ranges: &succ_ranges,
1026 modules: &modules,
1027 };
1028 let scc_nodes: Vec<usize> = vec![0, 1, 2];
1029
1030 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1031 assert!(cycles.len() >= 2, "should find at least 2 cycles");
1032 assert!(
1034 cycles[0].len() <= cycles[cycles.len() - 1].len(),
1035 "shorter cycles should be found before longer ones"
1036 );
1037 }
1038
1039 #[test]
1044 fn find_cycles_max_cycles_per_scc_respected() {
1045 let edges: Vec<(u32, u32)> = (0..5)
1047 .flat_map(|i| (0..5).filter(move |&j| i != j).map(move |j| (i, j)))
1048 .collect();
1049 let graph = build_cycle_graph(5, &edges);
1050 let cycles = graph.find_cycles();
1051 assert!(
1053 cycles.len() <= 20,
1054 "should cap at MAX_CYCLES_PER_SCC, got {}",
1055 cycles.len()
1056 );
1057 assert!(
1058 !cycles.is_empty(),
1059 "dense graph should still find some cycles"
1060 );
1061 }
1062
1063 #[test]
1064 fn find_cycles_graph_with_no_cycles_returns_empty() {
1065 let graph = build_cycle_graph(5, &[(0, 1), (0, 2), (0, 3), (0, 4)]);
1067 assert!(graph.find_cycles().is_empty());
1068 }
1069
1070 #[test]
1071 fn find_cycles_diamond_no_cycle() {
1072 let graph = build_cycle_graph(4, &[(0, 1), (0, 2), (1, 3), (2, 3)]);
1074 assert!(graph.find_cycles().is_empty());
1075 }
1076
1077 #[test]
1078 fn find_cycles_diamond_with_back_edge() {
1079 let graph = build_cycle_graph(4, &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 0)]);
1081 let cycles = graph.find_cycles();
1082 assert!(
1083 cycles.len() >= 2,
1084 "diamond with back-edge should have at least 2 elementary cycles, got {}",
1085 cycles.len()
1086 );
1087 assert_eq!(cycles[0].len(), 3);
1089 }
1090
1091 #[test]
1096 fn canonical_cycle_non_sequential_indices() {
1097 let (modules, _, _) = build_test_succs(5, &[]);
1099 let result = canonical_cycle(&[3, 1, 4], &modules);
1100 assert_eq!(result, vec![1, 4, 3]);
1102 }
1103
1104 #[test]
1105 fn canonical_cycle_different_starting_points_same_result() {
1106 let (modules, _, _) = build_test_succs(4, &[]);
1109 let r1 = canonical_cycle(&[0, 1, 2, 3], &modules);
1110 let r2 = canonical_cycle(&[1, 2, 3, 0], &modules);
1111 let r3 = canonical_cycle(&[2, 3, 0, 1], &modules);
1112 let r4 = canonical_cycle(&[3, 0, 1, 2], &modules);
1113 assert_eq!(r1, r2);
1114 assert_eq!(r2, r3);
1115 assert_eq!(r3, r4);
1116 assert_eq!(r1, vec![0, 1, 2, 3]);
1117 }
1118
1119 #[test]
1120 fn canonical_cycle_two_node_both_rotations() {
1121 let (modules, _, _) = build_test_succs(2, &[]);
1123 assert_eq!(canonical_cycle(&[0, 1], &modules), vec![0, 1]);
1124 assert_eq!(canonical_cycle(&[1, 0], &modules), vec![0, 1]);
1125 }
1126
1127 #[test]
1132 fn dfs_find_cycles_from_self_loop_not_found() {
1133 let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
1136 let succs = SuccessorMap {
1137 all_succs: &all_succs,
1138 succ_ranges: &succ_ranges,
1139 modules: &modules,
1140 };
1141 let scc_set: FxHashSet<usize> = std::iter::once(0).collect();
1142 let mut seen = FxHashSet::default();
1143 let mut cycles = Vec::new();
1144
1145 for depth in 1..=3 {
1146 dfs_find_cycles_from(0, depth, &scc_set, &succs, 10, &mut seen, &mut cycles);
1147 }
1148 assert!(
1149 cycles.is_empty(),
1150 "self-loop should not be detected as a cycle by dfs_find_cycles_from"
1151 );
1152 }
1153
1154 #[test]
1155 fn enumerate_elementary_cycles_self_loop_not_found() {
1156 let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
1158 let succs = SuccessorMap {
1159 all_succs: &all_succs,
1160 succ_ranges: &succ_ranges,
1161 modules: &modules,
1162 };
1163 let cycles = enumerate_elementary_cycles(&[0], &succs, 20);
1164 assert!(
1165 cycles.is_empty(),
1166 "self-loop should not produce elementary cycles"
1167 );
1168 }
1169
1170 #[test]
1175 fn find_cycles_two_cycles_sharing_edge() {
1176 let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 0), (1, 3), (3, 0)]);
1179 let cycles = graph.find_cycles();
1180 assert_eq!(
1181 cycles.len(),
1182 2,
1183 "two cycles sharing edge A->B should both be found, got {}",
1184 cycles.len()
1185 );
1186 assert!(
1187 cycles.iter().all(|c| c.len() == 3),
1188 "both cycles should have length 3"
1189 );
1190 }
1191
1192 #[test]
1193 fn enumerate_elementary_cycles_shared_edge() {
1194 let (modules, all_succs, succ_ranges) =
1196 build_test_succs(4, &[(0, 1), (1, 2), (2, 0), (1, 3), (3, 0)]);
1197 let succs = SuccessorMap {
1198 all_succs: &all_succs,
1199 succ_ranges: &succ_ranges,
1200 modules: &modules,
1201 };
1202 let scc_nodes: Vec<usize> = vec![0, 1, 2, 3];
1203 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1204 assert_eq!(
1205 cycles.len(),
1206 2,
1207 "should find exactly 2 elementary cycles sharing edge 0->1, got {}",
1208 cycles.len()
1209 );
1210 }
1211
1212 #[test]
1217 fn enumerate_elementary_cycles_pentagon_with_chords() {
1218 let (modules, all_succs, succ_ranges) =
1226 build_test_succs(5, &[(0, 1), (1, 2), (2, 3), (3, 4), (4, 0), (0, 2), (0, 3)]);
1227 let succs = SuccessorMap {
1228 all_succs: &all_succs,
1229 succ_ranges: &succ_ranges,
1230 modules: &modules,
1231 };
1232 let scc_nodes: Vec<usize> = vec![0, 1, 2, 3, 4];
1233 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1234
1235 assert!(
1237 cycles.len() >= 3,
1238 "pentagon with chords should have at least 3 elementary cycles, got {}",
1239 cycles.len()
1240 );
1241 let unique: FxHashSet<Vec<usize>> = cycles.iter().cloned().collect();
1243 assert_eq!(
1244 unique.len(),
1245 cycles.len(),
1246 "all enumerated cycles should be unique"
1247 );
1248 assert_eq!(
1250 cycles[0].len(),
1251 3,
1252 "shortest cycle in pentagon with chords should be length 3"
1253 );
1254 }
1255
1256 #[test]
1257 fn find_cycles_large_scc_complete_graph_k6() {
1258 let edges: Vec<(u32, u32)> = (0..6)
1261 .flat_map(|i| (0..6).filter(move |&j| i != j).map(move |j| (i, j)))
1262 .collect();
1263 let graph = build_cycle_graph(6, &edges);
1264 let cycles = graph.find_cycles();
1265
1266 assert!(
1268 cycles.len() <= 20,
1269 "should cap at MAX_CYCLES_PER_SCC (20), got {}",
1270 cycles.len()
1271 );
1272 assert_eq!(
1273 cycles.len(),
1274 20,
1275 "K6 has far more than 20 elementary cycles, so we should hit the cap"
1276 );
1277 assert_eq!(cycles[0].len(), 2, "shortest cycles in K6 should be 2-node");
1279 }
1280
1281 #[test]
1286 fn enumerate_elementary_cycles_respects_depth_cap_of_12() {
1287 let edges: Vec<(usize, usize)> = (0..15).map(|i| (i, (i + 1) % 15)).collect();
1291 let (modules, all_succs, succ_ranges) = build_test_succs(15, &edges);
1292 let succs = SuccessorMap {
1293 all_succs: &all_succs,
1294 succ_ranges: &succ_ranges,
1295 modules: &modules,
1296 };
1297 let scc_nodes: Vec<usize> = (0..15).collect();
1298 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1299
1300 assert!(
1301 cycles.is_empty(),
1302 "a pure 15-node cycle should not be found with depth cap of 12, got {} cycles",
1303 cycles.len()
1304 );
1305 }
1306
1307 #[test]
1308 fn enumerate_elementary_cycles_finds_cycle_at_depth_cap_boundary() {
1309 let edges: Vec<(usize, usize)> = (0..12).map(|i| (i, (i + 1) % 12)).collect();
1312 let (modules, all_succs, succ_ranges) = build_test_succs(12, &edges);
1313 let succs = SuccessorMap {
1314 all_succs: &all_succs,
1315 succ_ranges: &succ_ranges,
1316 modules: &modules,
1317 };
1318 let scc_nodes: Vec<usize> = (0..12).collect();
1319 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1320
1321 assert_eq!(
1322 cycles.len(),
1323 1,
1324 "a pure 12-node cycle should be found at the depth cap boundary"
1325 );
1326 assert_eq!(cycles[0].len(), 12);
1327 }
1328
1329 #[test]
1330 fn enumerate_elementary_cycles_13_node_pure_cycle_not_found() {
1331 let edges: Vec<(usize, usize)> = (0..13).map(|i| (i, (i + 1) % 13)).collect();
1333 let (modules, all_succs, succ_ranges) = build_test_succs(13, &edges);
1334 let succs = SuccessorMap {
1335 all_succs: &all_succs,
1336 succ_ranges: &succ_ranges,
1337 modules: &modules,
1338 };
1339 let scc_nodes: Vec<usize> = (0..13).collect();
1340 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1341
1342 assert!(
1343 cycles.is_empty(),
1344 "13-node pure cycle exceeds depth cap of 12"
1345 );
1346 }
1347
1348 #[test]
1353 fn find_cycles_max_cycles_per_scc_enforced_on_k7() {
1354 let edges: Vec<(u32, u32)> = (0..7)
1357 .flat_map(|i| (0..7).filter(move |&j| i != j).map(move |j| (i, j)))
1358 .collect();
1359 let graph = build_cycle_graph(7, &edges);
1360 let cycles = graph.find_cycles();
1361
1362 assert!(
1363 cycles.len() <= 20,
1364 "K7 should cap at MAX_CYCLES_PER_SCC (20), got {}",
1365 cycles.len()
1366 );
1367 assert_eq!(
1368 cycles.len(),
1369 20,
1370 "K7 has far more than 20 elementary cycles, should hit the cap exactly"
1371 );
1372 }
1373
1374 #[test]
1375 fn find_cycles_two_dense_sccs_each_capped() {
1376 let mut edges: Vec<(u32, u32)> = Vec::new();
1379 for i in 0..4 {
1381 for j in 0..4 {
1382 if i != j {
1383 edges.push((i, j));
1384 }
1385 }
1386 }
1387 for i in 4..8 {
1389 for j in 4..8 {
1390 if i != j {
1391 edges.push((i, j));
1392 }
1393 }
1394 }
1395 let graph = build_cycle_graph(8, &edges);
1396 let cycles = graph.find_cycles();
1397
1398 assert!(!cycles.is_empty(), "two dense SCCs should produce cycles");
1401 assert!(
1405 cycles.len() > 2,
1406 "should find multiple cycles across both SCCs, got {}",
1407 cycles.len()
1408 );
1409 }
1410
1411 mod proptests {
1412 use super::*;
1413 use proptest::prelude::*;
1414
1415 proptest! {
1416 #[test]
1419 fn dag_has_no_cycles(
1420 file_count in 2..20usize,
1421 edge_pairs in prop::collection::vec((0..19u32, 0..19u32), 0..30),
1422 ) {
1423 let dag_edges: Vec<(u32, u32)> = edge_pairs
1425 .into_iter()
1426 .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a < b)
1427 .collect();
1428
1429 let graph = build_cycle_graph(file_count, &dag_edges);
1430 let cycles = graph.find_cycles();
1431 prop_assert!(
1432 cycles.is_empty(),
1433 "DAG should have no cycles, but found {}",
1434 cycles.len()
1435 );
1436 }
1437
1438 #[test]
1440 fn mutual_edges_always_detect_cycle(extra_nodes in 0..10usize) {
1441 let file_count = 2 + extra_nodes;
1442 let graph = build_cycle_graph(file_count, &[(0, 1), (1, 0)]);
1443 let cycles = graph.find_cycles();
1444 prop_assert!(
1445 !cycles.is_empty(),
1446 "A->B->A should always produce at least one cycle"
1447 );
1448 let has_pair_cycle = cycles.iter().any(|c| {
1450 c.contains(&FileId(0)) && c.contains(&FileId(1))
1451 });
1452 prop_assert!(has_pair_cycle, "Should find a cycle containing nodes 0 and 1");
1453 }
1454
1455 #[test]
1457 fn cycle_members_are_valid_indices(
1458 file_count in 2..15usize,
1459 edge_pairs in prop::collection::vec((0..14u32, 0..14u32), 1..20),
1460 ) {
1461 let edges: Vec<(u32, u32)> = edge_pairs
1462 .into_iter()
1463 .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a != b)
1464 .collect();
1465
1466 let graph = build_cycle_graph(file_count, &edges);
1467 let cycles = graph.find_cycles();
1468 for cycle in &cycles {
1469 prop_assert!(cycle.len() >= 2, "Cycles must have at least 2 nodes");
1470 for file_id in cycle {
1471 prop_assert!(
1472 (file_id.0 as usize) < file_count,
1473 "FileId {} exceeds file count {}",
1474 file_id.0, file_count
1475 );
1476 }
1477 }
1478 }
1479
1480 #[test]
1482 fn cycles_sorted_by_length(
1483 file_count in 3..12usize,
1484 edge_pairs in prop::collection::vec((0..11u32, 0..11u32), 2..25),
1485 ) {
1486 let edges: Vec<(u32, u32)> = edge_pairs
1487 .into_iter()
1488 .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a != b)
1489 .collect();
1490
1491 let graph = build_cycle_graph(file_count, &edges);
1492 let cycles = graph.find_cycles();
1493 for window in cycles.windows(2) {
1494 prop_assert!(
1495 window[0].len() <= window[1].len(),
1496 "Cycles should be sorted by length: {} > {}",
1497 window[0].len(), window[1].len()
1498 );
1499 }
1500 }
1501 }
1502 }
1503}