1#![allow(
32 clippy::must_use_candidate,
33 clippy::module_name_repetitions,
34 clippy::missing_const_for_fn,
35 clippy::collapsible_if,
36 clippy::doc_markdown,
37 clippy::bool_to_int_with_if,
38 clippy::redundant_closure_for_method_calls
39)]
40
41use std::collections::{HashMap, HashSet};
42use std::fmt;
43
44use super::blocking::BlockingGraph;
45
46#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct CycleWarning {
56 pub cycle_path: Vec<String>,
62
63 pub edge_from: String,
65
66 pub edge_to: String,
68}
69
70impl CycleWarning {
71 pub fn cycle_len(&self) -> usize {
74 if self.cycle_path.len() <= 1 {
75 return 0;
76 }
77 self.cycle_path.len() - 1
78 }
79
80 pub fn is_self_loop(&self) -> bool {
82 self.edge_from == self.edge_to
83 }
84
85 pub fn is_mutual_block(&self) -> bool {
87 self.cycle_len() == 2
88 }
89}
90
91impl fmt::Display for CycleWarning {
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93 if self.is_self_loop() {
94 write!(
95 f,
96 "cycle detected: self-loop on '{}' (item blocks itself)",
97 self.edge_from
98 )
99 } else if self.is_mutual_block() {
100 write!(
101 f,
102 "cycle detected: mutual block between '{}' and '{}'",
103 self.edge_from, self.edge_to
104 )
105 } else {
106 let path_display = self.cycle_path.join(" → ");
107 write!(
108 f,
109 "cycle detected ({} items): {}",
110 self.cycle_len(),
111 path_display
112 )
113 }
114 }
115}
116
117pub fn detect_cycle_on_add(graph: &BlockingGraph, from: &str, to: &str) -> Option<CycleWarning> {
142 if from == to {
144 return Some(CycleWarning {
145 cycle_path: vec![from.to_string(), from.to_string()],
146 edge_from: from.to_string(),
147 edge_to: to.to_string(),
148 });
149 }
150
151 let mut visited: HashSet<String> = HashSet::new();
161 let mut parent_map: HashMap<String, String> = HashMap::new();
162
163 if dfs_find_path(graph, to, from, &mut visited, &mut parent_map) {
164 let mut path = vec![from.to_string()];
166 reconstruct_path(&parent_map, to, from, &mut path);
167
168 Some(CycleWarning {
169 cycle_path: path,
170 edge_from: from.to_string(),
171 edge_to: to.to_string(),
172 })
173 } else {
174 None
175 }
176}
177
178pub fn find_all_cycles(graph: &BlockingGraph) -> Vec<CycleWarning> {
188 let mut warnings = Vec::new();
189 let mut color: HashMap<String, Color> = HashMap::new();
190 let mut parent_map: HashMap<String, String> = HashMap::new();
191
192 for item in graph.all_item_ids() {
194 color.insert(item.to_string(), Color::White);
195 }
196
197 for item in graph.all_item_ids() {
198 if color.get(item) == Some(&Color::White) {
199 dfs_all_cycles(graph, item, &mut color, &mut parent_map, &mut warnings);
200 }
201 }
202
203 warnings
204}
205
206pub fn has_cycles(graph: &BlockingGraph) -> bool {
215 let mut color: HashMap<String, Color> = HashMap::new();
216
217 for item in graph.all_item_ids() {
218 color.insert(item.to_string(), Color::White);
219 }
220
221 for item in graph.all_item_ids() {
222 if color.get(item) == Some(&Color::White) {
223 if dfs_has_cycle(graph, item, &mut color) {
224 return true;
225 }
226 }
227 }
228
229 false
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238enum Color {
239 White,
241 Gray,
243 Black,
245}
246
247fn dfs_find_path(
252 graph: &BlockingGraph,
253 current: &str,
254 target: &str,
255 visited: &mut HashSet<String>,
256 parent_map: &mut HashMap<String, String>,
257) -> bool {
258 if current == target {
259 return true;
260 }
261
262 if !visited.insert(current.to_string()) {
263 return false;
264 }
265
266 for neighbor in graph.get_blockers(current) {
267 if !visited.contains(neighbor) {
268 parent_map.insert(neighbor.to_string(), current.to_string());
269 if dfs_find_path(graph, neighbor, target, visited, parent_map) {
270 return true;
271 }
272 }
273 }
274
275 false
276}
277
278fn reconstruct_path(
282 parent_map: &HashMap<String, String>,
283 start: &str,
284 end: &str,
285 path: &mut Vec<String>,
286) {
287 let mut chain = Vec::new();
290 let mut current = end.to_string();
291
292 while current != start {
294 chain.push(current.clone());
295 match parent_map.get(¤t) {
296 Some(parent) => current = parent.clone(),
297 None => break,
298 }
299 }
300
301 chain.push(start.to_string());
306 chain.reverse();
307
308 let skip = if path.last().map(|s| s.as_str()) == Some(start) {
310 1
311 } else {
312 0
313 };
314
315 for node in chain.into_iter().skip(skip) {
316 path.push(node);
317 }
318}
319
320fn dfs_all_cycles(
322 graph: &BlockingGraph,
323 node: &str,
324 color: &mut HashMap<String, Color>,
325 parent_map: &mut HashMap<String, String>,
326 warnings: &mut Vec<CycleWarning>,
327) {
328 color.insert(node.to_string(), Color::Gray);
329
330 for neighbor in graph.get_blockers(node) {
331 match color.get(neighbor) {
332 Some(Color::White) => {
333 parent_map.insert(neighbor.to_string(), node.to_string());
334 dfs_all_cycles(graph, neighbor, color, parent_map, warnings);
335 }
336 Some(Color::Gray) => {
337 let mut cycle_path = vec![neighbor.to_string()];
340 let mut cur = node.to_string();
341 while cur != neighbor {
342 cycle_path.push(cur.clone());
343 match parent_map.get(&cur) {
344 Some(p) => cur = p.clone(),
345 None => break,
346 }
347 }
348 cycle_path.push(neighbor.to_string());
349
350 warnings.push(CycleWarning {
351 cycle_path,
352 edge_from: node.to_string(),
353 edge_to: neighbor.to_string(),
354 });
355 }
356 _ => {} }
358 }
359
360 color.insert(node.to_string(), Color::Black);
361}
362
363fn dfs_has_cycle(graph: &BlockingGraph, node: &str, color: &mut HashMap<String, Color>) -> bool {
365 color.insert(node.to_string(), Color::Gray);
366
367 for neighbor in graph.get_blockers(node) {
368 match color.get(neighbor).copied() {
369 Some(Color::White) if dfs_has_cycle(graph, neighbor, color) => {
370 return true;
371 }
372 Some(Color::Gray) => {
373 return true; }
375 _ => {} }
377 }
378
379 color.insert(node.to_string(), Color::Black);
380 false
381}
382
383#[cfg(test)]
388mod tests {
389 use super::*;
390 use crate::clock::itc::Stamp;
391 use crate::crdt::item_state::WorkItemState;
392 use crate::event::Event;
393 use crate::event::data::{EventData, LinkData};
394 use crate::event::types::EventType;
395 use crate::model::item_id::ItemId;
396 use std::collections::{BTreeMap, HashMap};
397
398 fn make_link_event(
403 target: &str,
404 link_type: &str,
405 wall_ts: i64,
406 agent: &str,
407 hash: &str,
408 ) -> Event {
409 let mut stamp = Stamp::seed();
410 stamp.event();
411 Event {
412 wall_ts_us: wall_ts,
413 agent: agent.to_string(),
414 itc: stamp.to_string(),
415 parents: vec![],
416 event_type: EventType::Link,
417 item_id: ItemId::new_unchecked("bn-test"),
418 data: EventData::Link(LinkData {
419 target: target.to_string(),
420 link_type: link_type.to_string(),
421 extra: BTreeMap::new(),
422 }),
423 event_hash: hash.to_string(),
424 }
425 }
426
427 fn state_with_blockers(blocker_ids: &[&str]) -> WorkItemState {
429 let mut state = WorkItemState::new();
430 for (i, blocker) in blocker_ids.iter().enumerate() {
431 let hash = format!("blake3:link{i}");
432 let event = make_link_event(blocker, "blocks", 1000 + i as i64, "agent", &hash);
433 state.apply_event(&event);
434 }
435 state
436 }
437
438 fn build_graph(edges: &[(&str, &[&str])]) -> BlockingGraph {
440 let mut states: HashMap<String, WorkItemState> = HashMap::new();
441 for (item_id, blockers) in edges {
443 states
444 .entry(item_id.to_string())
445 .or_insert_with(WorkItemState::new);
446 for blocker in *blockers {
447 states
448 .entry(blocker.to_string())
449 .or_insert_with(WorkItemState::new);
450 }
451 }
452 for (item_id, blockers) in edges {
454 let state = state_with_blockers(blockers);
455 states.insert(item_id.to_string(), state);
456 }
457 BlockingGraph::from_states(&states)
458 }
459
460 #[test]
465 fn cycle_warning_self_loop_display() {
466 let w = CycleWarning {
467 cycle_path: vec!["A".to_string(), "A".to_string()],
468 edge_from: "A".to_string(),
469 edge_to: "A".to_string(),
470 };
471 assert!(w.is_self_loop());
472 assert!(!w.is_mutual_block());
473 assert_eq!(w.cycle_len(), 1);
474 let display = w.to_string();
475 assert!(display.contains("self-loop"), "display: {display}");
476 assert!(display.contains("A"), "display: {display}");
477 }
478
479 #[test]
480 fn cycle_warning_mutual_block_display() {
481 let w = CycleWarning {
482 cycle_path: vec!["A".to_string(), "B".to_string(), "A".to_string()],
483 edge_from: "A".to_string(),
484 edge_to: "B".to_string(),
485 };
486 assert!(!w.is_self_loop());
487 assert!(w.is_mutual_block());
488 assert_eq!(w.cycle_len(), 2);
489 let display = w.to_string();
490 assert!(display.contains("mutual block"), "display: {display}");
491 }
492
493 #[test]
494 fn cycle_warning_large_cycle_display() {
495 let w = CycleWarning {
496 cycle_path: vec![
497 "A".to_string(),
498 "B".to_string(),
499 "C".to_string(),
500 "D".to_string(),
501 "A".to_string(),
502 ],
503 edge_from: "A".to_string(),
504 edge_to: "B".to_string(),
505 };
506 assert!(!w.is_self_loop());
507 assert!(!w.is_mutual_block());
508 assert_eq!(w.cycle_len(), 4);
509 let display = w.to_string();
510 assert!(display.contains("4 items"), "display: {display}");
511 assert!(display.contains("A → B → C → D → A"), "display: {display}");
512 }
513
514 #[test]
519 fn self_loop_detected() {
520 let graph = build_graph(&[]);
522 let warning = detect_cycle_on_add(&graph, "A", "A");
523 assert!(warning.is_some());
524 let w = warning.unwrap();
525 assert!(w.is_self_loop());
526 assert_eq!(w.edge_from, "A");
527 assert_eq!(w.edge_to, "A");
528 }
529
530 #[test]
535 fn mutual_block_detected() {
536 let graph = build_graph(&[("A", &["B"])]);
538 let warning = detect_cycle_on_add(&graph, "B", "A");
539 assert!(warning.is_some());
540 let w = warning.unwrap();
541 assert!(w.is_mutual_block());
542 assert_eq!(w.cycle_len(), 2);
543 assert_eq!(w.cycle_path.first().unwrap(), "B");
545 assert_eq!(w.cycle_path.last().unwrap(), "B");
546 }
547
548 #[test]
553 fn three_node_cycle_detected() {
554 let graph = build_graph(&[("A", &["B"]), ("B", &["C"])]);
556 let warning = detect_cycle_on_add(&graph, "C", "A");
557 assert!(warning.is_some());
558 let w = warning.unwrap();
559 assert_eq!(w.cycle_len(), 3);
560 assert_eq!(w.edge_from, "C");
561 assert_eq!(w.edge_to, "A");
562 assert_eq!(w.cycle_path.first().unwrap(), "C");
564 assert_eq!(w.cycle_path.last().unwrap(), "C");
565 }
566
567 #[test]
572 fn no_cycle_in_dag() {
573 let graph = build_graph(&[("A", &["B"]), ("B", &["C"])]);
575 let warning = detect_cycle_on_add(&graph, "D", "A");
576 assert!(warning.is_none());
577 }
578
579 #[test]
580 fn no_cycle_parallel_chains() {
581 let graph = build_graph(&[("A", &["B"]), ("C", &["D"])]);
583 let warning = detect_cycle_on_add(&graph, "A", "C");
584 assert!(warning.is_none());
585 }
586
587 #[test]
588 fn no_cycle_diamond_dag() {
589 let graph = build_graph(&[("A", &["B", "C"]), ("B", &["D"]), ("C", &["D"])]);
591 let warning = detect_cycle_on_add(&graph, "E", "A");
592 assert!(warning.is_none());
593 }
594
595 #[test]
600 fn large_cycle_detected() {
601 let mut edges: Vec<(&str, Vec<&str>)> = Vec::new();
604 let names: Vec<String> = (0..10).map(|i| format!("item{i}")).collect();
605
606 for i in 0..9 {
607 edges.push((&names[i], vec![&names[i + 1]]));
608 }
609
610 let edge_refs: Vec<(&str, &[&str])> = edges
612 .iter()
613 .map(|(from, to)| (*from, to.as_slice()))
614 .collect();
615
616 let graph = build_graph(&edge_refs);
617 let warning = detect_cycle_on_add(&graph, "item9", "item0");
618 assert!(warning.is_some());
619 let w = warning.unwrap();
620 assert_eq!(w.cycle_len(), 10);
621 assert_eq!(w.cycle_path.first().unwrap(), "item9");
622 assert_eq!(w.cycle_path.last().unwrap(), "item9");
623 }
624
625 #[test]
626 fn very_large_cycle_detected() {
627 let names: Vec<String> = (0..50).map(|i| format!("n{i}")).collect();
629 let mut edges: Vec<(&str, Vec<&str>)> = Vec::new();
630
631 for i in 0..49 {
632 edges.push((&names[i], vec![&names[i + 1]]));
633 }
634
635 let edge_refs: Vec<(&str, &[&str])> = edges
636 .iter()
637 .map(|(from, to)| (*from, to.as_slice()))
638 .collect();
639
640 let graph = build_graph(&edge_refs);
641 let warning = detect_cycle_on_add(&graph, &names[49], &names[0]);
642 assert!(warning.is_some());
643 let w = warning.unwrap();
644 assert_eq!(w.cycle_len(), 50);
645 }
646
647 #[test]
652 fn empty_graph_no_cycle() {
653 let graph = build_graph(&[]);
654 let warning = detect_cycle_on_add(&graph, "A", "B");
655 assert!(warning.is_none());
656 }
657
658 #[test]
659 fn adding_duplicate_edge_to_existing_blocker_no_new_cycle() {
660 let graph = build_graph(&[("A", &["B"])]);
662 let warning = detect_cycle_on_add(&graph, "A", "B");
663 assert!(warning.is_none());
664 }
665
666 #[test]
667 fn cycle_in_subgraph_detected() {
668 let graph = build_graph(&[("X", &["Y"]), ("Y", &["Z"]), ("A", &["B"])]);
671 let warning = detect_cycle_on_add(&graph, "B", "A");
672 assert!(warning.is_some());
673 let w = warning.unwrap();
674 assert!(w.is_mutual_block());
675 }
676
677 #[test]
682 fn find_all_cycles_empty_graph() {
683 let graph = build_graph(&[]);
684 let cycles = find_all_cycles(&graph);
685 assert!(cycles.is_empty());
686 }
687
688 #[test]
689 fn find_all_cycles_dag_has_none() {
690 let graph = build_graph(&[("A", &["B"]), ("B", &["C"])]);
691 let cycles = find_all_cycles(&graph);
692 assert!(cycles.is_empty());
693 }
694
695 #[test]
696 fn find_all_cycles_self_loop() {
697 let graph = build_graph(&[("A", &["A"])]);
698 let cycles = find_all_cycles(&graph);
699 assert!(!cycles.is_empty());
700 assert!(
702 cycles
703 .iter()
704 .any(|w| w.edge_from == "A" && w.edge_to == "A")
705 );
706 }
707
708 #[test]
709 fn find_all_cycles_mutual_block() {
710 let graph = build_graph(&[("A", &["B"]), ("B", &["A"])]);
711 let cycles = find_all_cycles(&graph);
712 assert!(!cycles.is_empty());
713 }
714
715 #[test]
716 fn find_all_cycles_multiple_disjoint() {
717 let graph = build_graph(&[("A", &["B"]), ("B", &["A"]), ("C", &["D"]), ("D", &["C"])]);
719 let cycles = find_all_cycles(&graph);
720 assert!(cycles.len() >= 2);
722 }
723
724 #[test]
729 fn has_cycles_false_for_dag() {
730 let graph = build_graph(&[("A", &["B"]), ("B", &["C"]), ("A", &["C"])]);
731 assert!(!has_cycles(&graph));
732 }
733
734 #[test]
735 fn has_cycles_true_for_self_loop() {
736 let graph = build_graph(&[("A", &["A"])]);
737 assert!(has_cycles(&graph));
738 }
739
740 #[test]
741 fn has_cycles_true_for_mutual_block() {
742 let graph = build_graph(&[("A", &["B"]), ("B", &["A"])]);
743 assert!(has_cycles(&graph));
744 }
745
746 #[test]
747 fn has_cycles_true_for_large_cycle() {
748 let names: Vec<String> = (0..15).map(|i| format!("item{i}")).collect();
749 let mut edges: Vec<(&str, Vec<&str>)> = Vec::new();
750
751 for i in 0..14 {
752 edges.push((&names[i], vec![&names[i + 1]]));
753 }
754 edges.push((&names[14], vec![&names[0]]));
756
757 let edge_refs: Vec<(&str, &[&str])> = edges
758 .iter()
759 .map(|(from, to)| (*from, to.as_slice()))
760 .collect();
761
762 let graph = build_graph(&edge_refs);
763 assert!(has_cycles(&graph));
764 }
765
766 #[test]
767 fn has_cycles_false_for_empty_graph() {
768 let graph = build_graph(&[]);
769 assert!(!has_cycles(&graph));
770 }
771
772 #[test]
777 fn performance_large_dag_no_cycle() {
778 let names: Vec<String> = (0..1000).map(|i| format!("n{i}")).collect();
781 let mut edges: Vec<(&str, Vec<&str>)> = Vec::new();
782
783 for i in 0..999 {
784 edges.push((&names[i], vec![&names[i + 1]]));
785 }
786
787 let edge_refs: Vec<(&str, &[&str])> = edges
788 .iter()
789 .map(|(from, to)| (*from, to.as_slice()))
790 .collect();
791
792 let graph = build_graph(&edge_refs);
793
794 let warning = detect_cycle_on_add(&graph, "new_item", &names[0]);
796 assert!(warning.is_none());
797
798 assert!(!has_cycles(&graph));
800 }
801
802 #[test]
803 fn performance_large_dag_with_cycle_at_end() {
804 let names: Vec<String> = (0..1000).map(|i| format!("n{i}")).collect();
806 let mut edges: Vec<(&str, Vec<&str>)> = Vec::new();
807
808 for i in 0..999 {
809 edges.push((&names[i], vec![&names[i + 1]]));
810 }
811
812 let edge_refs: Vec<(&str, &[&str])> = edges
813 .iter()
814 .map(|(from, to)| (*from, to.as_slice()))
815 .collect();
816
817 let graph = build_graph(&edge_refs);
818
819 let warning = detect_cycle_on_add(&graph, &names[999], &names[0]);
821 assert!(warning.is_some());
822 assert_eq!(warning.unwrap().cycle_len(), 1000);
823 }
824
825 #[test]
830 fn integration_with_crdt_state() {
831 let mut states: HashMap<String, WorkItemState> = HashMap::new();
833
834 let mut state_a = WorkItemState::new();
836 state_a.apply_event(&make_link_event("B", "blocks", 1000, "alice", "blake3:l1"));
837 states.insert("A".to_string(), state_a);
838
839 let mut state_b = WorkItemState::new();
841 state_b.apply_event(&make_link_event("C", "blocks", 1001, "alice", "blake3:l2"));
842 states.insert("B".to_string(), state_b);
843
844 states.insert("C".to_string(), WorkItemState::new());
846
847 let graph = BlockingGraph::from_states(&states);
848
849 let warning = detect_cycle_on_add(&graph, "C", "A");
851 assert!(warning.is_some());
852 let w = warning.unwrap();
853 assert_eq!(w.cycle_len(), 3);
854 }
855}