Skip to main content

fallow_graph/graph/
cycles.rs

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