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