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) {
369 Some(Color::White) => {
370 if dfs_has_cycle(graph, neighbor, color) {
371 return true;
372 }
373 }
374 Some(Color::Gray) => {
375 return true; }
377 _ => {} }
379 }
380
381 color.insert(node.to_string(), Color::Black);
382 false
383}
384
385#[cfg(test)]
390mod tests {
391 use super::*;
392 use crate::clock::itc::Stamp;
393 use crate::crdt::item_state::WorkItemState;
394 use crate::event::Event;
395 use crate::event::data::{EventData, LinkData};
396 use crate::event::types::EventType;
397 use crate::model::item_id::ItemId;
398 use std::collections::{BTreeMap, HashMap};
399
400 fn make_link_event(
405 target: &str,
406 link_type: &str,
407 wall_ts: i64,
408 agent: &str,
409 hash: &str,
410 ) -> Event {
411 let mut stamp = Stamp::seed();
412 stamp.event();
413 Event {
414 wall_ts_us: wall_ts,
415 agent: agent.to_string(),
416 itc: stamp.to_string(),
417 parents: vec![],
418 event_type: EventType::Link,
419 item_id: ItemId::new_unchecked("bn-test"),
420 data: EventData::Link(LinkData {
421 target: target.to_string(),
422 link_type: link_type.to_string(),
423 extra: BTreeMap::new(),
424 }),
425 event_hash: hash.to_string(),
426 }
427 }
428
429 fn state_with_blockers(blocker_ids: &[&str]) -> WorkItemState {
431 let mut state = WorkItemState::new();
432 for (i, blocker) in blocker_ids.iter().enumerate() {
433 let hash = format!("blake3:link{i}");
434 let event = make_link_event(blocker, "blocks", 1000 + i as i64, "agent", &hash);
435 state.apply_event(&event);
436 }
437 state
438 }
439
440 fn build_graph(edges: &[(&str, &[&str])]) -> BlockingGraph {
442 let mut states: HashMap<String, WorkItemState> = HashMap::new();
443 for (item_id, blockers) in edges {
445 states
446 .entry(item_id.to_string())
447 .or_insert_with(WorkItemState::new);
448 for blocker in *blockers {
449 states
450 .entry(blocker.to_string())
451 .or_insert_with(WorkItemState::new);
452 }
453 }
454 for (item_id, blockers) in edges {
456 let state = state_with_blockers(blockers);
457 states.insert(item_id.to_string(), state);
458 }
459 BlockingGraph::from_states(&states)
460 }
461
462 #[test]
467 fn cycle_warning_self_loop_display() {
468 let w = CycleWarning {
469 cycle_path: vec!["A".to_string(), "A".to_string()],
470 edge_from: "A".to_string(),
471 edge_to: "A".to_string(),
472 };
473 assert!(w.is_self_loop());
474 assert!(!w.is_mutual_block());
475 assert_eq!(w.cycle_len(), 1);
476 let display = w.to_string();
477 assert!(display.contains("self-loop"), "display: {display}");
478 assert!(display.contains("A"), "display: {display}");
479 }
480
481 #[test]
482 fn cycle_warning_mutual_block_display() {
483 let w = CycleWarning {
484 cycle_path: vec!["A".to_string(), "B".to_string(), "A".to_string()],
485 edge_from: "A".to_string(),
486 edge_to: "B".to_string(),
487 };
488 assert!(!w.is_self_loop());
489 assert!(w.is_mutual_block());
490 assert_eq!(w.cycle_len(), 2);
491 let display = w.to_string();
492 assert!(display.contains("mutual block"), "display: {display}");
493 }
494
495 #[test]
496 fn cycle_warning_large_cycle_display() {
497 let w = CycleWarning {
498 cycle_path: vec![
499 "A".to_string(),
500 "B".to_string(),
501 "C".to_string(),
502 "D".to_string(),
503 "A".to_string(),
504 ],
505 edge_from: "A".to_string(),
506 edge_to: "B".to_string(),
507 };
508 assert!(!w.is_self_loop());
509 assert!(!w.is_mutual_block());
510 assert_eq!(w.cycle_len(), 4);
511 let display = w.to_string();
512 assert!(display.contains("4 items"), "display: {display}");
513 assert!(display.contains("A → B → C → D → A"), "display: {display}");
514 }
515
516 #[test]
521 fn self_loop_detected() {
522 let graph = build_graph(&[]);
524 let warning = detect_cycle_on_add(&graph, "A", "A");
525 assert!(warning.is_some());
526 let w = warning.unwrap();
527 assert!(w.is_self_loop());
528 assert_eq!(w.edge_from, "A");
529 assert_eq!(w.edge_to, "A");
530 }
531
532 #[test]
537 fn mutual_block_detected() {
538 let graph = build_graph(&[("A", &["B"])]);
540 let warning = detect_cycle_on_add(&graph, "B", "A");
541 assert!(warning.is_some());
542 let w = warning.unwrap();
543 assert!(w.is_mutual_block());
544 assert_eq!(w.cycle_len(), 2);
545 assert_eq!(w.cycle_path.first().unwrap(), "B");
547 assert_eq!(w.cycle_path.last().unwrap(), "B");
548 }
549
550 #[test]
555 fn three_node_cycle_detected() {
556 let graph = build_graph(&[("A", &["B"]), ("B", &["C"])]);
558 let warning = detect_cycle_on_add(&graph, "C", "A");
559 assert!(warning.is_some());
560 let w = warning.unwrap();
561 assert_eq!(w.cycle_len(), 3);
562 assert_eq!(w.edge_from, "C");
563 assert_eq!(w.edge_to, "A");
564 assert_eq!(w.cycle_path.first().unwrap(), "C");
566 assert_eq!(w.cycle_path.last().unwrap(), "C");
567 }
568
569 #[test]
574 fn no_cycle_in_dag() {
575 let graph = build_graph(&[("A", &["B"]), ("B", &["C"])]);
577 let warning = detect_cycle_on_add(&graph, "D", "A");
578 assert!(warning.is_none());
579 }
580
581 #[test]
582 fn no_cycle_parallel_chains() {
583 let graph = build_graph(&[("A", &["B"]), ("C", &["D"])]);
585 let warning = detect_cycle_on_add(&graph, "A", "C");
586 assert!(warning.is_none());
587 }
588
589 #[test]
590 fn no_cycle_diamond_dag() {
591 let graph = build_graph(&[("A", &["B", "C"]), ("B", &["D"]), ("C", &["D"])]);
593 let warning = detect_cycle_on_add(&graph, "E", "A");
594 assert!(warning.is_none());
595 }
596
597 #[test]
602 fn large_cycle_detected() {
603 let mut edges: Vec<(&str, Vec<&str>)> = Vec::new();
606 let names: Vec<String> = (0..10).map(|i| format!("item{i}")).collect();
607
608 for i in 0..9 {
609 edges.push((&names[i], vec![&names[i + 1]]));
610 }
611
612 let edge_refs: Vec<(&str, &[&str])> = edges
614 .iter()
615 .map(|(from, to)| (*from, to.as_slice()))
616 .collect();
617
618 let graph = build_graph(&edge_refs);
619 let warning = detect_cycle_on_add(&graph, "item9", "item0");
620 assert!(warning.is_some());
621 let w = warning.unwrap();
622 assert_eq!(w.cycle_len(), 10);
623 assert_eq!(w.cycle_path.first().unwrap(), "item9");
624 assert_eq!(w.cycle_path.last().unwrap(), "item9");
625 }
626
627 #[test]
628 fn very_large_cycle_detected() {
629 let names: Vec<String> = (0..50).map(|i| format!("n{i}")).collect();
631 let mut edges: Vec<(&str, Vec<&str>)> = Vec::new();
632
633 for i in 0..49 {
634 edges.push((&names[i], vec![&names[i + 1]]));
635 }
636
637 let edge_refs: Vec<(&str, &[&str])> = edges
638 .iter()
639 .map(|(from, to)| (*from, to.as_slice()))
640 .collect();
641
642 let graph = build_graph(&edge_refs);
643 let warning = detect_cycle_on_add(&graph, &names[49], &names[0]);
644 assert!(warning.is_some());
645 let w = warning.unwrap();
646 assert_eq!(w.cycle_len(), 50);
647 }
648
649 #[test]
654 fn empty_graph_no_cycle() {
655 let graph = build_graph(&[]);
656 let warning = detect_cycle_on_add(&graph, "A", "B");
657 assert!(warning.is_none());
658 }
659
660 #[test]
661 fn adding_duplicate_edge_to_existing_blocker_no_new_cycle() {
662 let graph = build_graph(&[("A", &["B"])]);
664 let warning = detect_cycle_on_add(&graph, "A", "B");
665 assert!(warning.is_none());
666 }
667
668 #[test]
669 fn cycle_in_subgraph_detected() {
670 let graph = build_graph(&[("X", &["Y"]), ("Y", &["Z"]), ("A", &["B"])]);
673 let warning = detect_cycle_on_add(&graph, "B", "A");
674 assert!(warning.is_some());
675 let w = warning.unwrap();
676 assert!(w.is_mutual_block());
677 }
678
679 #[test]
684 fn find_all_cycles_empty_graph() {
685 let graph = build_graph(&[]);
686 let cycles = find_all_cycles(&graph);
687 assert!(cycles.is_empty());
688 }
689
690 #[test]
691 fn find_all_cycles_dag_has_none() {
692 let graph = build_graph(&[("A", &["B"]), ("B", &["C"])]);
693 let cycles = find_all_cycles(&graph);
694 assert!(cycles.is_empty());
695 }
696
697 #[test]
698 fn find_all_cycles_self_loop() {
699 let graph = build_graph(&[("A", &["A"])]);
700 let cycles = find_all_cycles(&graph);
701 assert!(!cycles.is_empty());
702 assert!(
704 cycles
705 .iter()
706 .any(|w| w.edge_from == "A" && w.edge_to == "A")
707 );
708 }
709
710 #[test]
711 fn find_all_cycles_mutual_block() {
712 let graph = build_graph(&[("A", &["B"]), ("B", &["A"])]);
713 let cycles = find_all_cycles(&graph);
714 assert!(!cycles.is_empty());
715 }
716
717 #[test]
718 fn find_all_cycles_multiple_disjoint() {
719 let graph = build_graph(&[("A", &["B"]), ("B", &["A"]), ("C", &["D"]), ("D", &["C"])]);
721 let cycles = find_all_cycles(&graph);
722 assert!(cycles.len() >= 2);
724 }
725
726 #[test]
731 fn has_cycles_false_for_dag() {
732 let graph = build_graph(&[("A", &["B"]), ("B", &["C"]), ("A", &["C"])]);
733 assert!(!has_cycles(&graph));
734 }
735
736 #[test]
737 fn has_cycles_true_for_self_loop() {
738 let graph = build_graph(&[("A", &["A"])]);
739 assert!(has_cycles(&graph));
740 }
741
742 #[test]
743 fn has_cycles_true_for_mutual_block() {
744 let graph = build_graph(&[("A", &["B"]), ("B", &["A"])]);
745 assert!(has_cycles(&graph));
746 }
747
748 #[test]
749 fn has_cycles_true_for_large_cycle() {
750 let names: Vec<String> = (0..15).map(|i| format!("item{i}")).collect();
751 let mut edges: Vec<(&str, Vec<&str>)> = Vec::new();
752
753 for i in 0..14 {
754 edges.push((&names[i], vec![&names[i + 1]]));
755 }
756 edges.push((&names[14], vec![&names[0]]));
758
759 let edge_refs: Vec<(&str, &[&str])> = edges
760 .iter()
761 .map(|(from, to)| (*from, to.as_slice()))
762 .collect();
763
764 let graph = build_graph(&edge_refs);
765 assert!(has_cycles(&graph));
766 }
767
768 #[test]
769 fn has_cycles_false_for_empty_graph() {
770 let graph = build_graph(&[]);
771 assert!(!has_cycles(&graph));
772 }
773
774 #[test]
779 fn performance_large_dag_no_cycle() {
780 let names: Vec<String> = (0..1000).map(|i| format!("n{i}")).collect();
783 let mut edges: Vec<(&str, Vec<&str>)> = Vec::new();
784
785 for i in 0..999 {
786 edges.push((&names[i], vec![&names[i + 1]]));
787 }
788
789 let edge_refs: Vec<(&str, &[&str])> = edges
790 .iter()
791 .map(|(from, to)| (*from, to.as_slice()))
792 .collect();
793
794 let graph = build_graph(&edge_refs);
795
796 let warning = detect_cycle_on_add(&graph, "new_item", &names[0]);
798 assert!(warning.is_none());
799
800 assert!(!has_cycles(&graph));
802 }
803
804 #[test]
805 fn performance_large_dag_with_cycle_at_end() {
806 let names: Vec<String> = (0..1000).map(|i| format!("n{i}")).collect();
808 let mut edges: Vec<(&str, Vec<&str>)> = Vec::new();
809
810 for i in 0..999 {
811 edges.push((&names[i], vec![&names[i + 1]]));
812 }
813
814 let edge_refs: Vec<(&str, &[&str])> = edges
815 .iter()
816 .map(|(from, to)| (*from, to.as_slice()))
817 .collect();
818
819 let graph = build_graph(&edge_refs);
820
821 let warning = detect_cycle_on_add(&graph, &names[999], &names[0]);
823 assert!(warning.is_some());
824 assert_eq!(warning.unwrap().cycle_len(), 1000);
825 }
826
827 #[test]
832 fn integration_with_crdt_state() {
833 let mut states: HashMap<String, WorkItemState> = HashMap::new();
835
836 let mut state_a = WorkItemState::new();
838 state_a.apply_event(&make_link_event("B", "blocks", 1000, "alice", "blake3:l1"));
839 states.insert("A".to_string(), state_a);
840
841 let mut state_b = WorkItemState::new();
843 state_b.apply_event(&make_link_event("C", "blocks", 1001, "alice", "blake3:l2"));
844 states.insert("B".to_string(), state_b);
845
846 states.insert("C".to_string(), WorkItemState::new());
848
849 let graph = BlockingGraph::from_states(&states);
850
851 let warning = detect_cycle_on_add(&graph, "C", "A");
853 assert!(warning.is_some());
854 let w = warning.unwrap();
855 assert_eq!(w.cycle_len(), 3);
856 }
857}