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 as a cycle).
456        // Reason: Tarjan's SCC only reports components with >= 2 nodes,
457        // so a single-node self-edge never forms a reportable cycle.
458        let graph = build_cycle_graph(1, &[(0, 0)]);
459        let cycles = graph.find_cycles();
460        assert!(
461            cycles.is_empty(),
462            "self-imports should not be reported as cycles"
463        );
464    }
465
466    #[test]
467    fn find_cycles_multiple_independent_cycles() {
468        // Cycle 1: A -> B -> A
469        // Cycle 2: C -> D -> C
470        // No connection between cycles
471        let graph = build_cycle_graph(4, &[(0, 1), (1, 0), (2, 3), (3, 2)]);
472        let cycles = graph.find_cycles();
473        assert_eq!(cycles.len(), 2);
474        // Both cycles should have length 2
475        assert!(cycles.iter().all(|c| c.len() == 2));
476    }
477
478    #[test]
479    fn find_cycles_linear_chain_with_back_edge() {
480        // A -> B -> C -> D -> B (cycle is B-C-D)
481        let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 3), (3, 1)]);
482        let cycles = graph.find_cycles();
483        assert_eq!(cycles.len(), 1);
484        assert_eq!(cycles[0].len(), 3);
485        // The cycle should contain files 1, 2, 3
486        let ids: Vec<u32> = cycles[0].iter().map(|f| f.0).collect();
487        assert!(ids.contains(&1));
488        assert!(ids.contains(&2));
489        assert!(ids.contains(&3));
490        assert!(!ids.contains(&0));
491    }
492
493    #[test]
494    fn find_cycles_overlapping_cycles_enumerated() {
495        // A -> B -> A, B -> C -> B => SCC is {A, B, C} but should report 2 elementary cycles
496        let graph = build_cycle_graph(3, &[(0, 1), (1, 0), (1, 2), (2, 1)]);
497        let cycles = graph.find_cycles();
498        assert_eq!(
499            cycles.len(),
500            2,
501            "should find 2 elementary cycles, not 1 SCC"
502        );
503        assert!(
504            cycles.iter().all(|c| c.len() == 2),
505            "both cycles should have length 2"
506        );
507    }
508
509    #[test]
510    fn find_cycles_deterministic_ordering() {
511        // Run twice with the same graph — results should be identical
512        let graph1 = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
513        let graph2 = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
514        let cycles1 = graph1.find_cycles();
515        let cycles2 = graph2.find_cycles();
516        assert_eq!(cycles1.len(), cycles2.len());
517        for (c1, c2) in cycles1.iter().zip(cycles2.iter()) {
518            let paths1: Vec<&PathBuf> = c1
519                .iter()
520                .map(|f| &graph1.modules[f.0 as usize].path)
521                .collect();
522            let paths2: Vec<&PathBuf> = c2
523                .iter()
524                .map(|f| &graph2.modules[f.0 as usize].path)
525                .collect();
526            assert_eq!(paths1, paths2);
527        }
528    }
529
530    #[test]
531    fn find_cycles_sorted_by_length() {
532        // Two cycles: A-B (len 2) and C-D-E (len 3)
533        let graph = build_cycle_graph(5, &[(0, 1), (1, 0), (2, 3), (3, 4), (4, 2)]);
534        let cycles = graph.find_cycles();
535        assert_eq!(cycles.len(), 2);
536        assert!(
537            cycles[0].len() <= cycles[1].len(),
538            "cycles should be sorted by length"
539        );
540    }
541
542    #[test]
543    fn find_cycles_large_cycle() {
544        // Chain of 10 nodes forming a single cycle: 0->1->2->...->9->0
545        let edges: Vec<(u32, u32)> = (0..10).map(|i| (i, (i + 1) % 10)).collect();
546        let graph = build_cycle_graph(10, &edges);
547        let cycles = graph.find_cycles();
548        assert_eq!(cycles.len(), 1);
549        assert_eq!(cycles[0].len(), 10);
550    }
551
552    #[test]
553    fn find_cycles_complex_scc_multiple_elementary() {
554        // A square: A->B, B->C, C->D, D->A, plus diagonal A->C
555        // Elementary cycles: A->B->C->D->A, A->C->D->A, and A->B->C->...
556        let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)]);
557        let cycles = graph.find_cycles();
558        // Should find multiple elementary cycles, not just one SCC of 4
559        assert!(
560            cycles.len() >= 2,
561            "should find at least 2 elementary cycles, got {}",
562            cycles.len()
563        );
564        // All cycles should be shorter than the full SCC
565        assert!(cycles.iter().all(|c| c.len() <= 4));
566    }
567
568    #[test]
569    fn find_cycles_no_duplicate_cycles() {
570        // Triangle: A->B->C->A — should find exactly 1 cycle, not duplicates
571        // from different DFS start points
572        let graph = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
573        let cycles = graph.find_cycles();
574        assert_eq!(cycles.len(), 1, "triangle should produce exactly 1 cycle");
575        assert_eq!(cycles[0].len(), 3);
576    }
577
578    // -----------------------------------------------------------------------
579    // Unit-level helpers for testing extracted functions directly
580    // -----------------------------------------------------------------------
581
582    /// Build lightweight `ModuleNode` stubs and successor data for unit tests.
583    ///
584    /// `edges_spec` is a list of (source, target) pairs (0-indexed).
585    /// Returns (modules, all_succs, succ_ranges) suitable for constructing a `SuccessorMap`.
586    fn build_test_succs(
587        file_count: usize,
588        edges_spec: &[(usize, usize)],
589    ) -> (Vec<ModuleNode>, Vec<usize>, Vec<Range<usize>>) {
590        let modules: Vec<ModuleNode> = (0..file_count)
591            .map(|i| ModuleNode {
592                file_id: FileId(i as u32),
593                path: PathBuf::from(format!("/project/file{i}.ts")),
594                edge_range: 0..0,
595                exports: vec![],
596                re_exports: vec![],
597                is_entry_point: i == 0,
598                is_reachable: true,
599                has_cjs_exports: false,
600            })
601            .collect();
602
603        let mut all_succs: Vec<usize> = Vec::new();
604        let mut succ_ranges: Vec<Range<usize>> = Vec::with_capacity(file_count);
605        for src in 0..file_count {
606            let start = all_succs.len();
607            let mut seen = FxHashSet::default();
608            for &(s, t) in edges_spec {
609                if s == src && t < file_count && seen.insert(t) {
610                    all_succs.push(t);
611                }
612            }
613            let end = all_succs.len();
614            succ_ranges.push(start..end);
615        }
616
617        (modules, all_succs, succ_ranges)
618    }
619
620    // -----------------------------------------------------------------------
621    // canonical_cycle tests
622    // -----------------------------------------------------------------------
623
624    #[test]
625    fn canonical_cycle_empty() {
626        let modules: Vec<ModuleNode> = vec![];
627        assert!(canonical_cycle(&[], &modules).is_empty());
628    }
629
630    #[test]
631    fn canonical_cycle_rotates_to_smallest_path() {
632        let (modules, _, _) = build_test_succs(3, &[]);
633        // Cycle [2, 0, 1] — file0 has the smallest path, so canonical is [0, 1, 2]
634        let result = canonical_cycle(&[2, 0, 1], &modules);
635        assert_eq!(result, vec![0, 1, 2]);
636    }
637
638    #[test]
639    fn canonical_cycle_already_canonical() {
640        let (modules, _, _) = build_test_succs(3, &[]);
641        let result = canonical_cycle(&[0, 1, 2], &modules);
642        assert_eq!(result, vec![0, 1, 2]);
643    }
644
645    #[test]
646    fn canonical_cycle_single_node() {
647        let (modules, _, _) = build_test_succs(1, &[]);
648        let result = canonical_cycle(&[0], &modules);
649        assert_eq!(result, vec![0]);
650    }
651
652    // -----------------------------------------------------------------------
653    // try_record_cycle tests
654    // -----------------------------------------------------------------------
655
656    #[test]
657    fn try_record_cycle_inserts_new_cycle() {
658        let (modules, _, _) = build_test_succs(3, &[]);
659        let mut seen = FxHashSet::default();
660        let mut cycles = Vec::new();
661
662        try_record_cycle(&[0, 1, 2], &modules, &mut seen, &mut cycles);
663        assert_eq!(cycles.len(), 1);
664        assert_eq!(cycles[0], vec![0, 1, 2]);
665    }
666
667    #[test]
668    fn try_record_cycle_deduplicates_rotated_cycle() {
669        // Same cycle in two rotations: [0,1,2] and [1,2,0]
670        // Both should canonicalize to the same key, so only one is recorded.
671        let (modules, _, _) = build_test_succs(3, &[]);
672        let mut seen = FxHashSet::default();
673        let mut cycles = Vec::new();
674
675        try_record_cycle(&[0, 1, 2], &modules, &mut seen, &mut cycles);
676        try_record_cycle(&[1, 2, 0], &modules, &mut seen, &mut cycles);
677        try_record_cycle(&[2, 0, 1], &modules, &mut seen, &mut cycles);
678
679        assert_eq!(
680            cycles.len(),
681            1,
682            "rotations of the same cycle should be deduped"
683        );
684    }
685
686    #[test]
687    fn try_record_cycle_single_node_self_loop() {
688        // A single-node "cycle" (self-loop) — should be recorded if passed in
689        let (modules, _, _) = build_test_succs(1, &[]);
690        let mut seen = FxHashSet::default();
691        let mut cycles = Vec::new();
692
693        try_record_cycle(&[0], &modules, &mut seen, &mut cycles);
694        assert_eq!(cycles.len(), 1);
695        assert_eq!(cycles[0], vec![0]);
696    }
697
698    #[test]
699    fn try_record_cycle_distinct_cycles_both_recorded() {
700        // Two genuinely different cycles
701        let (modules, _, _) = build_test_succs(4, &[]);
702        let mut seen = FxHashSet::default();
703        let mut cycles = Vec::new();
704
705        try_record_cycle(&[0, 1], &modules, &mut seen, &mut cycles);
706        try_record_cycle(&[2, 3], &modules, &mut seen, &mut cycles);
707
708        assert_eq!(cycles.len(), 2);
709    }
710
711    // -----------------------------------------------------------------------
712    // SuccessorMap construction tests
713    // -----------------------------------------------------------------------
714
715    #[test]
716    fn successor_map_empty_graph() {
717        let (modules, all_succs, succ_ranges) = build_test_succs(0, &[]);
718        let succs = SuccessorMap {
719            all_succs: &all_succs,
720            succ_ranges: &succ_ranges,
721            modules: &modules,
722        };
723        assert!(succs.all_succs.is_empty());
724        assert!(succs.succ_ranges.is_empty());
725    }
726
727    #[test]
728    fn successor_map_single_node_self_edge() {
729        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
730        let succs = SuccessorMap {
731            all_succs: &all_succs,
732            succ_ranges: &succ_ranges,
733            modules: &modules,
734        };
735        assert_eq!(succs.all_succs.len(), 1);
736        assert_eq!(succs.all_succs[0], 0);
737        assert_eq!(succs.succ_ranges[0], 0..1);
738    }
739
740    #[test]
741    fn successor_map_deduplicates_edges() {
742        // Two edges from 0 to 1 — should be deduped
743        let (modules, all_succs, succ_ranges) = build_test_succs(2, &[(0, 1), (0, 1)]);
744        let succs = SuccessorMap {
745            all_succs: &all_succs,
746            succ_ranges: &succ_ranges,
747            modules: &modules,
748        };
749        let range = &succs.succ_ranges[0];
750        assert_eq!(
751            range.end - range.start,
752            1,
753            "duplicate edges should be deduped"
754        );
755    }
756
757    #[test]
758    fn successor_map_multiple_successors() {
759        let (modules, all_succs, succ_ranges) = build_test_succs(4, &[(0, 1), (0, 2), (0, 3)]);
760        let succs = SuccessorMap {
761            all_succs: &all_succs,
762            succ_ranges: &succ_ranges,
763            modules: &modules,
764        };
765        let range = &succs.succ_ranges[0];
766        assert_eq!(range.end - range.start, 3);
767        // Node 1, 2, 3 have no successors
768        for i in 1..4 {
769            let r = &succs.succ_ranges[i];
770            assert_eq!(r.end - r.start, 0);
771        }
772    }
773
774    // -----------------------------------------------------------------------
775    // dfs_find_cycles_from tests
776    // -----------------------------------------------------------------------
777
778    #[test]
779    fn dfs_find_cycles_from_isolated_node() {
780        // Node 0 with no successors — should find no cycles
781        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[]);
782        let succs = SuccessorMap {
783            all_succs: &all_succs,
784            succ_ranges: &succ_ranges,
785            modules: &modules,
786        };
787        let scc_set: FxHashSet<usize> = [0].into_iter().collect();
788        let mut seen = FxHashSet::default();
789        let mut cycles = Vec::new();
790
791        dfs_find_cycles_from(0, 2, &scc_set, &succs, 10, &mut seen, &mut cycles);
792        assert!(cycles.is_empty(), "isolated node should have no cycles");
793    }
794
795    #[test]
796    fn dfs_find_cycles_from_simple_two_cycle() {
797        // 0 -> 1, 1 -> 0, both in SCC
798        let (modules, all_succs, succ_ranges) = build_test_succs(2, &[(0, 1), (1, 0)]);
799        let succs = SuccessorMap {
800            all_succs: &all_succs,
801            succ_ranges: &succ_ranges,
802            modules: &modules,
803        };
804        let scc_set: FxHashSet<usize> = [0, 1].into_iter().collect();
805        let mut seen = FxHashSet::default();
806        let mut cycles = Vec::new();
807
808        dfs_find_cycles_from(0, 2, &scc_set, &succs, 10, &mut seen, &mut cycles);
809        assert_eq!(cycles.len(), 1);
810        assert_eq!(cycles[0].len(), 2);
811    }
812
813    #[test]
814    fn dfs_find_cycles_from_diamond_graph() {
815        // Diamond: 0->1, 0->2, 1->3, 2->3, 3->0 (all in SCC)
816        // At depth 3: 0->1->3->0 and 0->2->3->0
817        // At depth 4: 0->1->3->?->0 — but 3 only goes to 0, so no 4-cycle
818        let (modules, all_succs, succ_ranges) =
819            build_test_succs(4, &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 0)]);
820        let succs = SuccessorMap {
821            all_succs: &all_succs,
822            succ_ranges: &succ_ranges,
823            modules: &modules,
824        };
825        let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
826        let mut seen = FxHashSet::default();
827        let mut cycles = Vec::new();
828
829        // Depth 3: should find two 3-node cycles
830        dfs_find_cycles_from(0, 3, &scc_set, &succs, 10, &mut seen, &mut cycles);
831        assert_eq!(cycles.len(), 2, "diamond should have two 3-node cycles");
832        assert!(cycles.iter().all(|c| c.len() == 3));
833    }
834
835    #[test]
836    fn dfs_find_cycles_from_depth_limit_prevents_longer_cycles() {
837        // 0->1->2->3->0 forms a 4-cycle
838        // With depth_limit=3, the DFS should NOT find this 4-cycle
839        let (modules, all_succs, succ_ranges) =
840            build_test_succs(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
841        let succs = SuccessorMap {
842            all_succs: &all_succs,
843            succ_ranges: &succ_ranges,
844            modules: &modules,
845        };
846        let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
847        let mut seen = FxHashSet::default();
848        let mut cycles = Vec::new();
849
850        dfs_find_cycles_from(0, 3, &scc_set, &succs, 10, &mut seen, &mut cycles);
851        assert!(
852            cycles.is_empty(),
853            "depth_limit=3 should prevent finding a 4-node cycle"
854        );
855    }
856
857    #[test]
858    fn dfs_find_cycles_from_depth_limit_exact_match() {
859        // 0->1->2->3->0 forms a 4-cycle
860        // With depth_limit=4, the DFS should find it
861        let (modules, all_succs, succ_ranges) =
862            build_test_succs(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
863        let succs = SuccessorMap {
864            all_succs: &all_succs,
865            succ_ranges: &succ_ranges,
866            modules: &modules,
867        };
868        let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
869        let mut seen = FxHashSet::default();
870        let mut cycles = Vec::new();
871
872        dfs_find_cycles_from(0, 4, &scc_set, &succs, 10, &mut seen, &mut cycles);
873        assert_eq!(
874            cycles.len(),
875            1,
876            "depth_limit=4 should find the 4-node cycle"
877        );
878        assert_eq!(cycles[0].len(), 4);
879    }
880
881    #[test]
882    fn dfs_find_cycles_from_respects_max_cycles() {
883        // Dense graph: complete graph of 4 nodes — many cycles
884        let edges: Vec<(usize, usize)> = (0..4)
885            .flat_map(|i| (0..4).filter(move |&j| i != j).map(move |j| (i, j)))
886            .collect();
887        let (modules, all_succs, succ_ranges) = build_test_succs(4, &edges);
888        let succs = SuccessorMap {
889            all_succs: &all_succs,
890            succ_ranges: &succ_ranges,
891            modules: &modules,
892        };
893        let scc_set: FxHashSet<usize> = (0..4).collect();
894        let mut seen = FxHashSet::default();
895        let mut cycles = Vec::new();
896
897        // max_cycles = 2: should stop after finding 2
898        dfs_find_cycles_from(0, 2, &scc_set, &succs, 2, &mut seen, &mut cycles);
899        assert!(
900            cycles.len() <= 2,
901            "should respect max_cycles limit, got {}",
902            cycles.len()
903        );
904    }
905
906    #[test]
907    fn dfs_find_cycles_from_ignores_nodes_outside_scc() {
908        // 0->1->2->0 but only {0, 1} in SCC set — node 2 should be ignored
909        let (modules, all_succs, succ_ranges) = build_test_succs(3, &[(0, 1), (1, 2), (2, 0)]);
910        let succs = SuccessorMap {
911            all_succs: &all_succs,
912            succ_ranges: &succ_ranges,
913            modules: &modules,
914        };
915        let scc_set: FxHashSet<usize> = [0, 1].into_iter().collect();
916        let mut seen = FxHashSet::default();
917        let mut cycles = Vec::new();
918
919        for depth in 2..=3 {
920            dfs_find_cycles_from(0, depth, &scc_set, &succs, 10, &mut seen, &mut cycles);
921        }
922        assert!(
923            cycles.is_empty(),
924            "should not find cycles through nodes outside the SCC set"
925        );
926    }
927
928    // -----------------------------------------------------------------------
929    // enumerate_elementary_cycles tests
930    // -----------------------------------------------------------------------
931
932    #[test]
933    fn enumerate_elementary_cycles_empty_scc() {
934        let (modules, all_succs, succ_ranges) = build_test_succs(0, &[]);
935        let succs = SuccessorMap {
936            all_succs: &all_succs,
937            succ_ranges: &succ_ranges,
938            modules: &modules,
939        };
940        let cycles = enumerate_elementary_cycles(&[], &succs, 10);
941        assert!(cycles.is_empty());
942    }
943
944    #[test]
945    fn enumerate_elementary_cycles_max_cycles_limit() {
946        // Complete graph of 4 nodes — many elementary cycles
947        let edges: Vec<(usize, usize)> = (0..4)
948            .flat_map(|i| (0..4).filter(move |&j| i != j).map(move |j| (i, j)))
949            .collect();
950        let (modules, all_succs, succ_ranges) = build_test_succs(4, &edges);
951        let succs = SuccessorMap {
952            all_succs: &all_succs,
953            succ_ranges: &succ_ranges,
954            modules: &modules,
955        };
956        let scc_nodes: Vec<usize> = (0..4).collect();
957
958        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 3);
959        assert!(
960            cycles.len() <= 3,
961            "should respect max_cycles=3 limit, got {}",
962            cycles.len()
963        );
964    }
965
966    #[test]
967    fn enumerate_elementary_cycles_finds_all_in_triangle() {
968        // 0->1->2->0 — single elementary cycle
969        let (modules, all_succs, succ_ranges) = build_test_succs(3, &[(0, 1), (1, 2), (2, 0)]);
970        let succs = SuccessorMap {
971            all_succs: &all_succs,
972            succ_ranges: &succ_ranges,
973            modules: &modules,
974        };
975        let scc_nodes: Vec<usize> = vec![0, 1, 2];
976
977        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
978        assert_eq!(cycles.len(), 1);
979        assert_eq!(cycles[0].len(), 3);
980    }
981
982    #[test]
983    fn enumerate_elementary_cycles_iterative_deepening_order() {
984        // SCC with both 2-node and 3-node cycles
985        // 0->1->0 (2-cycle) and 0->1->2->0 (3-cycle)
986        let (modules, all_succs, succ_ranges) =
987            build_test_succs(3, &[(0, 1), (1, 0), (1, 2), (2, 0)]);
988        let succs = SuccessorMap {
989            all_succs: &all_succs,
990            succ_ranges: &succ_ranges,
991            modules: &modules,
992        };
993        let scc_nodes: Vec<usize> = vec![0, 1, 2];
994
995        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
996        assert!(cycles.len() >= 2, "should find at least 2 cycles");
997        // Iterative deepening: shorter cycles should come first
998        assert!(
999            cycles[0].len() <= cycles[cycles.len() - 1].len(),
1000            "shorter cycles should be found before longer ones"
1001        );
1002    }
1003
1004    // -----------------------------------------------------------------------
1005    // Integration-level edge cases
1006    // -----------------------------------------------------------------------
1007
1008    #[test]
1009    fn find_cycles_max_cycles_per_scc_respected() {
1010        // Dense SCC (complete graph of 5 nodes) — should cap at MAX_CYCLES_PER_SCC (20)
1011        let edges: Vec<(u32, u32)> = (0..5)
1012            .flat_map(|i| (0..5).filter(move |&j| i != j).map(move |j| (i, j)))
1013            .collect();
1014        let graph = build_cycle_graph(5, &edges);
1015        let cycles = graph.find_cycles();
1016        // K5 has many elementary cycles, but we cap at 20 per SCC
1017        assert!(
1018            cycles.len() <= 20,
1019            "should cap at MAX_CYCLES_PER_SCC, got {}",
1020            cycles.len()
1021        );
1022        assert!(
1023            !cycles.is_empty(),
1024            "dense graph should still find some cycles"
1025        );
1026    }
1027
1028    #[test]
1029    fn find_cycles_graph_with_no_cycles_returns_empty() {
1030        // Star topology: center -> all leaves, no cycles possible
1031        let graph = build_cycle_graph(5, &[(0, 1), (0, 2), (0, 3), (0, 4)]);
1032        assert!(graph.find_cycles().is_empty());
1033    }
1034
1035    #[test]
1036    fn find_cycles_diamond_no_cycle() {
1037        // Diamond without back-edge: A->B, A->C, B->D, C->D — no cycle
1038        let graph = build_cycle_graph(4, &[(0, 1), (0, 2), (1, 3), (2, 3)]);
1039        assert!(graph.find_cycles().is_empty());
1040    }
1041
1042    #[test]
1043    fn find_cycles_diamond_with_back_edge() {
1044        // Diamond with back-edge: A->B, A->C, B->D, C->D, D->A
1045        let graph = build_cycle_graph(4, &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 0)]);
1046        let cycles = graph.find_cycles();
1047        assert!(
1048            cycles.len() >= 2,
1049            "diamond with back-edge should have at least 2 elementary cycles, got {}",
1050            cycles.len()
1051        );
1052        // Shortest cycles should be length 3 (A->B->D->A and A->C->D->A)
1053        assert_eq!(cycles[0].len(), 3);
1054    }
1055
1056    // -----------------------------------------------------------------------
1057    // Additional canonical_cycle tests
1058    // -----------------------------------------------------------------------
1059
1060    #[test]
1061    fn canonical_cycle_non_sequential_indices() {
1062        // Cycle with non-sequential node indices [3, 1, 4] — file1 has smallest path
1063        let (modules, _, _) = build_test_succs(5, &[]);
1064        let result = canonical_cycle(&[3, 1, 4], &modules);
1065        // file1 has path "/project/file1.ts" which is smallest, so rotation starts there
1066        assert_eq!(result, vec![1, 4, 3]);
1067    }
1068
1069    #[test]
1070    fn canonical_cycle_different_starting_points_same_result() {
1071        // The same logical cycle [0, 1, 2, 3] presented from four different starting points
1072        // should always canonicalize to [0, 1, 2, 3] since file0 has the smallest path.
1073        let (modules, _, _) = build_test_succs(4, &[]);
1074        let r1 = canonical_cycle(&[0, 1, 2, 3], &modules);
1075        let r2 = canonical_cycle(&[1, 2, 3, 0], &modules);
1076        let r3 = canonical_cycle(&[2, 3, 0, 1], &modules);
1077        let r4 = canonical_cycle(&[3, 0, 1, 2], &modules);
1078        assert_eq!(r1, r2);
1079        assert_eq!(r2, r3);
1080        assert_eq!(r3, r4);
1081        assert_eq!(r1, vec![0, 1, 2, 3]);
1082    }
1083
1084    #[test]
1085    fn canonical_cycle_two_node_both_rotations() {
1086        // Two-node cycle: [0, 1] and [1, 0] should both canonicalize to [0, 1]
1087        let (modules, _, _) = build_test_succs(2, &[]);
1088        assert_eq!(canonical_cycle(&[0, 1], &modules), vec![0, 1]);
1089        assert_eq!(canonical_cycle(&[1, 0], &modules), vec![0, 1]);
1090    }
1091
1092    // -----------------------------------------------------------------------
1093    // Self-loop unit-level tests
1094    // -----------------------------------------------------------------------
1095
1096    #[test]
1097    fn dfs_find_cycles_from_self_loop_not_found() {
1098        // Node 0 has a self-edge (0->0). The DFS requires path.len() >= 2 for a cycle,
1099        // so a self-loop should not be detected as a cycle.
1100        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
1101        let succs = SuccessorMap {
1102            all_succs: &all_succs,
1103            succ_ranges: &succ_ranges,
1104            modules: &modules,
1105        };
1106        let scc_set: FxHashSet<usize> = [0].into_iter().collect();
1107        let mut seen = FxHashSet::default();
1108        let mut cycles = Vec::new();
1109
1110        for depth in 1..=3 {
1111            dfs_find_cycles_from(0, depth, &scc_set, &succs, 10, &mut seen, &mut cycles);
1112        }
1113        assert!(
1114            cycles.is_empty(),
1115            "self-loop should not be detected as a cycle by dfs_find_cycles_from"
1116        );
1117    }
1118
1119    #[test]
1120    fn enumerate_elementary_cycles_self_loop_not_found() {
1121        // Single node with self-edge — enumerate should find no elementary cycles
1122        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
1123        let succs = SuccessorMap {
1124            all_succs: &all_succs,
1125            succ_ranges: &succ_ranges,
1126            modules: &modules,
1127        };
1128        let cycles = enumerate_elementary_cycles(&[0], &succs, 20);
1129        assert!(
1130            cycles.is_empty(),
1131            "self-loop should not produce elementary cycles"
1132        );
1133    }
1134
1135    // -----------------------------------------------------------------------
1136    // Two overlapping cycles sharing an edge
1137    // -----------------------------------------------------------------------
1138
1139    #[test]
1140    fn find_cycles_two_cycles_sharing_edge() {
1141        // A->B->C->A and A->B->D->A share edge A->B
1142        // Should find exactly 2 elementary cycles, both of length 3
1143        let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 0), (1, 3), (3, 0)]);
1144        let cycles = graph.find_cycles();
1145        assert_eq!(
1146            cycles.len(),
1147            2,
1148            "two cycles sharing edge A->B should both be found, got {}",
1149            cycles.len()
1150        );
1151        assert!(
1152            cycles.iter().all(|c| c.len() == 3),
1153            "both cycles should have length 3"
1154        );
1155    }
1156
1157    #[test]
1158    fn enumerate_elementary_cycles_shared_edge() {
1159        // Same topology at the unit level: 0->1->2->0 and 0->1->3->0 share edge 0->1
1160        let (modules, all_succs, succ_ranges) =
1161            build_test_succs(4, &[(0, 1), (1, 2), (2, 0), (1, 3), (3, 0)]);
1162        let succs = SuccessorMap {
1163            all_succs: &all_succs,
1164            succ_ranges: &succ_ranges,
1165            modules: &modules,
1166        };
1167        let scc_nodes: Vec<usize> = vec![0, 1, 2, 3];
1168        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1169        assert_eq!(
1170            cycles.len(),
1171            2,
1172            "should find exactly 2 elementary cycles sharing edge 0->1, got {}",
1173            cycles.len()
1174        );
1175    }
1176
1177    // -----------------------------------------------------------------------
1178    // Large SCC with multiple elementary cycles — verify all found
1179    // -----------------------------------------------------------------------
1180
1181    #[test]
1182    fn enumerate_elementary_cycles_pentagon_with_chords() {
1183        // Pentagon 0->1->2->3->4->0 plus chords 0->2 and 0->3
1184        // Elementary cycles include:
1185        //   len 3: 0->2->3->4->... no, let's enumerate:
1186        //   0->1->2->3->4->0 (len 5)
1187        //   0->2->3->4->0 (len 4, via chord 0->2)
1188        //   0->3->4->0 (len 3, via chord 0->3)
1189        //   0->1->2->... subsets through chords
1190        let (modules, all_succs, succ_ranges) =
1191            build_test_succs(5, &[(0, 1), (1, 2), (2, 3), (3, 4), (4, 0), (0, 2), (0, 3)]);
1192        let succs = SuccessorMap {
1193            all_succs: &all_succs,
1194            succ_ranges: &succ_ranges,
1195            modules: &modules,
1196        };
1197        let scc_nodes: Vec<usize> = vec![0, 1, 2, 3, 4];
1198        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1199
1200        // Should find at least 3 distinct elementary cycles (the pentagon + two chord-shortened)
1201        assert!(
1202            cycles.len() >= 3,
1203            "pentagon with chords should have at least 3 elementary cycles, got {}",
1204            cycles.len()
1205        );
1206        // All cycles should be unique (no duplicates)
1207        let unique: FxHashSet<Vec<usize>> = cycles.iter().cloned().collect();
1208        assert_eq!(
1209            unique.len(),
1210            cycles.len(),
1211            "all enumerated cycles should be unique"
1212        );
1213        // Shortest cycle should be length 3 (0->3->4->0)
1214        assert_eq!(
1215            cycles[0].len(),
1216            3,
1217            "shortest cycle in pentagon with chords should be length 3"
1218        );
1219    }
1220
1221    #[test]
1222    fn find_cycles_large_scc_complete_graph_k6() {
1223        // Complete graph K6: every node connects to every other node
1224        // This creates a dense SCC with many elementary cycles
1225        let edges: Vec<(u32, u32)> = (0..6)
1226            .flat_map(|i| (0..6).filter(move |&j| i != j).map(move |j| (i, j)))
1227            .collect();
1228        let graph = build_cycle_graph(6, &edges);
1229        let cycles = graph.find_cycles();
1230
1231        // K6 has a huge number of elementary cycles; we should find many but cap at 20
1232        assert!(
1233            cycles.len() <= 20,
1234            "should cap at MAX_CYCLES_PER_SCC (20), got {}",
1235            cycles.len()
1236        );
1237        assert_eq!(
1238            cycles.len(),
1239            20,
1240            "K6 has far more than 20 elementary cycles, so we should hit the cap"
1241        );
1242        // Shortest cycles should be 2-node cycles (since every pair has bidirectional edges)
1243        assert_eq!(cycles[0].len(), 2, "shortest cycles in K6 should be 2-node");
1244    }
1245
1246    // -----------------------------------------------------------------------
1247    // Depth limit enforcement in enumerate_elementary_cycles
1248    // -----------------------------------------------------------------------
1249
1250    #[test]
1251    fn enumerate_elementary_cycles_respects_depth_cap_of_12() {
1252        // Build a single long cycle of 15 nodes: 0->1->2->...->14->0
1253        // enumerate_elementary_cycles caps depth at min(scc.len(), 12) = 12
1254        // So the 15-node cycle should NOT be found.
1255        let edges: Vec<(usize, usize)> = (0..15).map(|i| (i, (i + 1) % 15)).collect();
1256        let (modules, all_succs, succ_ranges) = build_test_succs(15, &edges);
1257        let succs = SuccessorMap {
1258            all_succs: &all_succs,
1259            succ_ranges: &succ_ranges,
1260            modules: &modules,
1261        };
1262        let scc_nodes: Vec<usize> = (0..15).collect();
1263        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1264
1265        assert!(
1266            cycles.is_empty(),
1267            "a pure 15-node cycle should not be found with depth cap of 12, got {} cycles",
1268            cycles.len()
1269        );
1270    }
1271
1272    #[test]
1273    fn enumerate_elementary_cycles_finds_cycle_at_depth_cap_boundary() {
1274        // Build a single cycle of exactly 12 nodes: 0->1->...->11->0
1275        // depth cap = min(12, 12) = 12, so this cycle should be found.
1276        let edges: Vec<(usize, usize)> = (0..12).map(|i| (i, (i + 1) % 12)).collect();
1277        let (modules, all_succs, succ_ranges) = build_test_succs(12, &edges);
1278        let succs = SuccessorMap {
1279            all_succs: &all_succs,
1280            succ_ranges: &succ_ranges,
1281            modules: &modules,
1282        };
1283        let scc_nodes: Vec<usize> = (0..12).collect();
1284        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1285
1286        assert_eq!(
1287            cycles.len(),
1288            1,
1289            "a pure 12-node cycle should be found at the depth cap boundary"
1290        );
1291        assert_eq!(cycles[0].len(), 12);
1292    }
1293
1294    #[test]
1295    fn enumerate_elementary_cycles_13_node_pure_cycle_not_found() {
1296        // 13-node pure cycle: depth cap = min(13, 12) = 12, so the 13-node cycle is skipped
1297        let edges: Vec<(usize, usize)> = (0..13).map(|i| (i, (i + 1) % 13)).collect();
1298        let (modules, all_succs, succ_ranges) = build_test_succs(13, &edges);
1299        let succs = SuccessorMap {
1300            all_succs: &all_succs,
1301            succ_ranges: &succ_ranges,
1302            modules: &modules,
1303        };
1304        let scc_nodes: Vec<usize> = (0..13).collect();
1305        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1306
1307        assert!(
1308            cycles.is_empty(),
1309            "13-node pure cycle exceeds depth cap of 12"
1310        );
1311    }
1312
1313    // -----------------------------------------------------------------------
1314    // MAX_CYCLES_PER_SCC enforcement at integration level
1315    // -----------------------------------------------------------------------
1316
1317    #[test]
1318    fn find_cycles_max_cycles_per_scc_enforced_on_k7() {
1319        // K7 complete graph: enormous number of elementary cycles
1320        // Should still be capped at 20 per SCC
1321        let edges: Vec<(u32, u32)> = (0..7)
1322            .flat_map(|i| (0..7).filter(move |&j| i != j).map(move |j| (i, j)))
1323            .collect();
1324        let graph = build_cycle_graph(7, &edges);
1325        let cycles = graph.find_cycles();
1326
1327        assert!(
1328            cycles.len() <= 20,
1329            "K7 should cap at MAX_CYCLES_PER_SCC (20), got {}",
1330            cycles.len()
1331        );
1332        assert_eq!(
1333            cycles.len(),
1334            20,
1335            "K7 has far more than 20 elementary cycles, should hit the cap exactly"
1336        );
1337    }
1338
1339    #[test]
1340    fn find_cycles_two_dense_sccs_each_capped() {
1341        // Two separate complete subgraphs K4 (nodes 0-3) and K4 (nodes 4-7)
1342        // Each has many elementary cycles; total should be capped at 20 per SCC
1343        let mut edges: Vec<(u32, u32)> = Vec::new();
1344        // First K4: nodes 0-3
1345        for i in 0..4 {
1346            for j in 0..4 {
1347                if i != j {
1348                    edges.push((i, j));
1349                }
1350            }
1351        }
1352        // Second K4: nodes 4-7
1353        for i in 4..8 {
1354            for j in 4..8 {
1355                if i != j {
1356                    edges.push((i, j));
1357                }
1358            }
1359        }
1360        let graph = build_cycle_graph(8, &edges);
1361        let cycles = graph.find_cycles();
1362
1363        // Each K4 has 2-cycles: C(4,2)=6, plus 3-cycles and 4-cycles
1364        // Both SCCs contribute cycles, but each is independently capped at 20
1365        assert!(!cycles.is_empty(), "two dense SCCs should produce cycles");
1366        // Total can be up to 40 (20 per SCC), but K4 has fewer than 20 elementary cycles
1367        // K4 elementary cycles: 6 two-cycles + 8 three-cycles + 3 four-cycles = 17
1368        // So we should get all from both SCCs
1369        assert!(
1370            cycles.len() > 2,
1371            "should find multiple cycles across both SCCs, got {}",
1372            cycles.len()
1373        );
1374    }
1375}