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    #[expect(clippy::excessive_nesting)]
23    pub fn find_cycles(&self) -> Vec<Vec<FileId>> {
24        let n = self.modules.len();
25        if n == 0 {
26            return Vec::new();
27        }
28
29        // Tarjan's SCC state
30        let mut index_counter: u32 = 0;
31        let mut indices: Vec<u32> = vec![u32::MAX; n]; // u32::MAX = undefined
32        let mut lowlinks: Vec<u32> = vec![0; n];
33        let mut on_stack = FixedBitSet::with_capacity(n);
34        let mut stack: Vec<usize> = Vec::new();
35        let mut sccs: Vec<Vec<FileId>> = Vec::new();
36
37        // Iterative DFS stack frame
38        struct Frame {
39            node: usize,
40            succ_pos: usize,
41            succ_end: usize,
42        }
43
44        // Pre-collect all successors (deduplicated) into a flat vec for cache-friendly access.
45        let mut all_succs: Vec<usize> = Vec::with_capacity(self.edges.len());
46        let mut succ_ranges: Vec<Range<usize>> = Vec::with_capacity(n);
47        let mut seen_set = FxHashSet::default();
48        for module in &self.modules {
49            let start = all_succs.len();
50            seen_set.clear();
51            for edge in &self.edges[module.edge_range.clone()] {
52                let target = edge.target.0 as usize;
53                if target < n && seen_set.insert(target) {
54                    all_succs.push(target);
55                }
56            }
57            let end = all_succs.len();
58            succ_ranges.push(start..end);
59        }
60
61        let mut dfs_stack: Vec<Frame> = Vec::new();
62
63        for start_node in 0..n {
64            if indices[start_node] != u32::MAX {
65                continue;
66            }
67
68            // Push the starting node
69            indices[start_node] = index_counter;
70            lowlinks[start_node] = index_counter;
71            index_counter += 1;
72            on_stack.insert(start_node);
73            stack.push(start_node);
74
75            let range = &succ_ranges[start_node];
76            dfs_stack.push(Frame {
77                node: start_node,
78                succ_pos: range.start,
79                succ_end: range.end,
80            });
81
82            while let Some(frame) = dfs_stack.last_mut() {
83                if frame.succ_pos < frame.succ_end {
84                    let w = all_succs[frame.succ_pos];
85                    frame.succ_pos += 1;
86
87                    if indices[w] == u32::MAX {
88                        // Tree edge: push w onto the DFS stack
89                        indices[w] = index_counter;
90                        lowlinks[w] = index_counter;
91                        index_counter += 1;
92                        on_stack.insert(w);
93                        stack.push(w);
94
95                        let range = &succ_ranges[w];
96                        dfs_stack.push(Frame {
97                            node: w,
98                            succ_pos: range.start,
99                            succ_end: range.end,
100                        });
101                    } else if on_stack.contains(w) {
102                        // Back edge: update lowlink
103                        let v = frame.node;
104                        lowlinks[v] = lowlinks[v].min(indices[w]);
105                    }
106                } else {
107                    // All successors processed — pop this frame
108                    let v = frame.node;
109                    let v_lowlink = lowlinks[v];
110                    let v_index = indices[v];
111                    dfs_stack.pop();
112
113                    // Update parent's lowlink
114                    if let Some(parent) = dfs_stack.last_mut() {
115                        lowlinks[parent.node] = lowlinks[parent.node].min(v_lowlink);
116                    }
117
118                    // If v is a root node, pop the SCC
119                    if v_lowlink == v_index {
120                        let mut scc = Vec::new();
121                        loop {
122                            let w = stack.pop().expect("SCC stack should not be empty");
123                            on_stack.set(w, false);
124                            scc.push(FileId(w as u32));
125                            if w == v {
126                                break;
127                            }
128                        }
129                        // Only report cycles of length >= 2
130                        if scc.len() >= 2 {
131                            sccs.push(scc);
132                        }
133                    }
134                }
135            }
136        }
137
138        // Phase 2: Enumerate individual elementary cycles within each SCC.
139        // For small SCCs (len == 2), there's exactly one cycle.
140        // For larger SCCs, use bounded DFS to find up to MAX_CYCLES_PER_SCC cycles.
141        const MAX_CYCLES_PER_SCC: usize = 20;
142
143        let succs = SuccessorMap {
144            all_succs: &all_succs,
145            succ_ranges: &succ_ranges,
146            modules: &self.modules,
147        };
148
149        let mut result: Vec<Vec<FileId>> = Vec::new();
150        let mut seen_cycles: FxHashSet<Vec<u32>> = FxHashSet::default();
151
152        for scc in &sccs {
153            if scc.len() == 2 {
154                let mut cycle = vec![scc[0].0 as usize, scc[1].0 as usize];
155                // Canonical: smallest path first
156                if self.modules[cycle[1]].path < self.modules[cycle[0]].path {
157                    cycle.swap(0, 1);
158                }
159                let key: Vec<u32> = cycle.iter().map(|&n| n as u32).collect();
160                if seen_cycles.insert(key) {
161                    result.push(cycle.into_iter().map(|n| FileId(n as u32)).collect());
162                }
163                continue;
164            }
165
166            let scc_nodes: Vec<usize> = scc.iter().map(|id| id.0 as usize).collect();
167            let elementary = enumerate_elementary_cycles(&scc_nodes, &succs, MAX_CYCLES_PER_SCC);
168
169            for cycle in elementary {
170                let key: Vec<u32> = cycle.iter().map(|&n| n as u32).collect();
171                if seen_cycles.insert(key) {
172                    result.push(cycle.into_iter().map(|n| FileId(n as u32)).collect());
173                }
174            }
175        }
176
177        // Sort: shortest first, then by first file path
178        result.sort_by(|a, b| {
179            a.len().cmp(&b.len()).then_with(|| {
180                self.modules[a[0].0 as usize]
181                    .path
182                    .cmp(&self.modules[b[0].0 as usize].path)
183            })
184        });
185
186        result
187    }
188}
189
190/// Rotate a cycle so the node with the smallest path is first (canonical form for dedup).
191fn canonical_cycle(cycle: &[usize], modules: &[ModuleNode]) -> Vec<usize> {
192    if cycle.is_empty() {
193        return Vec::new();
194    }
195    let min_pos = cycle
196        .iter()
197        .enumerate()
198        .min_by(|(_, a), (_, b)| modules[**a].path.cmp(&modules[**b].path))
199        .map_or(0, |(i, _)| i);
200    let mut result = cycle[min_pos..].to_vec();
201    result.extend_from_slice(&cycle[..min_pos]);
202    result
203}
204
205/// DFS frame for iterative cycle finding.
206struct CycleFrame {
207    succ_pos: usize,
208    succ_end: usize,
209}
210
211/// Pre-collected, deduplicated successor data for cache-friendly graph traversal.
212struct SuccessorMap<'a> {
213    all_succs: &'a [usize],
214    succ_ranges: &'a [Range<usize>],
215    modules: &'a [ModuleNode],
216}
217
218/// Record a cycle in canonical form if not already seen.
219fn try_record_cycle(
220    path: &[usize],
221    modules: &[ModuleNode],
222    seen: &mut FxHashSet<Vec<u32>>,
223    cycles: &mut Vec<Vec<usize>>,
224) {
225    let canonical = canonical_cycle(path, modules);
226    let key: Vec<u32> = canonical.iter().map(|&n| n as u32).collect();
227    if seen.insert(key) {
228        cycles.push(canonical);
229    }
230}
231
232/// Run a bounded DFS from `start`, looking for elementary cycles of exactly `depth_limit` nodes.
233///
234/// Appends any newly found cycles to `cycles` (deduped via `seen`).
235/// Stops early once `cycles.len() >= max_cycles`.
236fn dfs_find_cycles_from(
237    start: usize,
238    depth_limit: usize,
239    scc_set: &FxHashSet<usize>,
240    succs: &SuccessorMap<'_>,
241    max_cycles: usize,
242    seen: &mut FxHashSet<Vec<u32>>,
243    cycles: &mut Vec<Vec<usize>>,
244) {
245    let mut path: Vec<usize> = vec![start];
246    let mut path_set = FixedBitSet::with_capacity(succs.modules.len());
247    path_set.insert(start);
248
249    let range = &succs.succ_ranges[start];
250    let mut dfs: Vec<CycleFrame> = vec![CycleFrame {
251        succ_pos: range.start,
252        succ_end: range.end,
253    }];
254
255    while let Some(frame) = dfs.last_mut() {
256        if cycles.len() >= max_cycles {
257            return;
258        }
259
260        if frame.succ_pos >= frame.succ_end {
261            // Backtrack: all successors exhausted for this frame
262            dfs.pop();
263            if path.len() > 1 {
264                let removed = path.pop().unwrap();
265                path_set.set(removed, false);
266            }
267            continue;
268        }
269
270        let w = succs.all_succs[frame.succ_pos];
271        frame.succ_pos += 1;
272
273        // Only follow edges within this SCC
274        if !scc_set.contains(&w) {
275            continue;
276        }
277
278        // Found an elementary cycle at exactly this depth
279        if w == start && path.len() >= 2 && path.len() == depth_limit {
280            try_record_cycle(&path, succs.modules, seen, cycles);
281            continue;
282        }
283
284        // Skip if already on current path or beyond depth limit
285        if path_set.contains(w) || path.len() >= depth_limit {
286            continue;
287        }
288
289        // Extend path
290        path.push(w);
291        path_set.insert(w);
292
293        let range = &succs.succ_ranges[w];
294        dfs.push(CycleFrame {
295            succ_pos: range.start,
296            succ_end: range.end,
297        });
298    }
299}
300
301/// Enumerate individual elementary cycles within an SCC using depth-limited DFS.
302///
303/// Uses iterative deepening: first finds all 2-node cycles, then 3-node, etc.
304/// This ensures the shortest, most actionable cycles are always found first.
305/// Stops after `max_cycles` total cycles to bound work on dense SCCs.
306fn enumerate_elementary_cycles(
307    scc_nodes: &[usize],
308    succs: &SuccessorMap<'_>,
309    max_cycles: usize,
310) -> Vec<Vec<usize>> {
311    let scc_set: FxHashSet<usize> = scc_nodes.iter().copied().collect();
312    let mut cycles: Vec<Vec<usize>> = Vec::new();
313    let mut seen: FxHashSet<Vec<u32>> = FxHashSet::default();
314
315    // Sort start nodes by path for deterministic enumeration order
316    let mut sorted_nodes: Vec<usize> = scc_nodes.to_vec();
317    sorted_nodes.sort_by(|a, b| succs.modules[*a].path.cmp(&succs.modules[*b].path));
318
319    // Iterative deepening: increase max depth from 2 up to SCC size
320    let max_depth = scc_nodes.len().min(12); // Cap depth to avoid very long cycles
321    for depth_limit in 2..=max_depth {
322        if cycles.len() >= max_cycles {
323            break;
324        }
325
326        for &start in &sorted_nodes {
327            if cycles.len() >= max_cycles {
328                break;
329            }
330
331            dfs_find_cycles_from(
332                start,
333                depth_limit,
334                &scc_set,
335                succs,
336                max_cycles,
337                &mut seen,
338                &mut cycles,
339            );
340        }
341    }
342
343    cycles
344}
345
346#[cfg(test)]
347mod tests {
348    use std::ops::Range;
349    use std::path::PathBuf;
350
351    use rustc_hash::FxHashSet;
352
353    use crate::graph::types::ModuleNode;
354    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
355    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
356    use fallow_types::extract::{ExportName, ImportInfo, ImportedName};
357
358    use super::{
359        ModuleGraph, SuccessorMap, canonical_cycle, dfs_find_cycles_from,
360        enumerate_elementary_cycles, try_record_cycle,
361    };
362
363    /// Helper: build a graph from files+edges, no entry points needed for cycle detection.
364    fn build_cycle_graph(file_count: usize, edges_spec: &[(u32, u32)]) -> ModuleGraph {
365        let files: Vec<DiscoveredFile> = (0..file_count)
366            .map(|i| DiscoveredFile {
367                id: FileId(i as u32),
368                path: PathBuf::from(format!("/project/file{i}.ts")),
369                size_bytes: 100,
370            })
371            .collect();
372
373        let resolved_modules: Vec<ResolvedModule> = (0..file_count)
374            .map(|i| {
375                let imports: Vec<ResolvedImport> = edges_spec
376                    .iter()
377                    .filter(|(src, _)| *src == i as u32)
378                    .map(|(_, tgt)| ResolvedImport {
379                        info: ImportInfo {
380                            source: format!("./file{tgt}"),
381                            imported_name: ImportedName::Named("x".to_string()),
382                            local_name: "x".to_string(),
383                            is_type_only: false,
384                            span: oxc_span::Span::new(0, 10),
385                            source_span: oxc_span::Span::default(),
386                        },
387                        target: ResolveResult::InternalModule(FileId(*tgt)),
388                    })
389                    .collect();
390
391                ResolvedModule {
392                    file_id: FileId(i as u32),
393                    path: PathBuf::from(format!("/project/file{i}.ts")),
394                    exports: vec![fallow_types::extract::ExportInfo {
395                        name: ExportName::Named("x".to_string()),
396                        local_name: Some("x".to_string()),
397                        is_type_only: false,
398                        is_public: false,
399                        span: oxc_span::Span::new(0, 20),
400                        members: vec![],
401                    }],
402                    re_exports: vec![],
403                    resolved_imports: imports,
404                    resolved_dynamic_imports: vec![],
405                    resolved_dynamic_patterns: vec![],
406                    member_accesses: vec![],
407                    whole_object_uses: vec![],
408                    has_cjs_exports: false,
409                    unused_import_bindings: vec![],
410                }
411            })
412            .collect();
413
414        let entry_points = vec![EntryPoint {
415            path: PathBuf::from("/project/file0.ts"),
416            source: EntryPointSource::PackageJsonMain,
417        }];
418
419        ModuleGraph::build(&resolved_modules, &entry_points, &files)
420    }
421
422    #[test]
423    fn find_cycles_empty_graph() {
424        let graph = ModuleGraph::build(&[], &[], &[]);
425        assert!(graph.find_cycles().is_empty());
426    }
427
428    #[test]
429    fn find_cycles_no_cycles() {
430        // A -> B -> C (no back edges)
431        let graph = build_cycle_graph(3, &[(0, 1), (1, 2)]);
432        assert!(graph.find_cycles().is_empty());
433    }
434
435    #[test]
436    fn find_cycles_simple_two_node_cycle() {
437        // A -> B -> A
438        let graph = build_cycle_graph(2, &[(0, 1), (1, 0)]);
439        let cycles = graph.find_cycles();
440        assert_eq!(cycles.len(), 1);
441        assert_eq!(cycles[0].len(), 2);
442    }
443
444    #[test]
445    fn find_cycles_three_node_cycle() {
446        // A -> B -> C -> A
447        let graph = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
448        let cycles = graph.find_cycles();
449        assert_eq!(cycles.len(), 1);
450        assert_eq!(cycles[0].len(), 3);
451    }
452
453    #[test]
454    fn find_cycles_self_import_ignored() {
455        // A -> A (self-import, should NOT be reported)
456        let graph = build_cycle_graph(1, &[(0, 0)]);
457        let cycles = graph.find_cycles();
458        assert!(
459            cycles.is_empty(),
460            "self-imports should not be reported as cycles"
461        );
462    }
463
464    #[test]
465    fn find_cycles_multiple_independent_cycles() {
466        // Cycle 1: A -> B -> A
467        // Cycle 2: C -> D -> C
468        // No connection between cycles
469        let graph = build_cycle_graph(4, &[(0, 1), (1, 0), (2, 3), (3, 2)]);
470        let cycles = graph.find_cycles();
471        assert_eq!(cycles.len(), 2);
472        // Both cycles should have length 2
473        assert!(cycles.iter().all(|c| c.len() == 2));
474    }
475
476    #[test]
477    fn find_cycles_linear_chain_with_back_edge() {
478        // A -> B -> C -> D -> B (cycle is B-C-D)
479        let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 3), (3, 1)]);
480        let cycles = graph.find_cycles();
481        assert_eq!(cycles.len(), 1);
482        assert_eq!(cycles[0].len(), 3);
483        // The cycle should contain files 1, 2, 3
484        let ids: Vec<u32> = cycles[0].iter().map(|f| f.0).collect();
485        assert!(ids.contains(&1));
486        assert!(ids.contains(&2));
487        assert!(ids.contains(&3));
488        assert!(!ids.contains(&0));
489    }
490
491    #[test]
492    fn find_cycles_overlapping_cycles_enumerated() {
493        // A -> B -> A, B -> C -> B => SCC is {A, B, C} but should report 2 elementary cycles
494        let graph = build_cycle_graph(3, &[(0, 1), (1, 0), (1, 2), (2, 1)]);
495        let cycles = graph.find_cycles();
496        assert_eq!(
497            cycles.len(),
498            2,
499            "should find 2 elementary cycles, not 1 SCC"
500        );
501        assert!(
502            cycles.iter().all(|c| c.len() == 2),
503            "both cycles should have length 2"
504        );
505    }
506
507    #[test]
508    fn find_cycles_deterministic_ordering() {
509        // Run twice with the same graph — results should be identical
510        let graph1 = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
511        let graph2 = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
512        let cycles1 = graph1.find_cycles();
513        let cycles2 = graph2.find_cycles();
514        assert_eq!(cycles1.len(), cycles2.len());
515        for (c1, c2) in cycles1.iter().zip(cycles2.iter()) {
516            let paths1: Vec<&PathBuf> = c1
517                .iter()
518                .map(|f| &graph1.modules[f.0 as usize].path)
519                .collect();
520            let paths2: Vec<&PathBuf> = c2
521                .iter()
522                .map(|f| &graph2.modules[f.0 as usize].path)
523                .collect();
524            assert_eq!(paths1, paths2);
525        }
526    }
527
528    #[test]
529    fn find_cycles_sorted_by_length() {
530        // Two cycles: A-B (len 2) and C-D-E (len 3)
531        let graph = build_cycle_graph(5, &[(0, 1), (1, 0), (2, 3), (3, 4), (4, 2)]);
532        let cycles = graph.find_cycles();
533        assert_eq!(cycles.len(), 2);
534        assert!(
535            cycles[0].len() <= cycles[1].len(),
536            "cycles should be sorted by length"
537        );
538    }
539
540    #[test]
541    fn find_cycles_large_cycle() {
542        // Chain of 10 nodes forming a single cycle: 0->1->2->...->9->0
543        let edges: Vec<(u32, u32)> = (0..10).map(|i| (i, (i + 1) % 10)).collect();
544        let graph = build_cycle_graph(10, &edges);
545        let cycles = graph.find_cycles();
546        assert_eq!(cycles.len(), 1);
547        assert_eq!(cycles[0].len(), 10);
548    }
549
550    #[test]
551    fn find_cycles_complex_scc_multiple_elementary() {
552        // A square: A->B, B->C, C->D, D->A, plus diagonal A->C
553        // Elementary cycles: A->B->C->D->A, A->C->D->A, and A->B->C->...
554        let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)]);
555        let cycles = graph.find_cycles();
556        // Should find multiple elementary cycles, not just one SCC of 4
557        assert!(
558            cycles.len() >= 2,
559            "should find at least 2 elementary cycles, got {}",
560            cycles.len()
561        );
562        // All cycles should be shorter than the full SCC
563        assert!(cycles.iter().all(|c| c.len() <= 4));
564    }
565
566    #[test]
567    fn find_cycles_no_duplicate_cycles() {
568        // Triangle: A->B->C->A — should find exactly 1 cycle, not duplicates
569        // from different DFS start points
570        let graph = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
571        let cycles = graph.find_cycles();
572        assert_eq!(cycles.len(), 1, "triangle should produce exactly 1 cycle");
573        assert_eq!(cycles[0].len(), 3);
574    }
575
576    // -----------------------------------------------------------------------
577    // Unit-level helpers for testing extracted functions directly
578    // -----------------------------------------------------------------------
579
580    /// Build lightweight `ModuleNode` stubs and successor data for unit tests.
581    ///
582    /// `edges_spec` is a list of (source, target) pairs (0-indexed).
583    /// Returns (modules, all_succs, succ_ranges) suitable for constructing a `SuccessorMap`.
584    fn build_test_succs(
585        file_count: usize,
586        edges_spec: &[(usize, usize)],
587    ) -> (Vec<ModuleNode>, Vec<usize>, Vec<Range<usize>>) {
588        let modules: Vec<ModuleNode> = (0..file_count)
589            .map(|i| ModuleNode {
590                file_id: FileId(i as u32),
591                path: PathBuf::from(format!("/project/file{i}.ts")),
592                edge_range: 0..0,
593                exports: vec![],
594                re_exports: vec![],
595                is_entry_point: i == 0,
596                is_reachable: true,
597                has_cjs_exports: false,
598            })
599            .collect();
600
601        let mut all_succs: Vec<usize> = Vec::new();
602        let mut succ_ranges: Vec<Range<usize>> = Vec::with_capacity(file_count);
603        for src in 0..file_count {
604            let start = all_succs.len();
605            let mut seen = FxHashSet::default();
606            for &(s, t) in edges_spec {
607                if s == src && t < file_count && seen.insert(t) {
608                    all_succs.push(t);
609                }
610            }
611            let end = all_succs.len();
612            succ_ranges.push(start..end);
613        }
614
615        (modules, all_succs, succ_ranges)
616    }
617
618    // -----------------------------------------------------------------------
619    // canonical_cycle tests
620    // -----------------------------------------------------------------------
621
622    #[test]
623    fn canonical_cycle_empty() {
624        let modules: Vec<ModuleNode> = vec![];
625        assert!(canonical_cycle(&[], &modules).is_empty());
626    }
627
628    #[test]
629    fn canonical_cycle_rotates_to_smallest_path() {
630        let (modules, _, _) = build_test_succs(3, &[]);
631        // Cycle [2, 0, 1] — file0 has the smallest path, so canonical is [0, 1, 2]
632        let result = canonical_cycle(&[2, 0, 1], &modules);
633        assert_eq!(result, vec![0, 1, 2]);
634    }
635
636    #[test]
637    fn canonical_cycle_already_canonical() {
638        let (modules, _, _) = build_test_succs(3, &[]);
639        let result = canonical_cycle(&[0, 1, 2], &modules);
640        assert_eq!(result, vec![0, 1, 2]);
641    }
642
643    #[test]
644    fn canonical_cycle_single_node() {
645        let (modules, _, _) = build_test_succs(1, &[]);
646        let result = canonical_cycle(&[0], &modules);
647        assert_eq!(result, vec![0]);
648    }
649
650    // -----------------------------------------------------------------------
651    // try_record_cycle tests
652    // -----------------------------------------------------------------------
653
654    #[test]
655    fn try_record_cycle_inserts_new_cycle() {
656        let (modules, _, _) = build_test_succs(3, &[]);
657        let mut seen = FxHashSet::default();
658        let mut cycles = Vec::new();
659
660        try_record_cycle(&[0, 1, 2], &modules, &mut seen, &mut cycles);
661        assert_eq!(cycles.len(), 1);
662        assert_eq!(cycles[0], vec![0, 1, 2]);
663    }
664
665    #[test]
666    fn try_record_cycle_deduplicates_rotated_cycle() {
667        // Same cycle in two rotations: [0,1,2] and [1,2,0]
668        // Both should canonicalize to the same key, so only one is recorded.
669        let (modules, _, _) = build_test_succs(3, &[]);
670        let mut seen = FxHashSet::default();
671        let mut cycles = Vec::new();
672
673        try_record_cycle(&[0, 1, 2], &modules, &mut seen, &mut cycles);
674        try_record_cycle(&[1, 2, 0], &modules, &mut seen, &mut cycles);
675        try_record_cycle(&[2, 0, 1], &modules, &mut seen, &mut cycles);
676
677        assert_eq!(
678            cycles.len(),
679            1,
680            "rotations of the same cycle should be deduped"
681        );
682    }
683
684    #[test]
685    fn try_record_cycle_single_node_self_loop() {
686        // A single-node "cycle" (self-loop) — should be recorded if passed in
687        let (modules, _, _) = build_test_succs(1, &[]);
688        let mut seen = FxHashSet::default();
689        let mut cycles = Vec::new();
690
691        try_record_cycle(&[0], &modules, &mut seen, &mut cycles);
692        assert_eq!(cycles.len(), 1);
693        assert_eq!(cycles[0], vec![0]);
694    }
695
696    #[test]
697    fn try_record_cycle_distinct_cycles_both_recorded() {
698        // Two genuinely different cycles
699        let (modules, _, _) = build_test_succs(4, &[]);
700        let mut seen = FxHashSet::default();
701        let mut cycles = Vec::new();
702
703        try_record_cycle(&[0, 1], &modules, &mut seen, &mut cycles);
704        try_record_cycle(&[2, 3], &modules, &mut seen, &mut cycles);
705
706        assert_eq!(cycles.len(), 2);
707    }
708
709    // -----------------------------------------------------------------------
710    // SuccessorMap construction tests
711    // -----------------------------------------------------------------------
712
713    #[test]
714    fn successor_map_empty_graph() {
715        let (modules, all_succs, succ_ranges) = build_test_succs(0, &[]);
716        let succs = SuccessorMap {
717            all_succs: &all_succs,
718            succ_ranges: &succ_ranges,
719            modules: &modules,
720        };
721        assert!(succs.all_succs.is_empty());
722        assert!(succs.succ_ranges.is_empty());
723    }
724
725    #[test]
726    fn successor_map_single_node_self_edge() {
727        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
728        let succs = SuccessorMap {
729            all_succs: &all_succs,
730            succ_ranges: &succ_ranges,
731            modules: &modules,
732        };
733        assert_eq!(succs.all_succs.len(), 1);
734        assert_eq!(succs.all_succs[0], 0);
735        assert_eq!(succs.succ_ranges[0], 0..1);
736    }
737
738    #[test]
739    fn successor_map_deduplicates_edges() {
740        // Two edges from 0 to 1 — should be deduped
741        let (modules, all_succs, succ_ranges) = build_test_succs(2, &[(0, 1), (0, 1)]);
742        let succs = SuccessorMap {
743            all_succs: &all_succs,
744            succ_ranges: &succ_ranges,
745            modules: &modules,
746        };
747        let range = &succs.succ_ranges[0];
748        assert_eq!(
749            range.end - range.start,
750            1,
751            "duplicate edges should be deduped"
752        );
753    }
754
755    #[test]
756    fn successor_map_multiple_successors() {
757        let (modules, all_succs, succ_ranges) = build_test_succs(4, &[(0, 1), (0, 2), (0, 3)]);
758        let succs = SuccessorMap {
759            all_succs: &all_succs,
760            succ_ranges: &succ_ranges,
761            modules: &modules,
762        };
763        let range = &succs.succ_ranges[0];
764        assert_eq!(range.end - range.start, 3);
765        // Node 1, 2, 3 have no successors
766        for i in 1..4 {
767            let r = &succs.succ_ranges[i];
768            assert_eq!(r.end - r.start, 0);
769        }
770    }
771
772    // -----------------------------------------------------------------------
773    // dfs_find_cycles_from tests
774    // -----------------------------------------------------------------------
775
776    #[test]
777    fn dfs_find_cycles_from_isolated_node() {
778        // Node 0 with no successors — should find no cycles
779        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[]);
780        let succs = SuccessorMap {
781            all_succs: &all_succs,
782            succ_ranges: &succ_ranges,
783            modules: &modules,
784        };
785        let scc_set: FxHashSet<usize> = [0].into_iter().collect();
786        let mut seen = FxHashSet::default();
787        let mut cycles = Vec::new();
788
789        dfs_find_cycles_from(0, 2, &scc_set, &succs, 10, &mut seen, &mut cycles);
790        assert!(cycles.is_empty(), "isolated node should have no cycles");
791    }
792
793    #[test]
794    fn dfs_find_cycles_from_simple_two_cycle() {
795        // 0 -> 1, 1 -> 0, both in SCC
796        let (modules, all_succs, succ_ranges) = build_test_succs(2, &[(0, 1), (1, 0)]);
797        let succs = SuccessorMap {
798            all_succs: &all_succs,
799            succ_ranges: &succ_ranges,
800            modules: &modules,
801        };
802        let scc_set: FxHashSet<usize> = [0, 1].into_iter().collect();
803        let mut seen = FxHashSet::default();
804        let mut cycles = Vec::new();
805
806        dfs_find_cycles_from(0, 2, &scc_set, &succs, 10, &mut seen, &mut cycles);
807        assert_eq!(cycles.len(), 1);
808        assert_eq!(cycles[0].len(), 2);
809    }
810
811    #[test]
812    fn dfs_find_cycles_from_diamond_graph() {
813        // Diamond: 0->1, 0->2, 1->3, 2->3, 3->0 (all in SCC)
814        // At depth 3: 0->1->3->0 and 0->2->3->0
815        // At depth 4: 0->1->3->?->0 — but 3 only goes to 0, so no 4-cycle
816        let (modules, all_succs, succ_ranges) =
817            build_test_succs(4, &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 0)]);
818        let succs = SuccessorMap {
819            all_succs: &all_succs,
820            succ_ranges: &succ_ranges,
821            modules: &modules,
822        };
823        let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
824        let mut seen = FxHashSet::default();
825        let mut cycles = Vec::new();
826
827        // Depth 3: should find two 3-node cycles
828        dfs_find_cycles_from(0, 3, &scc_set, &succs, 10, &mut seen, &mut cycles);
829        assert_eq!(cycles.len(), 2, "diamond should have two 3-node cycles");
830        assert!(cycles.iter().all(|c| c.len() == 3));
831    }
832
833    #[test]
834    fn dfs_find_cycles_from_depth_limit_prevents_longer_cycles() {
835        // 0->1->2->3->0 forms a 4-cycle
836        // With depth_limit=3, the DFS should NOT find this 4-cycle
837        let (modules, all_succs, succ_ranges) =
838            build_test_succs(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
839        let succs = SuccessorMap {
840            all_succs: &all_succs,
841            succ_ranges: &succ_ranges,
842            modules: &modules,
843        };
844        let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
845        let mut seen = FxHashSet::default();
846        let mut cycles = Vec::new();
847
848        dfs_find_cycles_from(0, 3, &scc_set, &succs, 10, &mut seen, &mut cycles);
849        assert!(
850            cycles.is_empty(),
851            "depth_limit=3 should prevent finding a 4-node cycle"
852        );
853    }
854
855    #[test]
856    fn dfs_find_cycles_from_depth_limit_exact_match() {
857        // 0->1->2->3->0 forms a 4-cycle
858        // With depth_limit=4, the DFS should find it
859        let (modules, all_succs, succ_ranges) =
860            build_test_succs(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
861        let succs = SuccessorMap {
862            all_succs: &all_succs,
863            succ_ranges: &succ_ranges,
864            modules: &modules,
865        };
866        let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
867        let mut seen = FxHashSet::default();
868        let mut cycles = Vec::new();
869
870        dfs_find_cycles_from(0, 4, &scc_set, &succs, 10, &mut seen, &mut cycles);
871        assert_eq!(
872            cycles.len(),
873            1,
874            "depth_limit=4 should find the 4-node cycle"
875        );
876        assert_eq!(cycles[0].len(), 4);
877    }
878
879    #[test]
880    fn dfs_find_cycles_from_respects_max_cycles() {
881        // Dense graph: complete graph of 4 nodes — many cycles
882        let edges: Vec<(usize, usize)> = (0..4)
883            .flat_map(|i| (0..4).filter(move |&j| i != j).map(move |j| (i, j)))
884            .collect();
885        let (modules, all_succs, succ_ranges) = build_test_succs(4, &edges);
886        let succs = SuccessorMap {
887            all_succs: &all_succs,
888            succ_ranges: &succ_ranges,
889            modules: &modules,
890        };
891        let scc_set: FxHashSet<usize> = (0..4).collect();
892        let mut seen = FxHashSet::default();
893        let mut cycles = Vec::new();
894
895        // max_cycles = 2: should stop after finding 2
896        dfs_find_cycles_from(0, 2, &scc_set, &succs, 2, &mut seen, &mut cycles);
897        assert!(
898            cycles.len() <= 2,
899            "should respect max_cycles limit, got {}",
900            cycles.len()
901        );
902    }
903
904    #[test]
905    fn dfs_find_cycles_from_ignores_nodes_outside_scc() {
906        // 0->1->2->0 but only {0, 1} in SCC set — node 2 should be ignored
907        let (modules, all_succs, succ_ranges) = build_test_succs(3, &[(0, 1), (1, 2), (2, 0)]);
908        let succs = SuccessorMap {
909            all_succs: &all_succs,
910            succ_ranges: &succ_ranges,
911            modules: &modules,
912        };
913        let scc_set: FxHashSet<usize> = [0, 1].into_iter().collect();
914        let mut seen = FxHashSet::default();
915        let mut cycles = Vec::new();
916
917        for depth in 2..=3 {
918            dfs_find_cycles_from(0, depth, &scc_set, &succs, 10, &mut seen, &mut cycles);
919        }
920        assert!(
921            cycles.is_empty(),
922            "should not find cycles through nodes outside the SCC set"
923        );
924    }
925
926    // -----------------------------------------------------------------------
927    // enumerate_elementary_cycles tests
928    // -----------------------------------------------------------------------
929
930    #[test]
931    fn enumerate_elementary_cycles_empty_scc() {
932        let (modules, all_succs, succ_ranges) = build_test_succs(0, &[]);
933        let succs = SuccessorMap {
934            all_succs: &all_succs,
935            succ_ranges: &succ_ranges,
936            modules: &modules,
937        };
938        let cycles = enumerate_elementary_cycles(&[], &succs, 10);
939        assert!(cycles.is_empty());
940    }
941
942    #[test]
943    fn enumerate_elementary_cycles_max_cycles_limit() {
944        // Complete graph of 4 nodes — many elementary cycles
945        let edges: Vec<(usize, usize)> = (0..4)
946            .flat_map(|i| (0..4).filter(move |&j| i != j).map(move |j| (i, j)))
947            .collect();
948        let (modules, all_succs, succ_ranges) = build_test_succs(4, &edges);
949        let succs = SuccessorMap {
950            all_succs: &all_succs,
951            succ_ranges: &succ_ranges,
952            modules: &modules,
953        };
954        let scc_nodes: Vec<usize> = (0..4).collect();
955
956        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 3);
957        assert!(
958            cycles.len() <= 3,
959            "should respect max_cycles=3 limit, got {}",
960            cycles.len()
961        );
962    }
963
964    #[test]
965    fn enumerate_elementary_cycles_finds_all_in_triangle() {
966        // 0->1->2->0 — single elementary cycle
967        let (modules, all_succs, succ_ranges) = build_test_succs(3, &[(0, 1), (1, 2), (2, 0)]);
968        let succs = SuccessorMap {
969            all_succs: &all_succs,
970            succ_ranges: &succ_ranges,
971            modules: &modules,
972        };
973        let scc_nodes: Vec<usize> = vec![0, 1, 2];
974
975        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
976        assert_eq!(cycles.len(), 1);
977        assert_eq!(cycles[0].len(), 3);
978    }
979
980    #[test]
981    fn enumerate_elementary_cycles_iterative_deepening_order() {
982        // SCC with both 2-node and 3-node cycles
983        // 0->1->0 (2-cycle) and 0->1->2->0 (3-cycle)
984        let (modules, all_succs, succ_ranges) =
985            build_test_succs(3, &[(0, 1), (1, 0), (1, 2), (2, 0)]);
986        let succs = SuccessorMap {
987            all_succs: &all_succs,
988            succ_ranges: &succ_ranges,
989            modules: &modules,
990        };
991        let scc_nodes: Vec<usize> = vec![0, 1, 2];
992
993        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
994        assert!(cycles.len() >= 2, "should find at least 2 cycles");
995        // Iterative deepening: shorter cycles should come first
996        assert!(
997            cycles[0].len() <= cycles[cycles.len() - 1].len(),
998            "shorter cycles should be found before longer ones"
999        );
1000    }
1001
1002    // -----------------------------------------------------------------------
1003    // Integration-level edge cases
1004    // -----------------------------------------------------------------------
1005
1006    #[test]
1007    fn find_cycles_max_cycles_per_scc_respected() {
1008        // Dense SCC (complete graph of 5 nodes) — should cap at MAX_CYCLES_PER_SCC (20)
1009        let edges: Vec<(u32, u32)> = (0..5)
1010            .flat_map(|i| (0..5).filter(move |&j| i != j).map(move |j| (i, j)))
1011            .collect();
1012        let graph = build_cycle_graph(5, &edges);
1013        let cycles = graph.find_cycles();
1014        // K5 has many elementary cycles, but we cap at 20 per SCC
1015        assert!(
1016            cycles.len() <= 20,
1017            "should cap at MAX_CYCLES_PER_SCC, got {}",
1018            cycles.len()
1019        );
1020        assert!(
1021            !cycles.is_empty(),
1022            "dense graph should still find some cycles"
1023        );
1024    }
1025
1026    #[test]
1027    fn find_cycles_graph_with_no_cycles_returns_empty() {
1028        // Star topology: center -> all leaves, no cycles possible
1029        let graph = build_cycle_graph(5, &[(0, 1), (0, 2), (0, 3), (0, 4)]);
1030        assert!(graph.find_cycles().is_empty());
1031    }
1032
1033    #[test]
1034    fn find_cycles_diamond_no_cycle() {
1035        // Diamond without back-edge: A->B, A->C, B->D, C->D — no cycle
1036        let graph = build_cycle_graph(4, &[(0, 1), (0, 2), (1, 3), (2, 3)]);
1037        assert!(graph.find_cycles().is_empty());
1038    }
1039
1040    #[test]
1041    fn find_cycles_diamond_with_back_edge() {
1042        // Diamond with back-edge: A->B, A->C, B->D, C->D, D->A
1043        let graph = build_cycle_graph(4, &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 0)]);
1044        let cycles = graph.find_cycles();
1045        assert!(
1046            cycles.len() >= 2,
1047            "diamond with back-edge should have at least 2 elementary cycles, got {}",
1048            cycles.len()
1049        );
1050        // Shortest cycles should be length 3 (A->B->D->A and A->C->D->A)
1051        assert_eq!(cycles[0].len(), 3);
1052    }
1053}