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