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