Skip to main content

fallow_graph/graph/
cycles.rs

1//! Circular dependency detection via Tarjan's SCC algorithm + elementary cycle enumeration.
2
3use 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    /// Find all circular dependency cycles in the module graph.
15    ///
16    /// Uses an iterative implementation of Tarjan's strongly connected components
17    /// algorithm (O(V + E)) to find all SCCs with 2 or more nodes. Each such SCC
18    /// represents a set of files involved in a circular dependency.
19    ///
20    /// Returns cycles sorted by length (shortest first), with files within each
21    /// cycle sorted by path for deterministic output.
22    ///
23    /// # Panics
24    ///
25    /// Panics if the internal file-to-path lookup is inconsistent with the module list.
26    #[must_use]
27    pub fn find_cycles(&self) -> Vec<Vec<FileId>> {
28        let n = self.modules.len();
29        if n == 0 {
30            return Vec::new();
31        }
32
33        let (all_succs, succ_ranges) = self.build_runtime_successors(n);
34
35        let mut state = SccState::new(n);
36        for start_node in 0..n {
37            if state.indices[start_node] != u32::MAX {
38                continue;
39            }
40            state.run_dfs_from(start_node, &all_succs, &succ_ranges);
41        }
42
43        self.enumerate_cycles_from_sccs(&state.sccs, &all_succs, &succ_ranges)
44    }
45
46    /// Build the flattened runtime-successor adjacency (type-only edges and
47    /// duplicate targets excluded) plus the per-node range index into it.
48    fn build_runtime_successors(&self, n: usize) -> (Vec<usize>, Vec<Range<usize>>) {
49        let mut all_succs: Vec<usize> = Vec::with_capacity(self.edges.len());
50        let mut succ_ranges: Vec<Range<usize>> = Vec::with_capacity(n);
51        let mut seen_set = FxHashSet::default();
52        for module in &self.modules {
53            let start = all_succs.len();
54            seen_set.clear();
55            for edge in &self.edges[module.edge_range.clone()] {
56                if edge.symbols.iter().all(|s| s.is_type_only) {
57                    continue;
58                }
59                let target = edge.target.0 as usize;
60                if target < n && seen_set.insert(target) {
61                    all_succs.push(target);
62                }
63            }
64            let end = all_succs.len();
65            succ_ranges.push(start..end);
66        }
67        (all_succs, succ_ranges)
68    }
69
70    /// Enumerate individual elementary cycles from SCCs and return sorted results.
71    #[expect(
72        clippy::cast_possible_truncation,
73        reason = "file count is bounded by project size, well under u32::MAX"
74    )]
75    fn enumerate_cycles_from_sccs(
76        &self,
77        sccs: &[Vec<FileId>],
78        all_succs: &[usize],
79        succ_ranges: &[Range<usize>],
80    ) -> Vec<Vec<FileId>> {
81        const MAX_CYCLES_PER_SCC: usize = 20;
82
83        let succs = SuccessorMap {
84            all_succs,
85            succ_ranges,
86            modules: &self.modules,
87        };
88
89        let mut result: Vec<Vec<FileId>> = Vec::new();
90        let mut seen_cycles: FxHashSet<Vec<u32>> = FxHashSet::default();
91
92        for scc in sccs {
93            if scc.len() == 2 {
94                let mut cycle = vec![scc[0].0 as usize, scc[1].0 as usize];
95                if self.modules[cycle[1]].path < self.modules[cycle[0]].path {
96                    cycle.swap(0, 1);
97                }
98                let key: Vec<u32> = cycle.iter().map(|&n| n as u32).collect();
99                if seen_cycles.insert(key) {
100                    result.push(cycle.into_iter().map(|n| FileId(n as u32)).collect());
101                }
102                continue;
103            }
104
105            let scc_nodes: Vec<usize> = scc.iter().map(|id| id.0 as usize).collect();
106            let elementary = enumerate_elementary_cycles(&scc_nodes, &succs, MAX_CYCLES_PER_SCC);
107
108            for cycle in elementary {
109                let key: Vec<u32> = cycle.iter().map(|&n| n as u32).collect();
110                if seen_cycles.insert(key) {
111                    result.push(cycle.into_iter().map(|n| FileId(n as u32)).collect());
112                }
113            }
114        }
115
116        result.sort_by(|a, b| {
117            a.len().cmp(&b.len()).then_with(|| {
118                self.modules[a[0].0 as usize]
119                    .path
120                    .cmp(&self.modules[b[0].0 as usize].path)
121            })
122        });
123
124        result
125    }
126}
127
128/// One iterative-DFS frame for the Tarjan SCC pass over runtime successors.
129struct SccFrame {
130    node: usize,
131    succ_pos: usize,
132    succ_end: usize,
133}
134
135/// Mutable Tarjan SCC state for `find_cycles`, collecting SCCs of size >= 2.
136struct SccState {
137    index_counter: u32,
138    indices: Vec<u32>,
139    lowlinks: Vec<u32>,
140    on_stack: FixedBitSet,
141    stack: Vec<usize>,
142    sccs: Vec<Vec<FileId>>,
143}
144
145impl SccState {
146    fn new(n: usize) -> Self {
147        Self {
148            index_counter: 0,
149            indices: vec![u32::MAX; n],
150            lowlinks: vec![0; n],
151            on_stack: FixedBitSet::with_capacity(n),
152            stack: Vec::new(),
153            sccs: Vec::new(),
154        }
155    }
156
157    /// Assign the next DFS index to `node` and push it onto the SCC stack.
158    fn discover(&mut self, node: usize) {
159        self.indices[node] = self.index_counter;
160        self.lowlinks[node] = self.index_counter;
161        self.index_counter += 1;
162        self.on_stack.insert(node);
163        self.stack.push(node);
164    }
165
166    /// Build a frame spanning the successor range of `node`.
167    fn frame_for(node: usize, succ_ranges: &[Range<usize>]) -> SccFrame {
168        let range = &succ_ranges[node];
169        SccFrame {
170            node,
171            succ_pos: range.start,
172            succ_end: range.end,
173        }
174    }
175
176    /// Run the iterative Tarjan DFS rooted at `start`, appending discovered
177    /// SCCs of size >= 2 to `self.sccs`.
178    fn run_dfs_from(&mut self, start: usize, all_succs: &[usize], succ_ranges: &[Range<usize>]) {
179        self.discover(start);
180        let mut dfs_stack: Vec<SccFrame> = vec![Self::frame_for(start, succ_ranges)];
181
182        while let Some(frame) = dfs_stack.last_mut() {
183            if frame.succ_pos < frame.succ_end {
184                if let Some(child) = self.advance_frame(frame, all_succs) {
185                    dfs_stack.push(Self::frame_for(child, succ_ranges));
186                }
187            } else {
188                let v = frame.node;
189                let v_lowlink = self.lowlinks[v];
190                dfs_stack.pop();
191                if let Some(parent) = dfs_stack.last() {
192                    let pv = parent.node;
193                    self.lowlinks[pv] = self.lowlinks[pv].min(v_lowlink);
194                }
195                self.collect_root_scc(v);
196            }
197        }
198    }
199
200    /// Advance one successor of `frame`, discovering a new child (returned for
201    /// descent) or updating the lowlink for an on-stack back edge.
202    fn advance_frame(&mut self, frame: &mut SccFrame, all_succs: &[usize]) -> Option<usize> {
203        let w = all_succs[frame.succ_pos];
204        frame.succ_pos += 1;
205        if self.indices[w] == u32::MAX {
206            self.discover(w);
207            Some(w)
208        } else {
209            if self.on_stack.contains(w) {
210                let v = frame.node;
211                self.lowlinks[v] = self.lowlinks[v].min(self.indices[w]);
212            }
213            None
214        }
215    }
216
217    /// When `v` is an SCC root, pop its members off the stack and record the
218    /// SCC if it has at least two nodes.
219    #[expect(
220        clippy::cast_possible_truncation,
221        reason = "file count is bounded by project size, well under u32::MAX"
222    )]
223    #[expect(
224        clippy::expect_used,
225        reason = "Tarjan traversal only pops nodes that were pushed onto the SCC stack"
226    )]
227    fn collect_root_scc(&mut self, v: usize) {
228        if self.lowlinks[v] != self.indices[v] {
229            return;
230        }
231        let mut scc = Vec::new();
232        loop {
233            let w = self.stack.pop().expect("SCC stack should not be empty");
234            self.on_stack.set(w, false);
235            scc.push(FileId(w as u32));
236            if w == v {
237                break;
238            }
239        }
240        if scc.len() >= 2 {
241            self.sccs.push(scc);
242        }
243    }
244}
245
246/// Rotate a cycle so the node with the smallest path is first (canonical form for dedup).
247fn canonical_cycle(cycle: &[usize], modules: &[ModuleNode]) -> Vec<usize> {
248    if cycle.is_empty() {
249        return Vec::new();
250    }
251    let min_pos = cycle
252        .iter()
253        .enumerate()
254        .min_by(|(_, a), (_, b)| modules[**a].path.cmp(&modules[**b].path))
255        .map_or(0, |(i, _)| i);
256    let mut result = cycle[min_pos..].to_vec();
257    result.extend_from_slice(&cycle[..min_pos]);
258    result
259}
260
261struct CycleFrame {
262    succ_pos: usize,
263    succ_end: usize,
264}
265
266struct SuccessorMap<'a> {
267    all_succs: &'a [usize],
268    succ_ranges: &'a [Range<usize>],
269    modules: &'a [ModuleNode],
270}
271
272#[expect(
273    clippy::cast_possible_truncation,
274    reason = "file count is bounded by project size, well under u32::MAX"
275)]
276fn try_record_cycle(
277    path: &[usize],
278    modules: &[ModuleNode],
279    seen: &mut FxHashSet<Vec<u32>>,
280    cycles: &mut Vec<Vec<usize>>,
281) {
282    let canonical = canonical_cycle(path, modules);
283    let key: Vec<u32> = canonical.iter().map(|&n| n as u32).collect();
284    if seen.insert(key) {
285        cycles.push(canonical);
286    }
287}
288
289/// Run a bounded DFS from `start`, looking for elementary cycles of exactly `depth_limit` nodes.
290///
291/// Appends any newly found cycles to `cycles` (deduped via `seen`).
292/// Stops early once `cycles.len() >= max_cycles`.
293struct DfsCycleInput<'a> {
294    start: usize,
295    depth_limit: usize,
296    scc_set: &'a FxHashSet<usize>,
297    succs: &'a SuccessorMap<'a>,
298    max_cycles: usize,
299    seen: &'a mut FxHashSet<Vec<u32>>,
300    cycles: &'a mut Vec<Vec<usize>>,
301}
302
303fn dfs_find_cycles_from(input: &mut DfsCycleInput<'_>) {
304    let mut path: Vec<usize> = vec![input.start];
305    let mut path_set = FixedBitSet::with_capacity(input.succs.modules.len());
306    path_set.insert(input.start);
307
308    let range = &input.succs.succ_ranges[input.start];
309    let mut dfs: Vec<CycleFrame> = vec![CycleFrame {
310        succ_pos: range.start,
311        succ_end: range.end,
312    }];
313
314    while let Some(frame) = dfs.last_mut() {
315        if input.cycles.len() >= input.max_cycles {
316            return;
317        }
318
319        if frame.succ_pos >= frame.succ_end {
320            dfs.pop();
321            if path.len() > 1 {
322                let Some(removed) = path.pop() else {
323                    continue;
324                };
325                path_set.set(removed, false);
326            }
327            continue;
328        }
329
330        let w = input.succs.all_succs[frame.succ_pos];
331        frame.succ_pos += 1;
332
333        if !input.scc_set.contains(&w) {
334            continue;
335        }
336
337        if w == input.start && path.len() >= 2 && path.len() == input.depth_limit {
338            try_record_cycle(&path, input.succs.modules, input.seen, input.cycles);
339            continue;
340        }
341
342        if path_set.contains(w) || path.len() >= input.depth_limit {
343            continue;
344        }
345
346        path.push(w);
347        path_set.insert(w);
348
349        let range = &input.succs.succ_ranges[w];
350        dfs.push(CycleFrame {
351            succ_pos: range.start,
352            succ_end: range.end,
353        });
354    }
355}
356
357/// Enumerate individual elementary cycles within an SCC using depth-limited DFS.
358///
359/// Uses iterative deepening: first finds all 2-node cycles, then 3-node, etc.
360/// This ensures the shortest, most actionable cycles are always found first.
361/// Stops after `max_cycles` total cycles to bound work on dense SCCs.
362fn enumerate_elementary_cycles(
363    scc_nodes: &[usize],
364    succs: &SuccessorMap<'_>,
365    max_cycles: usize,
366) -> Vec<Vec<usize>> {
367    let scc_set: FxHashSet<usize> = scc_nodes.iter().copied().collect();
368    let mut cycles: Vec<Vec<usize>> = Vec::new();
369    let mut seen: FxHashSet<Vec<u32>> = FxHashSet::default();
370
371    let mut sorted_nodes: Vec<usize> = scc_nodes.to_vec();
372    sorted_nodes.sort_by(|a, b| succs.modules[*a].path.cmp(&succs.modules[*b].path));
373
374    let max_depth = scc_nodes.len().min(12); // Cap depth to avoid very long cycles
375    for depth_limit in 2..=max_depth {
376        if cycles.len() >= max_cycles {
377            break;
378        }
379
380        for &start in &sorted_nodes {
381            if cycles.len() >= max_cycles {
382                break;
383            }
384
385            dfs_find_cycles_from(&mut DfsCycleInput {
386                start,
387                depth_limit,
388                scc_set: &scc_set,
389                succs,
390                max_cycles,
391                seen: &mut seen,
392                cycles: &mut cycles,
393            });
394        }
395    }
396
397    cycles
398}
399
400#[cfg(test)]
401mod tests {
402    use std::ops::Range;
403    use std::path::PathBuf;
404
405    use rustc_hash::FxHashSet;
406
407    use crate::graph::types::ModuleNode;
408    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
409    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
410    use fallow_types::extract::{ExportName, ImportInfo, ImportedName, VisibilityTag};
411
412    use super::{
413        DfsCycleInput, ModuleGraph, SuccessorMap, canonical_cycle, dfs_find_cycles_from,
414        enumerate_elementary_cycles, try_record_cycle,
415    };
416
417    /// Helper: build a graph from files+edges, no entry points needed for cycle detection.
418    #[expect(
419        clippy::cast_possible_truncation,
420        reason = "test file counts are trivially small"
421    )]
422    fn build_cycle_graph(file_count: usize, edges_spec: &[(u32, u32)]) -> ModuleGraph {
423        let files: Vec<DiscoveredFile> = (0..file_count)
424            .map(|i| DiscoveredFile {
425                id: FileId(i as u32),
426                path: PathBuf::from(format!("/project/file{i}.ts")),
427                size_bytes: 100,
428            })
429            .collect();
430
431        let resolved_modules: Vec<ResolvedModule> = (0..file_count)
432            .map(|i| {
433                let imports: Vec<ResolvedImport> = edges_spec
434                    .iter()
435                    .filter(|(src, _)| *src == i as u32)
436                    .map(|(_, tgt)| ResolvedImport {
437                        info: ImportInfo {
438                            source: format!("./file{tgt}"),
439                            imported_name: ImportedName::Named("x".to_string()),
440                            local_name: "x".to_string(),
441                            is_type_only: false,
442                            is_type_only_star: false,
443                            from_style: false,
444                            span: oxc_span::Span::new(0, 10),
445                            source_span: oxc_span::Span::default(),
446                        },
447                        target: ResolveResult::InternalModule(FileId(*tgt)),
448                    })
449                    .collect();
450
451                ResolvedModule {
452                    file_id: FileId(i as u32),
453                    path: PathBuf::from(format!("/project/file{i}.ts")),
454                    exports: vec![fallow_types::extract::ExportInfo {
455                        name: ExportName::Named("x".to_string()),
456                        local_name: Some("x".to_string()),
457                        is_type_only: false,
458                        visibility: VisibilityTag::None,
459                        expected_unused_reason: None,
460                        span: oxc_span::Span::new(0, 20),
461                        members: vec![],
462                        is_side_effect_used: false,
463                        super_class: None,
464                        deprecated: false,
465                        deprecated_reason: None,
466                    }]
467                    .into(),
468                    re_exports: vec![],
469                    resolved_imports: imports,
470                    resolved_dynamic_imports: vec![],
471                    resolved_dynamic_patterns: vec![],
472                    member_accesses: vec![].into(),
473                    semantic_facts: std::sync::Arc::default(),
474                    whole_object_uses: std::sync::Arc::default(),
475                    has_cjs_exports: false,
476                    has_angular_component_template_url: false,
477                    unused_import_bindings: FxHashSet::default(),
478                    type_referenced_import_bindings: vec![],
479                    value_referenced_import_bindings: vec![],
480                    namespace_object_aliases: vec![],
481                    exported_factory_returns: std::sync::Arc::default(),
482                    exported_factory_return_object_shapes: std::sync::Arc::default(),
483                    type_member_types: std::sync::Arc::default(),
484                }
485            })
486            .collect();
487
488        let entry_points = vec![EntryPoint {
489            path: PathBuf::from("/project/file0.ts"),
490            source: EntryPointSource::PackageJsonMain,
491        }];
492
493        ModuleGraph::build(&resolved_modules, &entry_points, &files)
494    }
495
496    fn dfs_find_cycles_from_for_test(mut input: DfsCycleInput<'_>) {
497        dfs_find_cycles_from(&mut input);
498    }
499
500    #[test]
501    fn find_cycles_empty_graph() {
502        let graph = ModuleGraph::build(&[], &[], &[]);
503        assert!(graph.find_cycles().is_empty());
504    }
505
506    #[test]
507    fn find_cycles_no_cycles() {
508        let graph = build_cycle_graph(3, &[(0, 1), (1, 2)]);
509        assert!(graph.find_cycles().is_empty());
510    }
511
512    #[test]
513    fn find_cycles_simple_two_node_cycle() {
514        let graph = build_cycle_graph(2, &[(0, 1), (1, 0)]);
515        let cycles = graph.find_cycles();
516        assert_eq!(cycles.len(), 1);
517        assert_eq!(cycles[0].len(), 2);
518    }
519
520    #[test]
521    fn find_cycles_three_node_cycle() {
522        let graph = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
523        let cycles = graph.find_cycles();
524        assert_eq!(cycles.len(), 1);
525        assert_eq!(cycles[0].len(), 3);
526    }
527
528    #[test]
529    fn find_cycles_self_import_ignored() {
530        let graph = build_cycle_graph(1, &[(0, 0)]);
531        let cycles = graph.find_cycles();
532        assert!(
533            cycles.is_empty(),
534            "self-imports should not be reported as cycles"
535        );
536    }
537
538    #[test]
539    fn find_cycles_multiple_independent_cycles() {
540        let graph = build_cycle_graph(4, &[(0, 1), (1, 0), (2, 3), (3, 2)]);
541        let cycles = graph.find_cycles();
542        assert_eq!(cycles.len(), 2);
543        assert!(cycles.iter().all(|c| c.len() == 2));
544    }
545
546    #[test]
547    fn find_cycles_linear_chain_with_back_edge() {
548        let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 3), (3, 1)]);
549        let cycles = graph.find_cycles();
550        assert_eq!(cycles.len(), 1);
551        assert_eq!(cycles[0].len(), 3);
552        let ids: Vec<u32> = cycles[0].iter().map(|f| f.0).collect();
553        assert!(ids.contains(&1));
554        assert!(ids.contains(&2));
555        assert!(ids.contains(&3));
556        assert!(!ids.contains(&0));
557    }
558
559    #[test]
560    fn find_cycles_overlapping_cycles_enumerated() {
561        let graph = build_cycle_graph(3, &[(0, 1), (1, 0), (1, 2), (2, 1)]);
562        let cycles = graph.find_cycles();
563        assert_eq!(
564            cycles.len(),
565            2,
566            "should find 2 elementary cycles, not 1 SCC"
567        );
568        assert!(
569            cycles.iter().all(|c| c.len() == 2),
570            "both cycles should have length 2"
571        );
572    }
573
574    #[test]
575    fn find_cycles_deterministic_ordering() {
576        let graph1 = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
577        let graph2 = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
578        let cycles1 = graph1.find_cycles();
579        let cycles2 = graph2.find_cycles();
580        assert_eq!(cycles1.len(), cycles2.len());
581        for (c1, c2) in cycles1.iter().zip(cycles2.iter()) {
582            let paths1: Vec<&PathBuf> = c1
583                .iter()
584                .map(|f| &graph1.modules[f.0 as usize].path)
585                .collect();
586            let paths2: Vec<&PathBuf> = c2
587                .iter()
588                .map(|f| &graph2.modules[f.0 as usize].path)
589                .collect();
590            assert_eq!(paths1, paths2);
591        }
592    }
593
594    #[test]
595    fn find_cycles_sorted_by_length() {
596        let graph = build_cycle_graph(5, &[(0, 1), (1, 0), (2, 3), (3, 4), (4, 2)]);
597        let cycles = graph.find_cycles();
598        assert_eq!(cycles.len(), 2);
599        assert!(
600            cycles[0].len() <= cycles[1].len(),
601            "cycles should be sorted by length"
602        );
603    }
604
605    #[test]
606    fn find_cycles_large_cycle() {
607        let edges: Vec<(u32, u32)> = (0..10).map(|i| (i, (i + 1) % 10)).collect();
608        let graph = build_cycle_graph(10, &edges);
609        let cycles = graph.find_cycles();
610        assert_eq!(cycles.len(), 1);
611        assert_eq!(cycles[0].len(), 10);
612    }
613
614    #[test]
615    fn find_cycles_complex_scc_multiple_elementary() {
616        let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)]);
617        let cycles = graph.find_cycles();
618        assert!(
619            cycles.len() >= 2,
620            "should find at least 2 elementary cycles, got {}",
621            cycles.len()
622        );
623        assert!(cycles.iter().all(|c| c.len() <= 4));
624    }
625
626    #[test]
627    fn find_cycles_no_duplicate_cycles() {
628        let graph = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
629        let cycles = graph.find_cycles();
630        assert_eq!(cycles.len(), 1, "triangle should produce exactly 1 cycle");
631        assert_eq!(cycles[0].len(), 3);
632    }
633
634    /// Build lightweight `ModuleNode` stubs and successor data for unit tests.
635    ///
636    /// `edges_spec` is a list of (source, target) pairs (0-indexed).
637    /// Returns (modules, all_succs, succ_ranges) suitable for constructing a `SuccessorMap`.
638    #[expect(
639        clippy::cast_possible_truncation,
640        reason = "test file counts are trivially small"
641    )]
642    fn build_test_succs(
643        file_count: usize,
644        edges_spec: &[(usize, usize)],
645    ) -> (Vec<ModuleNode>, Vec<usize>, Vec<Range<usize>>) {
646        let modules: Vec<ModuleNode> = (0..file_count)
647            .map(|i| {
648                let mut node = ModuleNode {
649                    file_id: FileId(i as u32),
650                    path: PathBuf::from(format!("/project/file{i}.ts")),
651                    edge_range: 0..0,
652                    exports: vec![],
653                    re_exports: vec![],
654                    flags: ModuleNode::flags_from(i == 0, true, false),
655                };
656                node.set_reachable(true);
657                node
658            })
659            .collect();
660
661        let mut all_succs: Vec<usize> = Vec::new();
662        let mut succ_ranges: Vec<Range<usize>> = Vec::with_capacity(file_count);
663        for src in 0..file_count {
664            let start = all_succs.len();
665            let mut seen = FxHashSet::default();
666            for &(s, t) in edges_spec {
667                if s == src && t < file_count && seen.insert(t) {
668                    all_succs.push(t);
669                }
670            }
671            let end = all_succs.len();
672            succ_ranges.push(start..end);
673        }
674
675        (modules, all_succs, succ_ranges)
676    }
677
678    #[test]
679    fn canonical_cycle_empty() {
680        let modules: Vec<ModuleNode> = vec![];
681        assert!(canonical_cycle(&[], &modules).is_empty());
682    }
683
684    #[test]
685    fn canonical_cycle_rotates_to_smallest_path() {
686        let (modules, _, _) = build_test_succs(3, &[]);
687        let result = canonical_cycle(&[2, 0, 1], &modules);
688        assert_eq!(result, vec![0, 1, 2]);
689    }
690
691    #[test]
692    fn canonical_cycle_already_canonical() {
693        let (modules, _, _) = build_test_succs(3, &[]);
694        let result = canonical_cycle(&[0, 1, 2], &modules);
695        assert_eq!(result, vec![0, 1, 2]);
696    }
697
698    #[test]
699    fn canonical_cycle_single_node() {
700        let (modules, _, _) = build_test_succs(1, &[]);
701        let result = canonical_cycle(&[0], &modules);
702        assert_eq!(result, vec![0]);
703    }
704
705    #[test]
706    fn try_record_cycle_inserts_new_cycle() {
707        let (modules, _, _) = build_test_succs(3, &[]);
708        let mut seen = FxHashSet::default();
709        let mut cycles = Vec::new();
710
711        try_record_cycle(&[0, 1, 2], &modules, &mut seen, &mut cycles);
712        assert_eq!(cycles.len(), 1);
713        assert_eq!(cycles[0], vec![0, 1, 2]);
714    }
715
716    #[test]
717    fn try_record_cycle_deduplicates_rotated_cycle() {
718        let (modules, _, _) = build_test_succs(3, &[]);
719        let mut seen = FxHashSet::default();
720        let mut cycles = Vec::new();
721
722        try_record_cycle(&[0, 1, 2], &modules, &mut seen, &mut cycles);
723        try_record_cycle(&[1, 2, 0], &modules, &mut seen, &mut cycles);
724        try_record_cycle(&[2, 0, 1], &modules, &mut seen, &mut cycles);
725
726        assert_eq!(
727            cycles.len(),
728            1,
729            "rotations of the same cycle should be deduped"
730        );
731    }
732
733    #[test]
734    fn try_record_cycle_single_node_self_loop() {
735        let (modules, _, _) = build_test_succs(1, &[]);
736        let mut seen = FxHashSet::default();
737        let mut cycles = Vec::new();
738
739        try_record_cycle(&[0], &modules, &mut seen, &mut cycles);
740        assert_eq!(cycles.len(), 1);
741        assert_eq!(cycles[0], vec![0]);
742    }
743
744    #[test]
745    fn try_record_cycle_distinct_cycles_both_recorded() {
746        let (modules, _, _) = build_test_succs(4, &[]);
747        let mut seen = FxHashSet::default();
748        let mut cycles = Vec::new();
749
750        try_record_cycle(&[0, 1], &modules, &mut seen, &mut cycles);
751        try_record_cycle(&[2, 3], &modules, &mut seen, &mut cycles);
752
753        assert_eq!(cycles.len(), 2);
754    }
755
756    #[test]
757    fn successor_map_empty_graph() {
758        let (modules, all_succs, succ_ranges) = build_test_succs(0, &[]);
759        let succs = SuccessorMap {
760            all_succs: &all_succs,
761            succ_ranges: &succ_ranges,
762            modules: &modules,
763        };
764        assert!(succs.all_succs.is_empty());
765        assert!(succs.succ_ranges.is_empty());
766    }
767
768    #[test]
769    fn successor_map_single_node_self_edge() {
770        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
771        let succs = SuccessorMap {
772            all_succs: &all_succs,
773            succ_ranges: &succ_ranges,
774            modules: &modules,
775        };
776        assert_eq!(succs.all_succs.len(), 1);
777        assert_eq!(succs.all_succs[0], 0);
778        assert_eq!(succs.succ_ranges[0], 0..1);
779    }
780
781    #[test]
782    fn successor_map_deduplicates_edges() {
783        let (modules, all_succs, succ_ranges) = build_test_succs(2, &[(0, 1), (0, 1)]);
784        let succs = SuccessorMap {
785            all_succs: &all_succs,
786            succ_ranges: &succ_ranges,
787            modules: &modules,
788        };
789        let range = &succs.succ_ranges[0];
790        assert_eq!(
791            range.end - range.start,
792            1,
793            "duplicate edges should be deduped"
794        );
795    }
796
797    #[test]
798    fn successor_map_multiple_successors() {
799        let (modules, all_succs, succ_ranges) = build_test_succs(4, &[(0, 1), (0, 2), (0, 3)]);
800        let succs = SuccessorMap {
801            all_succs: &all_succs,
802            succ_ranges: &succ_ranges,
803            modules: &modules,
804        };
805        let range = &succs.succ_ranges[0];
806        assert_eq!(range.end - range.start, 3);
807        for i in 1..4 {
808            let r = &succs.succ_ranges[i];
809            assert_eq!(r.end - r.start, 0);
810        }
811    }
812
813    #[test]
814    fn dfs_find_cycles_from_isolated_node() {
815        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[]);
816        let succs = SuccessorMap {
817            all_succs: &all_succs,
818            succ_ranges: &succ_ranges,
819            modules: &modules,
820        };
821        let scc_set: FxHashSet<usize> = std::iter::once(0).collect();
822        let mut seen = FxHashSet::default();
823        let mut cycles = Vec::new();
824
825        dfs_find_cycles_from_for_test(DfsCycleInput {
826            start: 0,
827            depth_limit: 2,
828            scc_set: &scc_set,
829            succs: &succs,
830            max_cycles: 10,
831            seen: &mut seen,
832            cycles: &mut cycles,
833        });
834        assert!(cycles.is_empty(), "isolated node should have no cycles");
835    }
836
837    #[test]
838    fn dfs_find_cycles_from_simple_two_cycle() {
839        let (modules, all_succs, succ_ranges) = build_test_succs(2, &[(0, 1), (1, 0)]);
840        let succs = SuccessorMap {
841            all_succs: &all_succs,
842            succ_ranges: &succ_ranges,
843            modules: &modules,
844        };
845        let scc_set: FxHashSet<usize> = [0, 1].into_iter().collect();
846        let mut seen = FxHashSet::default();
847        let mut cycles = Vec::new();
848
849        dfs_find_cycles_from_for_test(DfsCycleInput {
850            start: 0,
851            depth_limit: 2,
852            scc_set: &scc_set,
853            succs: &succs,
854            max_cycles: 10,
855            seen: &mut seen,
856            cycles: &mut cycles,
857        });
858        assert_eq!(cycles.len(), 1);
859        assert_eq!(cycles[0].len(), 2);
860    }
861
862    #[test]
863    fn dfs_find_cycles_from_diamond_graph() {
864        let (modules, all_succs, succ_ranges) =
865            build_test_succs(4, &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 0)]);
866        let succs = SuccessorMap {
867            all_succs: &all_succs,
868            succ_ranges: &succ_ranges,
869            modules: &modules,
870        };
871        let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
872        let mut seen = FxHashSet::default();
873        let mut cycles = Vec::new();
874
875        dfs_find_cycles_from_for_test(DfsCycleInput {
876            start: 0,
877            depth_limit: 3,
878            scc_set: &scc_set,
879            succs: &succs,
880            max_cycles: 10,
881            seen: &mut seen,
882            cycles: &mut cycles,
883        });
884        assert_eq!(cycles.len(), 2, "diamond should have two 3-node cycles");
885        assert!(cycles.iter().all(|c| c.len() == 3));
886    }
887
888    #[test]
889    fn dfs_find_cycles_from_depth_limit_prevents_longer_cycles() {
890        let (modules, all_succs, succ_ranges) =
891            build_test_succs(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
892        let succs = SuccessorMap {
893            all_succs: &all_succs,
894            succ_ranges: &succ_ranges,
895            modules: &modules,
896        };
897        let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
898        let mut seen = FxHashSet::default();
899        let mut cycles = Vec::new();
900
901        dfs_find_cycles_from_for_test(DfsCycleInput {
902            start: 0,
903            depth_limit: 3,
904            scc_set: &scc_set,
905            succs: &succs,
906            max_cycles: 10,
907            seen: &mut seen,
908            cycles: &mut cycles,
909        });
910        assert!(
911            cycles.is_empty(),
912            "depth_limit=3 should prevent finding a 4-node cycle"
913        );
914    }
915
916    #[test]
917    fn dfs_find_cycles_from_depth_limit_exact_match() {
918        let (modules, all_succs, succ_ranges) =
919            build_test_succs(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
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, 1, 2, 3].into_iter().collect();
926        let mut seen = FxHashSet::default();
927        let mut cycles = Vec::new();
928
929        dfs_find_cycles_from_for_test(DfsCycleInput {
930            start: 0,
931            depth_limit: 4,
932            scc_set: &scc_set,
933            succs: &succs,
934            max_cycles: 10,
935            seen: &mut seen,
936            cycles: &mut cycles,
937        });
938        assert_eq!(
939            cycles.len(),
940            1,
941            "depth_limit=4 should find the 4-node cycle"
942        );
943        assert_eq!(cycles[0].len(), 4);
944    }
945
946    #[test]
947    fn dfs_find_cycles_from_respects_max_cycles() {
948        let edges: Vec<(usize, usize)> = (0..4)
949            .flat_map(|i| (0..4).filter(move |&j| i != j).map(move |j| (i, j)))
950            .collect();
951        let (modules, all_succs, succ_ranges) = build_test_succs(4, &edges);
952        let succs = SuccessorMap {
953            all_succs: &all_succs,
954            succ_ranges: &succ_ranges,
955            modules: &modules,
956        };
957        let scc_set: FxHashSet<usize> = (0..4).collect();
958        let mut seen = FxHashSet::default();
959        let mut cycles = Vec::new();
960
961        dfs_find_cycles_from_for_test(DfsCycleInput {
962            start: 0,
963            depth_limit: 2,
964            scc_set: &scc_set,
965            succs: &succs,
966            max_cycles: 2,
967            seen: &mut seen,
968            cycles: &mut cycles,
969        });
970        assert!(
971            cycles.len() <= 2,
972            "should respect max_cycles limit, got {}",
973            cycles.len()
974        );
975    }
976
977    #[test]
978    fn dfs_find_cycles_from_ignores_nodes_outside_scc() {
979        let (modules, all_succs, succ_ranges) = build_test_succs(3, &[(0, 1), (1, 2), (2, 0)]);
980        let succs = SuccessorMap {
981            all_succs: &all_succs,
982            succ_ranges: &succ_ranges,
983            modules: &modules,
984        };
985        let scc_set: FxHashSet<usize> = [0, 1].into_iter().collect();
986        let mut seen = FxHashSet::default();
987        let mut cycles = Vec::new();
988
989        for depth in 2..=3 {
990            dfs_find_cycles_from_for_test(DfsCycleInput {
991                start: 0,
992                depth_limit: depth,
993                scc_set: &scc_set,
994                succs: &succs,
995                max_cycles: 10,
996                seen: &mut seen,
997                cycles: &mut cycles,
998            });
999        }
1000        assert!(
1001            cycles.is_empty(),
1002            "should not find cycles through nodes outside the SCC set"
1003        );
1004    }
1005
1006    #[test]
1007    fn enumerate_elementary_cycles_empty_scc() {
1008        let (modules, all_succs, succ_ranges) = build_test_succs(0, &[]);
1009        let succs = SuccessorMap {
1010            all_succs: &all_succs,
1011            succ_ranges: &succ_ranges,
1012            modules: &modules,
1013        };
1014        let cycles = enumerate_elementary_cycles(&[], &succs, 10);
1015        assert!(cycles.is_empty());
1016    }
1017
1018    #[test]
1019    fn enumerate_elementary_cycles_max_cycles_limit() {
1020        let edges: Vec<(usize, usize)> = (0..4)
1021            .flat_map(|i| (0..4).filter(move |&j| i != j).map(move |j| (i, j)))
1022            .collect();
1023        let (modules, all_succs, succ_ranges) = build_test_succs(4, &edges);
1024        let succs = SuccessorMap {
1025            all_succs: &all_succs,
1026            succ_ranges: &succ_ranges,
1027            modules: &modules,
1028        };
1029        let scc_nodes: Vec<usize> = (0..4).collect();
1030
1031        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 3);
1032        assert!(
1033            cycles.len() <= 3,
1034            "should respect max_cycles=3 limit, got {}",
1035            cycles.len()
1036        );
1037    }
1038
1039    #[test]
1040    fn enumerate_elementary_cycles_finds_all_in_triangle() {
1041        let (modules, all_succs, succ_ranges) = build_test_succs(3, &[(0, 1), (1, 2), (2, 0)]);
1042        let succs = SuccessorMap {
1043            all_succs: &all_succs,
1044            succ_ranges: &succ_ranges,
1045            modules: &modules,
1046        };
1047        let scc_nodes: Vec<usize> = vec![0, 1, 2];
1048
1049        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1050        assert_eq!(cycles.len(), 1);
1051        assert_eq!(cycles[0].len(), 3);
1052    }
1053
1054    #[test]
1055    fn enumerate_elementary_cycles_iterative_deepening_order() {
1056        let (modules, all_succs, succ_ranges) =
1057            build_test_succs(3, &[(0, 1), (1, 0), (1, 2), (2, 0)]);
1058        let succs = SuccessorMap {
1059            all_succs: &all_succs,
1060            succ_ranges: &succ_ranges,
1061            modules: &modules,
1062        };
1063        let scc_nodes: Vec<usize> = vec![0, 1, 2];
1064
1065        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1066        assert!(cycles.len() >= 2, "should find at least 2 cycles");
1067        assert!(
1068            cycles[0].len() <= cycles[cycles.len() - 1].len(),
1069            "shorter cycles should be found before longer ones"
1070        );
1071    }
1072
1073    #[test]
1074    fn find_cycles_max_cycles_per_scc_respected() {
1075        let edges: Vec<(u32, u32)> = (0..5)
1076            .flat_map(|i| (0..5).filter(move |&j| i != j).map(move |j| (i, j)))
1077            .collect();
1078        let graph = build_cycle_graph(5, &edges);
1079        let cycles = graph.find_cycles();
1080        assert!(
1081            cycles.len() <= 20,
1082            "should cap at MAX_CYCLES_PER_SCC, got {}",
1083            cycles.len()
1084        );
1085        assert!(
1086            !cycles.is_empty(),
1087            "dense graph should still find some cycles"
1088        );
1089    }
1090
1091    #[test]
1092    fn find_cycles_graph_with_no_cycles_returns_empty() {
1093        let graph = build_cycle_graph(5, &[(0, 1), (0, 2), (0, 3), (0, 4)]);
1094        assert!(graph.find_cycles().is_empty());
1095    }
1096
1097    #[test]
1098    fn find_cycles_diamond_no_cycle() {
1099        let graph = build_cycle_graph(4, &[(0, 1), (0, 2), (1, 3), (2, 3)]);
1100        assert!(graph.find_cycles().is_empty());
1101    }
1102
1103    #[test]
1104    fn find_cycles_diamond_with_back_edge() {
1105        let graph = build_cycle_graph(4, &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 0)]);
1106        let cycles = graph.find_cycles();
1107        assert!(
1108            cycles.len() >= 2,
1109            "diamond with back-edge should have at least 2 elementary cycles, got {}",
1110            cycles.len()
1111        );
1112        assert_eq!(cycles[0].len(), 3);
1113    }
1114
1115    #[test]
1116    fn canonical_cycle_non_sequential_indices() {
1117        let (modules, _, _) = build_test_succs(5, &[]);
1118        let result = canonical_cycle(&[3, 1, 4], &modules);
1119        assert_eq!(result, vec![1, 4, 3]);
1120    }
1121
1122    #[test]
1123    fn canonical_cycle_different_starting_points_same_result() {
1124        let (modules, _, _) = build_test_succs(4, &[]);
1125        let r1 = canonical_cycle(&[0, 1, 2, 3], &modules);
1126        let r2 = canonical_cycle(&[1, 2, 3, 0], &modules);
1127        let r3 = canonical_cycle(&[2, 3, 0, 1], &modules);
1128        let r4 = canonical_cycle(&[3, 0, 1, 2], &modules);
1129        assert_eq!(r1, r2);
1130        assert_eq!(r2, r3);
1131        assert_eq!(r3, r4);
1132        assert_eq!(r1, vec![0, 1, 2, 3]);
1133    }
1134
1135    #[test]
1136    fn canonical_cycle_two_node_both_rotations() {
1137        let (modules, _, _) = build_test_succs(2, &[]);
1138        assert_eq!(canonical_cycle(&[0, 1], &modules), vec![0, 1]);
1139        assert_eq!(canonical_cycle(&[1, 0], &modules), vec![0, 1]);
1140    }
1141
1142    #[test]
1143    fn dfs_find_cycles_from_self_loop_not_found() {
1144        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
1145        let succs = SuccessorMap {
1146            all_succs: &all_succs,
1147            succ_ranges: &succ_ranges,
1148            modules: &modules,
1149        };
1150        let scc_set: FxHashSet<usize> = std::iter::once(0).collect();
1151        let mut seen = FxHashSet::default();
1152        let mut cycles = Vec::new();
1153
1154        for depth in 1..=3 {
1155            dfs_find_cycles_from_for_test(DfsCycleInput {
1156                start: 0,
1157                depth_limit: depth,
1158                scc_set: &scc_set,
1159                succs: &succs,
1160                max_cycles: 10,
1161                seen: &mut seen,
1162                cycles: &mut cycles,
1163            });
1164        }
1165        assert!(
1166            cycles.is_empty(),
1167            "self-loop should not be detected as a cycle by dfs_find_cycles_from"
1168        );
1169    }
1170
1171    #[test]
1172    fn enumerate_elementary_cycles_self_loop_not_found() {
1173        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
1174        let succs = SuccessorMap {
1175            all_succs: &all_succs,
1176            succ_ranges: &succ_ranges,
1177            modules: &modules,
1178        };
1179        let cycles = enumerate_elementary_cycles(&[0], &succs, 20);
1180        assert!(
1181            cycles.is_empty(),
1182            "self-loop should not produce elementary cycles"
1183        );
1184    }
1185
1186    #[test]
1187    fn find_cycles_two_cycles_sharing_edge() {
1188        let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 0), (1, 3), (3, 0)]);
1189        let cycles = graph.find_cycles();
1190        assert_eq!(
1191            cycles.len(),
1192            2,
1193            "two cycles sharing edge A->B should both be found, got {}",
1194            cycles.len()
1195        );
1196        assert!(
1197            cycles.iter().all(|c| c.len() == 3),
1198            "both cycles should have length 3"
1199        );
1200    }
1201
1202    #[test]
1203    fn enumerate_elementary_cycles_shared_edge() {
1204        let (modules, all_succs, succ_ranges) =
1205            build_test_succs(4, &[(0, 1), (1, 2), (2, 0), (1, 3), (3, 0)]);
1206        let succs = SuccessorMap {
1207            all_succs: &all_succs,
1208            succ_ranges: &succ_ranges,
1209            modules: &modules,
1210        };
1211        let scc_nodes: Vec<usize> = vec![0, 1, 2, 3];
1212        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1213        assert_eq!(
1214            cycles.len(),
1215            2,
1216            "should find exactly 2 elementary cycles sharing edge 0->1, got {}",
1217            cycles.len()
1218        );
1219    }
1220
1221    #[test]
1222    fn enumerate_elementary_cycles_pentagon_with_chords() {
1223        let (modules, all_succs, succ_ranges) =
1224            build_test_succs(5, &[(0, 1), (1, 2), (2, 3), (3, 4), (4, 0), (0, 2), (0, 3)]);
1225        let succs = SuccessorMap {
1226            all_succs: &all_succs,
1227            succ_ranges: &succ_ranges,
1228            modules: &modules,
1229        };
1230        let scc_nodes: Vec<usize> = vec![0, 1, 2, 3, 4];
1231        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1232
1233        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();
1239        assert_eq!(
1240            unique.len(),
1241            cycles.len(),
1242            "all enumerated cycles should be unique"
1243        );
1244        assert_eq!(
1245            cycles[0].len(),
1246            3,
1247            "shortest cycle in pentagon with chords should be length 3"
1248        );
1249    }
1250
1251    #[test]
1252    fn find_cycles_large_scc_complete_graph_k6() {
1253        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!(
1260            cycles.len() <= 20,
1261            "should cap at MAX_CYCLES_PER_SCC (20), got {}",
1262            cycles.len()
1263        );
1264        assert_eq!(
1265            cycles.len(),
1266            20,
1267            "K6 has far more than 20 elementary cycles, so we should hit the cap"
1268        );
1269        assert_eq!(cycles[0].len(), 2, "shortest cycles in K6 should be 2-node");
1270    }
1271
1272    #[test]
1273    fn enumerate_elementary_cycles_respects_depth_cap_of_12() {
1274        let edges: Vec<(usize, usize)> = (0..15).map(|i| (i, (i + 1) % 15)).collect();
1275        let (modules, all_succs, succ_ranges) = build_test_succs(15, &edges);
1276        let succs = SuccessorMap {
1277            all_succs: &all_succs,
1278            succ_ranges: &succ_ranges,
1279            modules: &modules,
1280        };
1281        let scc_nodes: Vec<usize> = (0..15).collect();
1282        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1283
1284        assert!(
1285            cycles.is_empty(),
1286            "a pure 15-node cycle should not be found with depth cap of 12, got {} cycles",
1287            cycles.len()
1288        );
1289    }
1290
1291    #[test]
1292    fn enumerate_elementary_cycles_finds_cycle_at_depth_cap_boundary() {
1293        let edges: Vec<(usize, usize)> = (0..12).map(|i| (i, (i + 1) % 12)).collect();
1294        let (modules, all_succs, succ_ranges) = build_test_succs(12, &edges);
1295        let succs = SuccessorMap {
1296            all_succs: &all_succs,
1297            succ_ranges: &succ_ranges,
1298            modules: &modules,
1299        };
1300        let scc_nodes: Vec<usize> = (0..12).collect();
1301        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1302
1303        assert_eq!(
1304            cycles.len(),
1305            1,
1306            "a pure 12-node cycle should be found at the depth cap boundary"
1307        );
1308        assert_eq!(cycles[0].len(), 12);
1309    }
1310
1311    #[test]
1312    fn enumerate_elementary_cycles_13_node_pure_cycle_not_found() {
1313        let edges: Vec<(usize, usize)> = (0..13).map(|i| (i, (i + 1) % 13)).collect();
1314        let (modules, all_succs, succ_ranges) = build_test_succs(13, &edges);
1315        let succs = SuccessorMap {
1316            all_succs: &all_succs,
1317            succ_ranges: &succ_ranges,
1318            modules: &modules,
1319        };
1320        let scc_nodes: Vec<usize> = (0..13).collect();
1321        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1322
1323        assert!(
1324            cycles.is_empty(),
1325            "13-node pure cycle exceeds depth cap of 12"
1326        );
1327    }
1328
1329    #[test]
1330    fn find_cycles_max_cycles_per_scc_enforced_on_k7() {
1331        let edges: Vec<(u32, u32)> = (0..7)
1332            .flat_map(|i| (0..7).filter(move |&j| i != j).map(move |j| (i, j)))
1333            .collect();
1334        let graph = build_cycle_graph(7, &edges);
1335        let cycles = graph.find_cycles();
1336
1337        assert!(
1338            cycles.len() <= 20,
1339            "K7 should cap at MAX_CYCLES_PER_SCC (20), got {}",
1340            cycles.len()
1341        );
1342        assert_eq!(
1343            cycles.len(),
1344            20,
1345            "K7 has far more than 20 elementary cycles, should hit the cap exactly"
1346        );
1347    }
1348
1349    #[test]
1350    fn find_cycles_two_dense_sccs_each_capped() {
1351        let mut edges: Vec<(u32, u32)> = Vec::new();
1352        for i in 0..4 {
1353            for j in 0..4 {
1354                if i != j {
1355                    edges.push((i, j));
1356                }
1357            }
1358        }
1359        for i in 4..8 {
1360            for j in 4..8 {
1361                if i != j {
1362                    edges.push((i, j));
1363                }
1364            }
1365        }
1366        let graph = build_cycle_graph(8, &edges);
1367        let cycles = graph.find_cycles();
1368
1369        assert!(!cycles.is_empty(), "two dense SCCs should produce cycles");
1370        assert!(
1371            cycles.len() > 2,
1372            "should find multiple cycles across both SCCs, got {}",
1373            cycles.len()
1374        );
1375    }
1376
1377    mod proptests {
1378        use super::*;
1379        use proptest::prelude::*;
1380
1381        proptest! {
1382            /// A DAG (directed acyclic graph) should always have zero cycles.
1383            /// We construct a DAG by only allowing edges from lower to higher node indices.
1384            #[test]
1385            fn dag_has_no_cycles(
1386                file_count in 2..20usize,
1387                edge_pairs in prop::collection::vec((0..19u32, 0..19u32), 0..30),
1388            ) {
1389                let dag_edges: Vec<(u32, u32)> = edge_pairs
1390                    .into_iter()
1391                    .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a < b)
1392                    .collect();
1393
1394                let graph = build_cycle_graph(file_count, &dag_edges);
1395                let cycles = graph.find_cycles();
1396                prop_assert!(
1397                    cycles.is_empty(),
1398                    "DAG should have no cycles, but found {}",
1399                    cycles.len()
1400                );
1401            }
1402
1403            /// Adding mutual edges A->B->A should always detect a cycle.
1404            #[test]
1405            fn mutual_edges_always_detect_cycle(extra_nodes in 0..10usize) {
1406                let file_count = 2 + extra_nodes;
1407                let graph = build_cycle_graph(file_count, &[(0, 1), (1, 0)]);
1408                let cycles = graph.find_cycles();
1409                prop_assert!(
1410                    !cycles.is_empty(),
1411                    "A->B->A should always produce at least one cycle"
1412                );
1413                let has_pair_cycle = cycles.iter().any(|c| {
1414                    c.contains(&FileId(0)) && c.contains(&FileId(1))
1415                });
1416                prop_assert!(has_pair_cycle, "Should find a cycle containing nodes 0 and 1");
1417            }
1418
1419            /// All cycle members should be valid FileId indices.
1420            #[test]
1421            fn cycle_members_are_valid_indices(
1422                file_count in 2..15usize,
1423                edge_pairs in prop::collection::vec((0..14u32, 0..14u32), 1..20),
1424            ) {
1425                let edges: Vec<(u32, u32)> = edge_pairs
1426                    .into_iter()
1427                    .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a != b)
1428                    .collect();
1429
1430                let graph = build_cycle_graph(file_count, &edges);
1431                let cycles = graph.find_cycles();
1432                for cycle in &cycles {
1433                    prop_assert!(cycle.len() >= 2, "Cycles must have at least 2 nodes");
1434                    for file_id in cycle {
1435                        prop_assert!(
1436                            (file_id.0 as usize) < file_count,
1437                            "FileId {} exceeds file count {}",
1438                            file_id.0, file_count
1439                        );
1440                    }
1441                }
1442            }
1443
1444            /// Cycles should be sorted by length (shortest first).
1445            #[test]
1446            fn cycles_sorted_by_length(
1447                file_count in 3..12usize,
1448                edge_pairs in prop::collection::vec((0..11u32, 0..11u32), 2..25),
1449            ) {
1450                let edges: Vec<(u32, u32)> = edge_pairs
1451                    .into_iter()
1452                    .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a != b)
1453                    .collect();
1454
1455                let graph = build_cycle_graph(file_count, &edges);
1456                let cycles = graph.find_cycles();
1457                for window in cycles.windows(2) {
1458                    prop_assert!(
1459                        window[0].len() <= window[1].len(),
1460                        "Cycles should be sorted by length: {} > {}",
1461                        window[0].len(), window[1].len()
1462                    );
1463                }
1464            }
1465        }
1466    }
1467
1468    /// Build a cycle graph where specific edges are type-only.
1469    fn build_cycle_graph_with_type_only(
1470        file_count: usize,
1471        edges_spec: &[(u32, u32, bool)], // (source, target, is_type_only)
1472    ) -> ModuleGraph {
1473        let files: Vec<DiscoveredFile> = (0..file_count)
1474            .map(|i| DiscoveredFile {
1475                id: FileId(i as u32),
1476                path: PathBuf::from(format!("/project/file{i}.ts")),
1477                size_bytes: 100,
1478            })
1479            .collect();
1480
1481        let resolved_modules: Vec<ResolvedModule> = (0..file_count)
1482            .map(|i| {
1483                let imports: Vec<ResolvedImport> = edges_spec
1484                    .iter()
1485                    .filter(|(src, _, _)| *src == i as u32)
1486                    .map(|(_, tgt, type_only)| ResolvedImport {
1487                        info: ImportInfo {
1488                            source: format!("./file{tgt}"),
1489                            imported_name: ImportedName::Named("x".to_string()),
1490                            local_name: "x".to_string(),
1491                            is_type_only: *type_only,
1492                            is_type_only_star: false,
1493                            from_style: false,
1494                            span: oxc_span::Span::new(0, 10),
1495                            source_span: oxc_span::Span::default(),
1496                        },
1497                        target: ResolveResult::InternalModule(FileId(*tgt)),
1498                    })
1499                    .collect();
1500
1501                ResolvedModule {
1502                    file_id: FileId(i as u32),
1503                    path: PathBuf::from(format!("/project/file{i}.ts")),
1504                    exports: vec![fallow_types::extract::ExportInfo {
1505                        name: ExportName::Named("x".to_string()),
1506                        local_name: Some("x".to_string()),
1507                        is_type_only: false,
1508                        visibility: VisibilityTag::None,
1509                        expected_unused_reason: None,
1510                        span: oxc_span::Span::new(0, 20),
1511                        members: vec![],
1512                        is_side_effect_used: false,
1513                        super_class: None,
1514                        deprecated: false,
1515                        deprecated_reason: None,
1516                    }]
1517                    .into(),
1518                    re_exports: vec![],
1519                    resolved_imports: imports,
1520                    resolved_dynamic_imports: vec![],
1521                    resolved_dynamic_patterns: vec![],
1522                    member_accesses: vec![].into(),
1523                    semantic_facts: std::sync::Arc::default(),
1524                    whole_object_uses: std::sync::Arc::default(),
1525                    has_cjs_exports: false,
1526                    has_angular_component_template_url: false,
1527                    unused_import_bindings: FxHashSet::default(),
1528                    type_referenced_import_bindings: vec![],
1529                    value_referenced_import_bindings: vec![],
1530                    namespace_object_aliases: vec![],
1531                    exported_factory_returns: std::sync::Arc::default(),
1532                    exported_factory_return_object_shapes: std::sync::Arc::default(),
1533                    type_member_types: std::sync::Arc::default(),
1534                }
1535            })
1536            .collect();
1537
1538        let entry_points = vec![EntryPoint {
1539            path: PathBuf::from("/project/file0.ts"),
1540            source: EntryPointSource::PackageJsonMain,
1541        }];
1542
1543        ModuleGraph::build(&resolved_modules, &entry_points, &files)
1544    }
1545
1546    #[test]
1547    fn type_only_bidirectional_import_not_a_cycle() {
1548        let graph = build_cycle_graph_with_type_only(2, &[(0, 1, true), (1, 0, true)]);
1549        let cycles = graph.find_cycles();
1550        assert!(
1551            cycles.is_empty(),
1552            "type-only bidirectional imports should not be reported as cycles"
1553        );
1554    }
1555
1556    #[test]
1557    fn mixed_type_and_value_import_not_a_cycle() {
1558        let graph = build_cycle_graph_with_type_only(2, &[(0, 1, false), (1, 0, true)]);
1559        let cycles = graph.find_cycles();
1560        assert!(
1561            cycles.is_empty(),
1562            "A->B (value) + B->A (type-only) is not a runtime cycle"
1563        );
1564    }
1565
1566    #[test]
1567    fn both_value_imports_with_one_type_still_a_cycle() {
1568        let graph = build_cycle_graph_with_type_only(2, &[(0, 1, false), (1, 0, false)]);
1569        let cycles = graph.find_cycles();
1570        assert!(
1571            !cycles.is_empty(),
1572            "bidirectional value imports should be reported as a cycle"
1573        );
1574    }
1575
1576    #[test]
1577    fn all_value_imports_still_a_cycle() {
1578        let graph = build_cycle_graph_with_type_only(2, &[(0, 1, false), (1, 0, false)]);
1579        let cycles = graph.find_cycles();
1580        assert_eq!(cycles.len(), 1);
1581    }
1582
1583    #[test]
1584    fn three_node_type_only_cycle_not_reported() {
1585        let graph =
1586            build_cycle_graph_with_type_only(3, &[(0, 1, true), (1, 2, true), (2, 0, true)]);
1587        let cycles = graph.find_cycles();
1588        assert!(
1589            cycles.is_empty(),
1590            "three-node type-only cycle should not be reported"
1591        );
1592    }
1593
1594    #[test]
1595    fn three_node_cycle_one_value_edge_still_reported() {
1596        let graph =
1597            build_cycle_graph_with_type_only(3, &[(0, 1, false), (1, 2, true), (2, 0, true)]);
1598        let cycles = graph.find_cycles();
1599        assert!(
1600            cycles.is_empty(),
1601            "cycle broken by type-only edge in the middle should not be reported"
1602        );
1603    }
1604}