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                has_cjs_exports: false,
635            })
636            .collect();
637
638        let mut all_succs: Vec<usize> = Vec::new();
639        let mut succ_ranges: Vec<Range<usize>> = Vec::with_capacity(file_count);
640        for src in 0..file_count {
641            let start = all_succs.len();
642            let mut seen = FxHashSet::default();
643            for &(s, t) in edges_spec {
644                if s == src && t < file_count && seen.insert(t) {
645                    all_succs.push(t);
646                }
647            }
648            let end = all_succs.len();
649            succ_ranges.push(start..end);
650        }
651
652        (modules, all_succs, succ_ranges)
653    }
654
655    // -----------------------------------------------------------------------
656    // canonical_cycle tests
657    // -----------------------------------------------------------------------
658
659    #[test]
660    fn canonical_cycle_empty() {
661        let modules: Vec<ModuleNode> = vec![];
662        assert!(canonical_cycle(&[], &modules).is_empty());
663    }
664
665    #[test]
666    fn canonical_cycle_rotates_to_smallest_path() {
667        let (modules, _, _) = build_test_succs(3, &[]);
668        // Cycle [2, 0, 1] — file0 has the smallest path, so canonical is [0, 1, 2]
669        let result = canonical_cycle(&[2, 0, 1], &modules);
670        assert_eq!(result, vec![0, 1, 2]);
671    }
672
673    #[test]
674    fn canonical_cycle_already_canonical() {
675        let (modules, _, _) = build_test_succs(3, &[]);
676        let result = canonical_cycle(&[0, 1, 2], &modules);
677        assert_eq!(result, vec![0, 1, 2]);
678    }
679
680    #[test]
681    fn canonical_cycle_single_node() {
682        let (modules, _, _) = build_test_succs(1, &[]);
683        let result = canonical_cycle(&[0], &modules);
684        assert_eq!(result, vec![0]);
685    }
686
687    // -----------------------------------------------------------------------
688    // try_record_cycle tests
689    // -----------------------------------------------------------------------
690
691    #[test]
692    fn try_record_cycle_inserts_new_cycle() {
693        let (modules, _, _) = build_test_succs(3, &[]);
694        let mut seen = FxHashSet::default();
695        let mut cycles = Vec::new();
696
697        try_record_cycle(&[0, 1, 2], &modules, &mut seen, &mut cycles);
698        assert_eq!(cycles.len(), 1);
699        assert_eq!(cycles[0], vec![0, 1, 2]);
700    }
701
702    #[test]
703    fn try_record_cycle_deduplicates_rotated_cycle() {
704        // Same cycle in two rotations: [0,1,2] and [1,2,0]
705        // Both should canonicalize to the same key, so only one is recorded.
706        let (modules, _, _) = build_test_succs(3, &[]);
707        let mut seen = FxHashSet::default();
708        let mut cycles = Vec::new();
709
710        try_record_cycle(&[0, 1, 2], &modules, &mut seen, &mut cycles);
711        try_record_cycle(&[1, 2, 0], &modules, &mut seen, &mut cycles);
712        try_record_cycle(&[2, 0, 1], &modules, &mut seen, &mut cycles);
713
714        assert_eq!(
715            cycles.len(),
716            1,
717            "rotations of the same cycle should be deduped"
718        );
719    }
720
721    #[test]
722    fn try_record_cycle_single_node_self_loop() {
723        // A single-node "cycle" (self-loop) — should be recorded if passed in
724        let (modules, _, _) = build_test_succs(1, &[]);
725        let mut seen = FxHashSet::default();
726        let mut cycles = Vec::new();
727
728        try_record_cycle(&[0], &modules, &mut seen, &mut cycles);
729        assert_eq!(cycles.len(), 1);
730        assert_eq!(cycles[0], vec![0]);
731    }
732
733    #[test]
734    fn try_record_cycle_distinct_cycles_both_recorded() {
735        // Two genuinely different cycles
736        let (modules, _, _) = build_test_succs(4, &[]);
737        let mut seen = FxHashSet::default();
738        let mut cycles = Vec::new();
739
740        try_record_cycle(&[0, 1], &modules, &mut seen, &mut cycles);
741        try_record_cycle(&[2, 3], &modules, &mut seen, &mut cycles);
742
743        assert_eq!(cycles.len(), 2);
744    }
745
746    // -----------------------------------------------------------------------
747    // SuccessorMap construction tests
748    // -----------------------------------------------------------------------
749
750    #[test]
751    fn successor_map_empty_graph() {
752        let (modules, all_succs, succ_ranges) = build_test_succs(0, &[]);
753        let succs = SuccessorMap {
754            all_succs: &all_succs,
755            succ_ranges: &succ_ranges,
756            modules: &modules,
757        };
758        assert!(succs.all_succs.is_empty());
759        assert!(succs.succ_ranges.is_empty());
760    }
761
762    #[test]
763    fn successor_map_single_node_self_edge() {
764        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
765        let succs = SuccessorMap {
766            all_succs: &all_succs,
767            succ_ranges: &succ_ranges,
768            modules: &modules,
769        };
770        assert_eq!(succs.all_succs.len(), 1);
771        assert_eq!(succs.all_succs[0], 0);
772        assert_eq!(succs.succ_ranges[0], 0..1);
773    }
774
775    #[test]
776    fn successor_map_deduplicates_edges() {
777        // Two edges from 0 to 1 — should be deduped
778        let (modules, all_succs, succ_ranges) = build_test_succs(2, &[(0, 1), (0, 1)]);
779        let succs = SuccessorMap {
780            all_succs: &all_succs,
781            succ_ranges: &succ_ranges,
782            modules: &modules,
783        };
784        let range = &succs.succ_ranges[0];
785        assert_eq!(
786            range.end - range.start,
787            1,
788            "duplicate edges should be deduped"
789        );
790    }
791
792    #[test]
793    fn successor_map_multiple_successors() {
794        let (modules, all_succs, succ_ranges) = build_test_succs(4, &[(0, 1), (0, 2), (0, 3)]);
795        let succs = SuccessorMap {
796            all_succs: &all_succs,
797            succ_ranges: &succ_ranges,
798            modules: &modules,
799        };
800        let range = &succs.succ_ranges[0];
801        assert_eq!(range.end - range.start, 3);
802        // Node 1, 2, 3 have no successors
803        for i in 1..4 {
804            let r = &succs.succ_ranges[i];
805            assert_eq!(r.end - r.start, 0);
806        }
807    }
808
809    // -----------------------------------------------------------------------
810    // dfs_find_cycles_from tests
811    // -----------------------------------------------------------------------
812
813    #[test]
814    fn dfs_find_cycles_from_isolated_node() {
815        // Node 0 with no successors — should find no cycles
816        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[]);
817        let succs = SuccessorMap {
818            all_succs: &all_succs,
819            succ_ranges: &succ_ranges,
820            modules: &modules,
821        };
822        let scc_set: FxHashSet<usize> = std::iter::once(0).collect();
823        let mut seen = FxHashSet::default();
824        let mut cycles = Vec::new();
825
826        dfs_find_cycles_from(0, 2, &scc_set, &succs, 10, &mut seen, &mut cycles);
827        assert!(cycles.is_empty(), "isolated node should have no cycles");
828    }
829
830    #[test]
831    fn dfs_find_cycles_from_simple_two_cycle() {
832        // 0 -> 1, 1 -> 0, both in SCC
833        let (modules, all_succs, succ_ranges) = build_test_succs(2, &[(0, 1), (1, 0)]);
834        let succs = SuccessorMap {
835            all_succs: &all_succs,
836            succ_ranges: &succ_ranges,
837            modules: &modules,
838        };
839        let scc_set: FxHashSet<usize> = [0, 1].into_iter().collect();
840        let mut seen = FxHashSet::default();
841        let mut cycles = Vec::new();
842
843        dfs_find_cycles_from(0, 2, &scc_set, &succs, 10, &mut seen, &mut cycles);
844        assert_eq!(cycles.len(), 1);
845        assert_eq!(cycles[0].len(), 2);
846    }
847
848    #[test]
849    fn dfs_find_cycles_from_diamond_graph() {
850        // Diamond: 0->1, 0->2, 1->3, 2->3, 3->0 (all in SCC)
851        // At depth 3: 0->1->3->0 and 0->2->3->0
852        // At depth 4: 0->1->3->?->0 — but 3 only goes to 0, so no 4-cycle
853        let (modules, all_succs, succ_ranges) =
854            build_test_succs(4, &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 0)]);
855        let succs = SuccessorMap {
856            all_succs: &all_succs,
857            succ_ranges: &succ_ranges,
858            modules: &modules,
859        };
860        let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
861        let mut seen = FxHashSet::default();
862        let mut cycles = Vec::new();
863
864        // Depth 3: should find two 3-node cycles
865        dfs_find_cycles_from(0, 3, &scc_set, &succs, 10, &mut seen, &mut cycles);
866        assert_eq!(cycles.len(), 2, "diamond should have two 3-node cycles");
867        assert!(cycles.iter().all(|c| c.len() == 3));
868    }
869
870    #[test]
871    fn dfs_find_cycles_from_depth_limit_prevents_longer_cycles() {
872        // 0->1->2->3->0 forms a 4-cycle
873        // With depth_limit=3, the DFS should NOT find this 4-cycle
874        let (modules, all_succs, succ_ranges) =
875            build_test_succs(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
876        let succs = SuccessorMap {
877            all_succs: &all_succs,
878            succ_ranges: &succ_ranges,
879            modules: &modules,
880        };
881        let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
882        let mut seen = FxHashSet::default();
883        let mut cycles = Vec::new();
884
885        dfs_find_cycles_from(0, 3, &scc_set, &succs, 10, &mut seen, &mut cycles);
886        assert!(
887            cycles.is_empty(),
888            "depth_limit=3 should prevent finding a 4-node cycle"
889        );
890    }
891
892    #[test]
893    fn dfs_find_cycles_from_depth_limit_exact_match() {
894        // 0->1->2->3->0 forms a 4-cycle
895        // With depth_limit=4, the DFS should find it
896        let (modules, all_succs, succ_ranges) =
897            build_test_succs(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
898        let succs = SuccessorMap {
899            all_succs: &all_succs,
900            succ_ranges: &succ_ranges,
901            modules: &modules,
902        };
903        let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
904        let mut seen = FxHashSet::default();
905        let mut cycles = Vec::new();
906
907        dfs_find_cycles_from(0, 4, &scc_set, &succs, 10, &mut seen, &mut cycles);
908        assert_eq!(
909            cycles.len(),
910            1,
911            "depth_limit=4 should find the 4-node cycle"
912        );
913        assert_eq!(cycles[0].len(), 4);
914    }
915
916    #[test]
917    fn dfs_find_cycles_from_respects_max_cycles() {
918        // Dense graph: complete graph of 4 nodes — many cycles
919        let edges: Vec<(usize, usize)> = (0..4)
920            .flat_map(|i| (0..4).filter(move |&j| i != j).map(move |j| (i, j)))
921            .collect();
922        let (modules, all_succs, succ_ranges) = build_test_succs(4, &edges);
923        let succs = SuccessorMap {
924            all_succs: &all_succs,
925            succ_ranges: &succ_ranges,
926            modules: &modules,
927        };
928        let scc_set: FxHashSet<usize> = (0..4).collect();
929        let mut seen = FxHashSet::default();
930        let mut cycles = Vec::new();
931
932        // max_cycles = 2: should stop after finding 2
933        dfs_find_cycles_from(0, 2, &scc_set, &succs, 2, &mut seen, &mut cycles);
934        assert!(
935            cycles.len() <= 2,
936            "should respect max_cycles limit, got {}",
937            cycles.len()
938        );
939    }
940
941    #[test]
942    fn dfs_find_cycles_from_ignores_nodes_outside_scc() {
943        // 0->1->2->0 but only {0, 1} in SCC set — node 2 should be ignored
944        let (modules, all_succs, succ_ranges) = build_test_succs(3, &[(0, 1), (1, 2), (2, 0)]);
945        let succs = SuccessorMap {
946            all_succs: &all_succs,
947            succ_ranges: &succ_ranges,
948            modules: &modules,
949        };
950        let scc_set: FxHashSet<usize> = [0, 1].into_iter().collect();
951        let mut seen = FxHashSet::default();
952        let mut cycles = Vec::new();
953
954        for depth in 2..=3 {
955            dfs_find_cycles_from(0, depth, &scc_set, &succs, 10, &mut seen, &mut cycles);
956        }
957        assert!(
958            cycles.is_empty(),
959            "should not find cycles through nodes outside the SCC set"
960        );
961    }
962
963    // -----------------------------------------------------------------------
964    // enumerate_elementary_cycles tests
965    // -----------------------------------------------------------------------
966
967    #[test]
968    fn enumerate_elementary_cycles_empty_scc() {
969        let (modules, all_succs, succ_ranges) = build_test_succs(0, &[]);
970        let succs = SuccessorMap {
971            all_succs: &all_succs,
972            succ_ranges: &succ_ranges,
973            modules: &modules,
974        };
975        let cycles = enumerate_elementary_cycles(&[], &succs, 10);
976        assert!(cycles.is_empty());
977    }
978
979    #[test]
980    fn enumerate_elementary_cycles_max_cycles_limit() {
981        // Complete graph of 4 nodes — many elementary cycles
982        let edges: Vec<(usize, usize)> = (0..4)
983            .flat_map(|i| (0..4).filter(move |&j| i != j).map(move |j| (i, j)))
984            .collect();
985        let (modules, all_succs, succ_ranges) = build_test_succs(4, &edges);
986        let succs = SuccessorMap {
987            all_succs: &all_succs,
988            succ_ranges: &succ_ranges,
989            modules: &modules,
990        };
991        let scc_nodes: Vec<usize> = (0..4).collect();
992
993        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 3);
994        assert!(
995            cycles.len() <= 3,
996            "should respect max_cycles=3 limit, got {}",
997            cycles.len()
998        );
999    }
1000
1001    #[test]
1002    fn enumerate_elementary_cycles_finds_all_in_triangle() {
1003        // 0->1->2->0 — single elementary cycle
1004        let (modules, all_succs, succ_ranges) = build_test_succs(3, &[(0, 1), (1, 2), (2, 0)]);
1005        let succs = SuccessorMap {
1006            all_succs: &all_succs,
1007            succ_ranges: &succ_ranges,
1008            modules: &modules,
1009        };
1010        let scc_nodes: Vec<usize> = vec![0, 1, 2];
1011
1012        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1013        assert_eq!(cycles.len(), 1);
1014        assert_eq!(cycles[0].len(), 3);
1015    }
1016
1017    #[test]
1018    fn enumerate_elementary_cycles_iterative_deepening_order() {
1019        // SCC with both 2-node and 3-node cycles
1020        // 0->1->0 (2-cycle) and 0->1->2->0 (3-cycle)
1021        let (modules, all_succs, succ_ranges) =
1022            build_test_succs(3, &[(0, 1), (1, 0), (1, 2), (2, 0)]);
1023        let succs = SuccessorMap {
1024            all_succs: &all_succs,
1025            succ_ranges: &succ_ranges,
1026            modules: &modules,
1027        };
1028        let scc_nodes: Vec<usize> = vec![0, 1, 2];
1029
1030        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1031        assert!(cycles.len() >= 2, "should find at least 2 cycles");
1032        // Iterative deepening: shorter cycles should come first
1033        assert!(
1034            cycles[0].len() <= cycles[cycles.len() - 1].len(),
1035            "shorter cycles should be found before longer ones"
1036        );
1037    }
1038
1039    // -----------------------------------------------------------------------
1040    // Integration-level edge cases
1041    // -----------------------------------------------------------------------
1042
1043    #[test]
1044    fn find_cycles_max_cycles_per_scc_respected() {
1045        // Dense SCC (complete graph of 5 nodes) — should cap at MAX_CYCLES_PER_SCC (20)
1046        let edges: Vec<(u32, u32)> = (0..5)
1047            .flat_map(|i| (0..5).filter(move |&j| i != j).map(move |j| (i, j)))
1048            .collect();
1049        let graph = build_cycle_graph(5, &edges);
1050        let cycles = graph.find_cycles();
1051        // K5 has many elementary cycles, but we cap at 20 per SCC
1052        assert!(
1053            cycles.len() <= 20,
1054            "should cap at MAX_CYCLES_PER_SCC, got {}",
1055            cycles.len()
1056        );
1057        assert!(
1058            !cycles.is_empty(),
1059            "dense graph should still find some cycles"
1060        );
1061    }
1062
1063    #[test]
1064    fn find_cycles_graph_with_no_cycles_returns_empty() {
1065        // Star topology: center -> all leaves, no cycles possible
1066        let graph = build_cycle_graph(5, &[(0, 1), (0, 2), (0, 3), (0, 4)]);
1067        assert!(graph.find_cycles().is_empty());
1068    }
1069
1070    #[test]
1071    fn find_cycles_diamond_no_cycle() {
1072        // Diamond without back-edge: A->B, A->C, B->D, C->D — no cycle
1073        let graph = build_cycle_graph(4, &[(0, 1), (0, 2), (1, 3), (2, 3)]);
1074        assert!(graph.find_cycles().is_empty());
1075    }
1076
1077    #[test]
1078    fn find_cycles_diamond_with_back_edge() {
1079        // Diamond with back-edge: A->B, A->C, B->D, C->D, D->A
1080        let graph = build_cycle_graph(4, &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 0)]);
1081        let cycles = graph.find_cycles();
1082        assert!(
1083            cycles.len() >= 2,
1084            "diamond with back-edge should have at least 2 elementary cycles, got {}",
1085            cycles.len()
1086        );
1087        // Shortest cycles should be length 3 (A->B->D->A and A->C->D->A)
1088        assert_eq!(cycles[0].len(), 3);
1089    }
1090
1091    // -----------------------------------------------------------------------
1092    // Additional canonical_cycle tests
1093    // -----------------------------------------------------------------------
1094
1095    #[test]
1096    fn canonical_cycle_non_sequential_indices() {
1097        // Cycle with non-sequential node indices [3, 1, 4] — file1 has smallest path
1098        let (modules, _, _) = build_test_succs(5, &[]);
1099        let result = canonical_cycle(&[3, 1, 4], &modules);
1100        // file1 has path "/project/file1.ts" which is smallest, so rotation starts there
1101        assert_eq!(result, vec![1, 4, 3]);
1102    }
1103
1104    #[test]
1105    fn canonical_cycle_different_starting_points_same_result() {
1106        // The same logical cycle [0, 1, 2, 3] presented from four different starting points
1107        // should always canonicalize to [0, 1, 2, 3] since file0 has the smallest path.
1108        let (modules, _, _) = build_test_succs(4, &[]);
1109        let r1 = canonical_cycle(&[0, 1, 2, 3], &modules);
1110        let r2 = canonical_cycle(&[1, 2, 3, 0], &modules);
1111        let r3 = canonical_cycle(&[2, 3, 0, 1], &modules);
1112        let r4 = canonical_cycle(&[3, 0, 1, 2], &modules);
1113        assert_eq!(r1, r2);
1114        assert_eq!(r2, r3);
1115        assert_eq!(r3, r4);
1116        assert_eq!(r1, vec![0, 1, 2, 3]);
1117    }
1118
1119    #[test]
1120    fn canonical_cycle_two_node_both_rotations() {
1121        // Two-node cycle: [0, 1] and [1, 0] should both canonicalize to [0, 1]
1122        let (modules, _, _) = build_test_succs(2, &[]);
1123        assert_eq!(canonical_cycle(&[0, 1], &modules), vec![0, 1]);
1124        assert_eq!(canonical_cycle(&[1, 0], &modules), vec![0, 1]);
1125    }
1126
1127    // -----------------------------------------------------------------------
1128    // Self-loop unit-level tests
1129    // -----------------------------------------------------------------------
1130
1131    #[test]
1132    fn dfs_find_cycles_from_self_loop_not_found() {
1133        // Node 0 has a self-edge (0->0). The DFS requires path.len() >= 2 for a cycle,
1134        // so a self-loop should not be detected as a cycle.
1135        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
1136        let succs = SuccessorMap {
1137            all_succs: &all_succs,
1138            succ_ranges: &succ_ranges,
1139            modules: &modules,
1140        };
1141        let scc_set: FxHashSet<usize> = std::iter::once(0).collect();
1142        let mut seen = FxHashSet::default();
1143        let mut cycles = Vec::new();
1144
1145        for depth in 1..=3 {
1146            dfs_find_cycles_from(0, depth, &scc_set, &succs, 10, &mut seen, &mut cycles);
1147        }
1148        assert!(
1149            cycles.is_empty(),
1150            "self-loop should not be detected as a cycle by dfs_find_cycles_from"
1151        );
1152    }
1153
1154    #[test]
1155    fn enumerate_elementary_cycles_self_loop_not_found() {
1156        // Single node with self-edge — enumerate should find no elementary cycles
1157        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
1158        let succs = SuccessorMap {
1159            all_succs: &all_succs,
1160            succ_ranges: &succ_ranges,
1161            modules: &modules,
1162        };
1163        let cycles = enumerate_elementary_cycles(&[0], &succs, 20);
1164        assert!(
1165            cycles.is_empty(),
1166            "self-loop should not produce elementary cycles"
1167        );
1168    }
1169
1170    // -----------------------------------------------------------------------
1171    // Two overlapping cycles sharing an edge
1172    // -----------------------------------------------------------------------
1173
1174    #[test]
1175    fn find_cycles_two_cycles_sharing_edge() {
1176        // A->B->C->A and A->B->D->A share edge A->B
1177        // Should find exactly 2 elementary cycles, both of length 3
1178        let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 0), (1, 3), (3, 0)]);
1179        let cycles = graph.find_cycles();
1180        assert_eq!(
1181            cycles.len(),
1182            2,
1183            "two cycles sharing edge A->B should both be found, got {}",
1184            cycles.len()
1185        );
1186        assert!(
1187            cycles.iter().all(|c| c.len() == 3),
1188            "both cycles should have length 3"
1189        );
1190    }
1191
1192    #[test]
1193    fn enumerate_elementary_cycles_shared_edge() {
1194        // Same topology at the unit level: 0->1->2->0 and 0->1->3->0 share edge 0->1
1195        let (modules, all_succs, succ_ranges) =
1196            build_test_succs(4, &[(0, 1), (1, 2), (2, 0), (1, 3), (3, 0)]);
1197        let succs = SuccessorMap {
1198            all_succs: &all_succs,
1199            succ_ranges: &succ_ranges,
1200            modules: &modules,
1201        };
1202        let scc_nodes: Vec<usize> = vec![0, 1, 2, 3];
1203        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1204        assert_eq!(
1205            cycles.len(),
1206            2,
1207            "should find exactly 2 elementary cycles sharing edge 0->1, got {}",
1208            cycles.len()
1209        );
1210    }
1211
1212    // -----------------------------------------------------------------------
1213    // Large SCC with multiple elementary cycles — verify all found
1214    // -----------------------------------------------------------------------
1215
1216    #[test]
1217    fn enumerate_elementary_cycles_pentagon_with_chords() {
1218        // Pentagon 0->1->2->3->4->0 plus chords 0->2 and 0->3
1219        // Elementary cycles include:
1220        //   len 3: 0->2->3->4->... no, let's enumerate:
1221        //   0->1->2->3->4->0 (len 5)
1222        //   0->2->3->4->0 (len 4, via chord 0->2)
1223        //   0->3->4->0 (len 3, via chord 0->3)
1224        //   0->1->2->... subsets through chords
1225        let (modules, all_succs, succ_ranges) =
1226            build_test_succs(5, &[(0, 1), (1, 2), (2, 3), (3, 4), (4, 0), (0, 2), (0, 3)]);
1227        let succs = SuccessorMap {
1228            all_succs: &all_succs,
1229            succ_ranges: &succ_ranges,
1230            modules: &modules,
1231        };
1232        let scc_nodes: Vec<usize> = vec![0, 1, 2, 3, 4];
1233        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1234
1235        // Should find at least 3 distinct elementary cycles (the pentagon + two chord-shortened)
1236        assert!(
1237            cycles.len() >= 3,
1238            "pentagon with chords should have at least 3 elementary cycles, got {}",
1239            cycles.len()
1240        );
1241        // All cycles should be unique (no duplicates)
1242        let unique: FxHashSet<Vec<usize>> = cycles.iter().cloned().collect();
1243        assert_eq!(
1244            unique.len(),
1245            cycles.len(),
1246            "all enumerated cycles should be unique"
1247        );
1248        // Shortest cycle should be length 3 (0->3->4->0)
1249        assert_eq!(
1250            cycles[0].len(),
1251            3,
1252            "shortest cycle in pentagon with chords should be length 3"
1253        );
1254    }
1255
1256    #[test]
1257    fn find_cycles_large_scc_complete_graph_k6() {
1258        // Complete graph K6: every node connects to every other node
1259        // This creates a dense SCC with many elementary cycles
1260        let edges: Vec<(u32, u32)> = (0..6)
1261            .flat_map(|i| (0..6).filter(move |&j| i != j).map(move |j| (i, j)))
1262            .collect();
1263        let graph = build_cycle_graph(6, &edges);
1264        let cycles = graph.find_cycles();
1265
1266        // K6 has a huge number of elementary cycles; we should find many but cap at 20
1267        assert!(
1268            cycles.len() <= 20,
1269            "should cap at MAX_CYCLES_PER_SCC (20), got {}",
1270            cycles.len()
1271        );
1272        assert_eq!(
1273            cycles.len(),
1274            20,
1275            "K6 has far more than 20 elementary cycles, so we should hit the cap"
1276        );
1277        // Shortest cycles should be 2-node cycles (since every pair has bidirectional edges)
1278        assert_eq!(cycles[0].len(), 2, "shortest cycles in K6 should be 2-node");
1279    }
1280
1281    // -----------------------------------------------------------------------
1282    // Depth limit enforcement in enumerate_elementary_cycles
1283    // -----------------------------------------------------------------------
1284
1285    #[test]
1286    fn enumerate_elementary_cycles_respects_depth_cap_of_12() {
1287        // Build a single long cycle of 15 nodes: 0->1->2->...->14->0
1288        // enumerate_elementary_cycles caps depth at min(scc.len(), 12) = 12
1289        // So the 15-node cycle should NOT be found.
1290        let edges: Vec<(usize, usize)> = (0..15).map(|i| (i, (i + 1) % 15)).collect();
1291        let (modules, all_succs, succ_ranges) = build_test_succs(15, &edges);
1292        let succs = SuccessorMap {
1293            all_succs: &all_succs,
1294            succ_ranges: &succ_ranges,
1295            modules: &modules,
1296        };
1297        let scc_nodes: Vec<usize> = (0..15).collect();
1298        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1299
1300        assert!(
1301            cycles.is_empty(),
1302            "a pure 15-node cycle should not be found with depth cap of 12, got {} cycles",
1303            cycles.len()
1304        );
1305    }
1306
1307    #[test]
1308    fn enumerate_elementary_cycles_finds_cycle_at_depth_cap_boundary() {
1309        // Build a single cycle of exactly 12 nodes: 0->1->...->11->0
1310        // depth cap = min(12, 12) = 12, so this cycle should be found.
1311        let edges: Vec<(usize, usize)> = (0..12).map(|i| (i, (i + 1) % 12)).collect();
1312        let (modules, all_succs, succ_ranges) = build_test_succs(12, &edges);
1313        let succs = SuccessorMap {
1314            all_succs: &all_succs,
1315            succ_ranges: &succ_ranges,
1316            modules: &modules,
1317        };
1318        let scc_nodes: Vec<usize> = (0..12).collect();
1319        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1320
1321        assert_eq!(
1322            cycles.len(),
1323            1,
1324            "a pure 12-node cycle should be found at the depth cap boundary"
1325        );
1326        assert_eq!(cycles[0].len(), 12);
1327    }
1328
1329    #[test]
1330    fn enumerate_elementary_cycles_13_node_pure_cycle_not_found() {
1331        // 13-node pure cycle: depth cap = min(13, 12) = 12, so the 13-node cycle is skipped
1332        let edges: Vec<(usize, usize)> = (0..13).map(|i| (i, (i + 1) % 13)).collect();
1333        let (modules, all_succs, succ_ranges) = build_test_succs(13, &edges);
1334        let succs = SuccessorMap {
1335            all_succs: &all_succs,
1336            succ_ranges: &succ_ranges,
1337            modules: &modules,
1338        };
1339        let scc_nodes: Vec<usize> = (0..13).collect();
1340        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1341
1342        assert!(
1343            cycles.is_empty(),
1344            "13-node pure cycle exceeds depth cap of 12"
1345        );
1346    }
1347
1348    // -----------------------------------------------------------------------
1349    // MAX_CYCLES_PER_SCC enforcement at integration level
1350    // -----------------------------------------------------------------------
1351
1352    #[test]
1353    fn find_cycles_max_cycles_per_scc_enforced_on_k7() {
1354        // K7 complete graph: enormous number of elementary cycles
1355        // Should still be capped at 20 per SCC
1356        let edges: Vec<(u32, u32)> = (0..7)
1357            .flat_map(|i| (0..7).filter(move |&j| i != j).map(move |j| (i, j)))
1358            .collect();
1359        let graph = build_cycle_graph(7, &edges);
1360        let cycles = graph.find_cycles();
1361
1362        assert!(
1363            cycles.len() <= 20,
1364            "K7 should cap at MAX_CYCLES_PER_SCC (20), got {}",
1365            cycles.len()
1366        );
1367        assert_eq!(
1368            cycles.len(),
1369            20,
1370            "K7 has far more than 20 elementary cycles, should hit the cap exactly"
1371        );
1372    }
1373
1374    #[test]
1375    fn find_cycles_two_dense_sccs_each_capped() {
1376        // Two separate complete subgraphs K4 (nodes 0-3) and K4 (nodes 4-7)
1377        // Each has many elementary cycles; total should be capped at 20 per SCC
1378        let mut edges: Vec<(u32, u32)> = Vec::new();
1379        // First K4: nodes 0-3
1380        for i in 0..4 {
1381            for j in 0..4 {
1382                if i != j {
1383                    edges.push((i, j));
1384                }
1385            }
1386        }
1387        // Second K4: nodes 4-7
1388        for i in 4..8 {
1389            for j in 4..8 {
1390                if i != j {
1391                    edges.push((i, j));
1392                }
1393            }
1394        }
1395        let graph = build_cycle_graph(8, &edges);
1396        let cycles = graph.find_cycles();
1397
1398        // Each K4 has 2-cycles: C(4,2)=6, plus 3-cycles and 4-cycles
1399        // Both SCCs contribute cycles, but each is independently capped at 20
1400        assert!(!cycles.is_empty(), "two dense SCCs should produce cycles");
1401        // Total can be up to 40 (20 per SCC), but K4 has fewer than 20 elementary cycles
1402        // K4 elementary cycles: 6 two-cycles + 8 three-cycles + 3 four-cycles = 17
1403        // So we should get all from both SCCs
1404        assert!(
1405            cycles.len() > 2,
1406            "should find multiple cycles across both SCCs, got {}",
1407            cycles.len()
1408        );
1409    }
1410
1411    mod proptests {
1412        use super::*;
1413        use proptest::prelude::*;
1414
1415        proptest! {
1416            /// A DAG (directed acyclic graph) should always have zero cycles.
1417            /// We construct a DAG by only allowing edges from lower to higher node indices.
1418            #[test]
1419            fn dag_has_no_cycles(
1420                file_count in 2..20usize,
1421                edge_pairs in prop::collection::vec((0..19u32, 0..19u32), 0..30),
1422            ) {
1423                // Filter to only forward edges (i < j) to guarantee a DAG
1424                let dag_edges: Vec<(u32, u32)> = edge_pairs
1425                    .into_iter()
1426                    .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a < b)
1427                    .collect();
1428
1429                let graph = build_cycle_graph(file_count, &dag_edges);
1430                let cycles = graph.find_cycles();
1431                prop_assert!(
1432                    cycles.is_empty(),
1433                    "DAG should have no cycles, but found {}",
1434                    cycles.len()
1435                );
1436            }
1437
1438            /// Adding mutual edges A->B->A should always detect a cycle.
1439            #[test]
1440            fn mutual_edges_always_detect_cycle(extra_nodes in 0..10usize) {
1441                let file_count = 2 + extra_nodes;
1442                let graph = build_cycle_graph(file_count, &[(0, 1), (1, 0)]);
1443                let cycles = graph.find_cycles();
1444                prop_assert!(
1445                    !cycles.is_empty(),
1446                    "A->B->A should always produce at least one cycle"
1447                );
1448                // The cycle should contain both nodes 0 and 1
1449                let has_pair_cycle = cycles.iter().any(|c| {
1450                    c.contains(&FileId(0)) && c.contains(&FileId(1))
1451                });
1452                prop_assert!(has_pair_cycle, "Should find a cycle containing nodes 0 and 1");
1453            }
1454
1455            /// All cycle members should be valid FileId indices.
1456            #[test]
1457            fn cycle_members_are_valid_indices(
1458                file_count in 2..15usize,
1459                edge_pairs in prop::collection::vec((0..14u32, 0..14u32), 1..20),
1460            ) {
1461                let edges: Vec<(u32, u32)> = edge_pairs
1462                    .into_iter()
1463                    .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a != b)
1464                    .collect();
1465
1466                let graph = build_cycle_graph(file_count, &edges);
1467                let cycles = graph.find_cycles();
1468                for cycle in &cycles {
1469                    prop_assert!(cycle.len() >= 2, "Cycles must have at least 2 nodes");
1470                    for file_id in cycle {
1471                        prop_assert!(
1472                            (file_id.0 as usize) < file_count,
1473                            "FileId {} exceeds file count {}",
1474                            file_id.0, file_count
1475                        );
1476                    }
1477                }
1478            }
1479
1480            /// Cycles should be sorted by length (shortest first).
1481            #[test]
1482            fn cycles_sorted_by_length(
1483                file_count in 3..12usize,
1484                edge_pairs in prop::collection::vec((0..11u32, 0..11u32), 2..25),
1485            ) {
1486                let edges: Vec<(u32, u32)> = edge_pairs
1487                    .into_iter()
1488                    .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a != b)
1489                    .collect();
1490
1491                let graph = build_cycle_graph(file_count, &edges);
1492                let cycles = graph.find_cycles();
1493                for window in cycles.windows(2) {
1494                    prop_assert!(
1495                        window[0].len() <= window[1].len(),
1496                        "Cycles should be sorted by length: {} > {}",
1497                        window[0].len(), window[1].len()
1498                    );
1499                }
1500            }
1501        }
1502    }
1503}