use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
pub fn detect_cycles<'a, I>(edges: I) -> Vec<Vec<PathBuf>>
where
I: Iterator<Item = (&'a Path, &'a Path)>,
{
let mut adjacency: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
let mut nodes = HashSet::new();
for (from, to) in edges {
adjacency
.entry(from.to_path_buf())
.or_default()
.push(to.to_path_buf());
nodes.insert(from.to_path_buf());
nodes.insert(to.to_path_buf());
}
let mut state = DfsState {
visited: HashSet::new(),
recursion_stack: HashSet::new(),
path_stack: Vec::new(),
cycles: Vec::new(),
};
let mut sorted_nodes: Vec<_> = nodes.into_iter().collect();
sorted_nodes.sort();
for node in sorted_nodes {
if !state.visited.contains(&node) {
dfs(&node, &adjacency, &mut state);
}
}
state.cycles
}
struct DfsState {
visited: HashSet<PathBuf>,
recursion_stack: HashSet<PathBuf>,
path_stack: Vec<PathBuf>,
cycles: Vec<Vec<PathBuf>>,
}
fn dfs(
node: &PathBuf,
adjacency: &HashMap<PathBuf, Vec<PathBuf>>,
state: &mut DfsState,
) {
state.visited.insert(node.clone());
state.recursion_stack.insert(node.clone());
state.path_stack.push(node.clone());
if let Some(neighbors) = adjacency.get(node) {
process_neighbors(neighbors, adjacency, state);
}
state.recursion_stack.remove(node);
state.path_stack.pop();
}
fn process_neighbors(
neighbors: &[PathBuf],
adjacency: &HashMap<PathBuf, Vec<PathBuf>>,
state: &mut DfsState,
) {
let mut sorted_neighbors = neighbors.to_vec();
sorted_neighbors.sort();
for neighbor in sorted_neighbors {
visit_neighbor(neighbor, adjacency, state);
}
}
fn visit_neighbor(
neighbor: PathBuf,
adjacency: &HashMap<PathBuf, Vec<PathBuf>>,
state: &mut DfsState,
) {
if !state.visited.contains(&neighbor) {
dfs(&neighbor, adjacency, state);
} else if state.recursion_stack.contains(&neighbor) {
record_cycle(neighbor, state);
}
}
#[allow(clippy::indexing_slicing)] fn record_cycle(
neighbor: PathBuf,
state: &mut DfsState,
) {
if let Some(pos) = state.path_stack.iter().position(|x| x == &neighbor) {
let mut cycle = state.path_stack[pos..].to_vec();
cycle.push(neighbor); state.cycles.push(cycle);
}
}
#[cfg(test)]
#[allow(clippy::indexing_slicing)]
mod tests {
use super::*;
fn p(s: &str) -> PathBuf { PathBuf::from(s) }
fn edges(list: &[(&str, &str)]) -> Vec<(PathBuf, PathBuf)> {
list.iter().map(|(a, b)| (p(a), p(b))).collect()
}
#[test]
fn test_cycle_detection_logic() {
let cases = vec![
(
vec![("a", "b"), ("b", "c")],
0,
"No cycles"
),
(
vec![("a", "b"), ("b", "a")],
1,
"Simple cycle"
),
(
vec![("a", "b"), ("a", "c"), ("b", "d"), ("c", "d")],
0,
"Diamond DAG (no cycle)"
),
(
vec![("a", "a")],
1,
"Self loop"
),
(
vec![("a", "b"), ("b", "c"), ("c", "a")],
1,
"Three node cycle"
),
(
vec![("a", "b"), ("b", "a"), ("c", "d"), ("d", "c")],
2,
"Disjoint cycles"
),
(
vec![("a", "b"), ("b", "a"), ("b", "c"), ("c", "b")], 2,
"Figure-8 (shared node)"
),
(
vec![("a", "b"), ("b", "c"), ("c", "d"), ("d", "e"), ("e", "a")],
1,
"Long cycle (5 nodes)"
),
(
vec![],
0,
"Empty graph"
),
(
vec![("a", "b")],
0,
"Single edge"
),
];
for (edge_list, expected_count, desc) in cases {
let edge_vec = edges(&edge_list);
let edge_refs: Vec<(&Path, &Path)> = edge_vec.iter().map(|(a, b)| (a.as_ref(), b.as_ref())).collect();
let cycles = detect_cycles(edge_refs.into_iter());
assert_eq!(cycles.len(), expected_count, "Failed: {desc}");
if desc == "Simple cycle" {
assert_eq!(cycles[0].len(), 3, "a->b->a length");
}
if desc == "Self loop" {
assert_eq!(cycles[0].len(), 2, "a->a length");
}
}
}
#[test]
fn test_cycle_content() {
let list = vec![("x", "y"), ("y", "z"), ("z", "x")];
let edge_vec = edges(&list);
let edge_refs: Vec<(&Path, &Path)> = edge_vec.iter().map(|(a, b)| (a.as_ref(), b.as_ref())).collect();
let cycles = detect_cycles(edge_refs.into_iter());
assert_eq!(cycles.len(), 1);
let cycle = &cycles[0];
assert!(cycle.contains(&p("x")));
assert!(cycle.contains(&p("y")));
assert!(cycle.contains(&p("z")));
}
}