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