#![allow(
clippy::must_use_candidate,
clippy::module_name_repetitions,
clippy::missing_const_for_fn,
clippy::collapsible_if,
clippy::doc_markdown,
clippy::bool_to_int_with_if,
clippy::redundant_closure_for_method_calls
)]
use std::collections::{HashMap, HashSet};
use std::fmt;
use super::blocking::BlockingGraph;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CycleWarning {
pub cycle_path: Vec<String>,
pub edge_from: String,
pub edge_to: String,
}
impl CycleWarning {
pub fn cycle_len(&self) -> usize {
if self.cycle_path.len() <= 1 {
return 0;
}
self.cycle_path.len() - 1
}
pub fn is_self_loop(&self) -> bool {
self.edge_from == self.edge_to
}
pub fn is_mutual_block(&self) -> bool {
self.cycle_len() == 2
}
}
impl fmt::Display for CycleWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.is_self_loop() {
write!(
f,
"cycle detected: self-loop on '{}' (item blocks itself)",
self.edge_from
)
} else if self.is_mutual_block() {
write!(
f,
"cycle detected: mutual block between '{}' and '{}'",
self.edge_from, self.edge_to
)
} else {
let path_display = self.cycle_path.join(" → ");
write!(
f,
"cycle detected ({} items): {}",
self.cycle_len(),
path_display
)
}
}
}
pub fn detect_cycle_on_add(graph: &BlockingGraph, from: &str, to: &str) -> Option<CycleWarning> {
if from == to {
return Some(CycleWarning {
cycle_path: vec![from.to_string(), from.to_string()],
edge_from: from.to_string(),
edge_to: to.to_string(),
});
}
let mut visited: HashSet<String> = HashSet::new();
let mut parent_map: HashMap<String, String> = HashMap::new();
if dfs_find_path(graph, to, from, &mut visited, &mut parent_map) {
let mut path = vec![from.to_string()];
reconstruct_path(&parent_map, to, from, &mut path);
Some(CycleWarning {
cycle_path: path,
edge_from: from.to_string(),
edge_to: to.to_string(),
})
} else {
None
}
}
pub fn find_all_cycles(graph: &BlockingGraph) -> Vec<CycleWarning> {
let mut warnings = Vec::new();
let mut color: HashMap<String, Color> = HashMap::new();
let mut parent_map: HashMap<String, String> = HashMap::new();
for item in graph.all_item_ids() {
color.insert(item.to_string(), Color::White);
}
for item in graph.all_item_ids() {
if color.get(item) == Some(&Color::White) {
dfs_all_cycles(graph, item, &mut color, &mut parent_map, &mut warnings);
}
}
warnings
}
pub fn has_cycles(graph: &BlockingGraph) -> bool {
let mut color: HashMap<String, Color> = HashMap::new();
for item in graph.all_item_ids() {
color.insert(item.to_string(), Color::White);
}
for item in graph.all_item_ids() {
if color.get(item) == Some(&Color::White) {
if dfs_has_cycle(graph, item, &mut color) {
return true;
}
}
}
false
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Color {
White,
Gray,
Black,
}
fn dfs_find_path(
graph: &BlockingGraph,
current: &str,
target: &str,
visited: &mut HashSet<String>,
parent_map: &mut HashMap<String, String>,
) -> bool {
if current == target {
return true;
}
if !visited.insert(current.to_string()) {
return false;
}
for neighbor in graph.get_blockers(current) {
if !visited.contains(neighbor) {
parent_map.insert(neighbor.to_string(), current.to_string());
if dfs_find_path(graph, neighbor, target, visited, parent_map) {
return true;
}
}
}
false
}
fn reconstruct_path(
parent_map: &HashMap<String, String>,
start: &str,
end: &str,
path: &mut Vec<String>,
) {
let mut chain = Vec::new();
let mut current = end.to_string();
while current != start {
chain.push(current.clone());
match parent_map.get(¤t) {
Some(parent) => current = parent.clone(),
None => break,
}
}
chain.push(start.to_string());
chain.reverse();
let skip = if path.last().map(|s| s.as_str()) == Some(start) {
1
} else {
0
};
for node in chain.into_iter().skip(skip) {
path.push(node);
}
}
fn dfs_all_cycles(
graph: &BlockingGraph,
node: &str,
color: &mut HashMap<String, Color>,
parent_map: &mut HashMap<String, String>,
warnings: &mut Vec<CycleWarning>,
) {
color.insert(node.to_string(), Color::Gray);
for neighbor in graph.get_blockers(node) {
match color.get(neighbor) {
Some(Color::White) => {
parent_map.insert(neighbor.to_string(), node.to_string());
dfs_all_cycles(graph, neighbor, color, parent_map, warnings);
}
Some(Color::Gray) => {
let mut cycle_path = vec![neighbor.to_string()];
let mut cur = node.to_string();
while cur != neighbor {
cycle_path.push(cur.clone());
match parent_map.get(&cur) {
Some(p) => cur = p.clone(),
None => break,
}
}
cycle_path.push(neighbor.to_string());
warnings.push(CycleWarning {
cycle_path,
edge_from: node.to_string(),
edge_to: neighbor.to_string(),
});
}
_ => {} }
}
color.insert(node.to_string(), Color::Black);
}
fn dfs_has_cycle(graph: &BlockingGraph, node: &str, color: &mut HashMap<String, Color>) -> bool {
color.insert(node.to_string(), Color::Gray);
for neighbor in graph.get_blockers(node) {
match color.get(neighbor).copied() {
Some(Color::White) if dfs_has_cycle(graph, neighbor, color) => {
return true;
}
Some(Color::Gray) => {
return true; }
_ => {} }
}
color.insert(node.to_string(), Color::Black);
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::itc::Stamp;
use crate::crdt::item_state::WorkItemState;
use crate::event::Event;
use crate::event::data::{EventData, LinkData};
use crate::event::types::EventType;
use crate::model::item_id::ItemId;
use std::collections::{BTreeMap, HashMap};
fn make_link_event(
target: &str,
link_type: &str,
wall_ts: i64,
agent: &str,
hash: &str,
) -> Event {
let mut stamp = Stamp::seed();
stamp.event();
Event {
wall_ts_us: wall_ts,
agent: agent.to_string(),
itc: stamp.to_string(),
parents: vec![],
event_type: EventType::Link,
item_id: ItemId::new_unchecked("bn-test"),
data: EventData::Link(LinkData {
target: target.to_string(),
link_type: link_type.to_string(),
extra: BTreeMap::new(),
}),
event_hash: hash.to_string(),
}
}
fn state_with_blockers(blocker_ids: &[&str]) -> WorkItemState {
let mut state = WorkItemState::new();
for (i, blocker) in blocker_ids.iter().enumerate() {
let hash = format!("blake3:link{i}");
let event = make_link_event(blocker, "blocks", 1000 + i as i64, "agent", &hash);
state.apply_event(&event);
}
state
}
fn build_graph(edges: &[(&str, &[&str])]) -> BlockingGraph {
let mut states: HashMap<String, WorkItemState> = HashMap::new();
for (item_id, blockers) in edges {
states
.entry(item_id.to_string())
.or_insert_with(WorkItemState::new);
for blocker in *blockers {
states
.entry(blocker.to_string())
.or_insert_with(WorkItemState::new);
}
}
for (item_id, blockers) in edges {
let state = state_with_blockers(blockers);
states.insert(item_id.to_string(), state);
}
BlockingGraph::from_states(&states)
}
#[test]
fn cycle_warning_self_loop_display() {
let w = CycleWarning {
cycle_path: vec!["A".to_string(), "A".to_string()],
edge_from: "A".to_string(),
edge_to: "A".to_string(),
};
assert!(w.is_self_loop());
assert!(!w.is_mutual_block());
assert_eq!(w.cycle_len(), 1);
let display = w.to_string();
assert!(display.contains("self-loop"), "display: {display}");
assert!(display.contains("A"), "display: {display}");
}
#[test]
fn cycle_warning_mutual_block_display() {
let w = CycleWarning {
cycle_path: vec!["A".to_string(), "B".to_string(), "A".to_string()],
edge_from: "A".to_string(),
edge_to: "B".to_string(),
};
assert!(!w.is_self_loop());
assert!(w.is_mutual_block());
assert_eq!(w.cycle_len(), 2);
let display = w.to_string();
assert!(display.contains("mutual block"), "display: {display}");
}
#[test]
fn cycle_warning_large_cycle_display() {
let w = CycleWarning {
cycle_path: vec![
"A".to_string(),
"B".to_string(),
"C".to_string(),
"D".to_string(),
"A".to_string(),
],
edge_from: "A".to_string(),
edge_to: "B".to_string(),
};
assert!(!w.is_self_loop());
assert!(!w.is_mutual_block());
assert_eq!(w.cycle_len(), 4);
let display = w.to_string();
assert!(display.contains("4 items"), "display: {display}");
assert!(display.contains("A → B → C → D → A"), "display: {display}");
}
#[test]
fn self_loop_detected() {
let graph = build_graph(&[]);
let warning = detect_cycle_on_add(&graph, "A", "A");
assert!(warning.is_some());
let w = warning.unwrap();
assert!(w.is_self_loop());
assert_eq!(w.edge_from, "A");
assert_eq!(w.edge_to, "A");
}
#[test]
fn mutual_block_detected() {
let graph = build_graph(&[("A", &["B"])]);
let warning = detect_cycle_on_add(&graph, "B", "A");
assert!(warning.is_some());
let w = warning.unwrap();
assert!(w.is_mutual_block());
assert_eq!(w.cycle_len(), 2);
assert_eq!(w.cycle_path.first().unwrap(), "B");
assert_eq!(w.cycle_path.last().unwrap(), "B");
}
#[test]
fn three_node_cycle_detected() {
let graph = build_graph(&[("A", &["B"]), ("B", &["C"])]);
let warning = detect_cycle_on_add(&graph, "C", "A");
assert!(warning.is_some());
let w = warning.unwrap();
assert_eq!(w.cycle_len(), 3);
assert_eq!(w.edge_from, "C");
assert_eq!(w.edge_to, "A");
assert_eq!(w.cycle_path.first().unwrap(), "C");
assert_eq!(w.cycle_path.last().unwrap(), "C");
}
#[test]
fn no_cycle_in_dag() {
let graph = build_graph(&[("A", &["B"]), ("B", &["C"])]);
let warning = detect_cycle_on_add(&graph, "D", "A");
assert!(warning.is_none());
}
#[test]
fn no_cycle_parallel_chains() {
let graph = build_graph(&[("A", &["B"]), ("C", &["D"])]);
let warning = detect_cycle_on_add(&graph, "A", "C");
assert!(warning.is_none());
}
#[test]
fn no_cycle_diamond_dag() {
let graph = build_graph(&[("A", &["B", "C"]), ("B", &["D"]), ("C", &["D"])]);
let warning = detect_cycle_on_add(&graph, "E", "A");
assert!(warning.is_none());
}
#[test]
fn large_cycle_detected() {
let mut edges: Vec<(&str, Vec<&str>)> = Vec::new();
let names: Vec<String> = (0..10).map(|i| format!("item{i}")).collect();
for i in 0..9 {
edges.push((&names[i], vec![&names[i + 1]]));
}
let edge_refs: Vec<(&str, &[&str])> = edges
.iter()
.map(|(from, to)| (*from, to.as_slice()))
.collect();
let graph = build_graph(&edge_refs);
let warning = detect_cycle_on_add(&graph, "item9", "item0");
assert!(warning.is_some());
let w = warning.unwrap();
assert_eq!(w.cycle_len(), 10);
assert_eq!(w.cycle_path.first().unwrap(), "item9");
assert_eq!(w.cycle_path.last().unwrap(), "item9");
}
#[test]
fn very_large_cycle_detected() {
let names: Vec<String> = (0..50).map(|i| format!("n{i}")).collect();
let mut edges: Vec<(&str, Vec<&str>)> = Vec::new();
for i in 0..49 {
edges.push((&names[i], vec![&names[i + 1]]));
}
let edge_refs: Vec<(&str, &[&str])> = edges
.iter()
.map(|(from, to)| (*from, to.as_slice()))
.collect();
let graph = build_graph(&edge_refs);
let warning = detect_cycle_on_add(&graph, &names[49], &names[0]);
assert!(warning.is_some());
let w = warning.unwrap();
assert_eq!(w.cycle_len(), 50);
}
#[test]
fn empty_graph_no_cycle() {
let graph = build_graph(&[]);
let warning = detect_cycle_on_add(&graph, "A", "B");
assert!(warning.is_none());
}
#[test]
fn adding_duplicate_edge_to_existing_blocker_no_new_cycle() {
let graph = build_graph(&[("A", &["B"])]);
let warning = detect_cycle_on_add(&graph, "A", "B");
assert!(warning.is_none());
}
#[test]
fn cycle_in_subgraph_detected() {
let graph = build_graph(&[("X", &["Y"]), ("Y", &["Z"]), ("A", &["B"])]);
let warning = detect_cycle_on_add(&graph, "B", "A");
assert!(warning.is_some());
let w = warning.unwrap();
assert!(w.is_mutual_block());
}
#[test]
fn find_all_cycles_empty_graph() {
let graph = build_graph(&[]);
let cycles = find_all_cycles(&graph);
assert!(cycles.is_empty());
}
#[test]
fn find_all_cycles_dag_has_none() {
let graph = build_graph(&[("A", &["B"]), ("B", &["C"])]);
let cycles = find_all_cycles(&graph);
assert!(cycles.is_empty());
}
#[test]
fn find_all_cycles_self_loop() {
let graph = build_graph(&[("A", &["A"])]);
let cycles = find_all_cycles(&graph);
assert!(!cycles.is_empty());
assert!(
cycles
.iter()
.any(|w| w.edge_from == "A" && w.edge_to == "A")
);
}
#[test]
fn find_all_cycles_mutual_block() {
let graph = build_graph(&[("A", &["B"]), ("B", &["A"])]);
let cycles = find_all_cycles(&graph);
assert!(!cycles.is_empty());
}
#[test]
fn find_all_cycles_multiple_disjoint() {
let graph = build_graph(&[("A", &["B"]), ("B", &["A"]), ("C", &["D"]), ("D", &["C"])]);
let cycles = find_all_cycles(&graph);
assert!(cycles.len() >= 2);
}
#[test]
fn has_cycles_false_for_dag() {
let graph = build_graph(&[("A", &["B"]), ("B", &["C"]), ("A", &["C"])]);
assert!(!has_cycles(&graph));
}
#[test]
fn has_cycles_true_for_self_loop() {
let graph = build_graph(&[("A", &["A"])]);
assert!(has_cycles(&graph));
}
#[test]
fn has_cycles_true_for_mutual_block() {
let graph = build_graph(&[("A", &["B"]), ("B", &["A"])]);
assert!(has_cycles(&graph));
}
#[test]
fn has_cycles_true_for_large_cycle() {
let names: Vec<String> = (0..15).map(|i| format!("item{i}")).collect();
let mut edges: Vec<(&str, Vec<&str>)> = Vec::new();
for i in 0..14 {
edges.push((&names[i], vec![&names[i + 1]]));
}
edges.push((&names[14], vec![&names[0]]));
let edge_refs: Vec<(&str, &[&str])> = edges
.iter()
.map(|(from, to)| (*from, to.as_slice()))
.collect();
let graph = build_graph(&edge_refs);
assert!(has_cycles(&graph));
}
#[test]
fn has_cycles_false_for_empty_graph() {
let graph = build_graph(&[]);
assert!(!has_cycles(&graph));
}
#[test]
fn performance_large_dag_no_cycle() {
let names: Vec<String> = (0..1000).map(|i| format!("n{i}")).collect();
let mut edges: Vec<(&str, Vec<&str>)> = Vec::new();
for i in 0..999 {
edges.push((&names[i], vec![&names[i + 1]]));
}
let edge_refs: Vec<(&str, &[&str])> = edges
.iter()
.map(|(from, to)| (*from, to.as_slice()))
.collect();
let graph = build_graph(&edge_refs);
let warning = detect_cycle_on_add(&graph, "new_item", &names[0]);
assert!(warning.is_none());
assert!(!has_cycles(&graph));
}
#[test]
fn performance_large_dag_with_cycle_at_end() {
let names: Vec<String> = (0..1000).map(|i| format!("n{i}")).collect();
let mut edges: Vec<(&str, Vec<&str>)> = Vec::new();
for i in 0..999 {
edges.push((&names[i], vec![&names[i + 1]]));
}
let edge_refs: Vec<(&str, &[&str])> = edges
.iter()
.map(|(from, to)| (*from, to.as_slice()))
.collect();
let graph = build_graph(&edge_refs);
let warning = detect_cycle_on_add(&graph, &names[999], &names[0]);
assert!(warning.is_some());
assert_eq!(warning.unwrap().cycle_len(), 1000);
}
#[test]
fn integration_with_crdt_state() {
let mut states: HashMap<String, WorkItemState> = HashMap::new();
let mut state_a = WorkItemState::new();
state_a.apply_event(&make_link_event("B", "blocks", 1000, "alice", "blake3:l1"));
states.insert("A".to_string(), state_a);
let mut state_b = WorkItemState::new();
state_b.apply_event(&make_link_event("C", "blocks", 1001, "alice", "blake3:l2"));
states.insert("B".to_string(), state_b);
states.insert("C".to_string(), WorkItemState::new());
let graph = BlockingGraph::from_states(&states);
let warning = detect_cycle_on_add(&graph, "C", "A");
assert!(warning.is_some());
let w = warning.unwrap();
assert_eq!(w.cycle_len(), 3);
}
}