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