Skip to main content

bones_core/graph/
cycles.rs

1//! Cycle detection for the blocking dependency graph.
2//!
3//! # Overview
4//!
5//! Blocking dependencies form a directed graph. Cycles make items permanently
6//! stuck (each item waits on another in the loop). This module detects cycles
7//! when a new blocking edge is added and returns a warning with the cycle path.
8//!
9//! # Design
10//!
11//! - **DFS-based**: Standard depth-first search from the target of the new
12//!   edge, looking for a path back to the source. This detects the cycle that
13//!   the new edge closes.
14//! - **Warn, don't block**: Cycles are user errors, not system errors. The
15//!   link is still added to the CRDT; the caller is responsible for surfacing
16//!   the warning.
17//! - **O(V+E)**: Each detection check visits each node and edge at most once.
18//!
19//! # Usage
20//!
21//! ```rust,ignore
22//! use bones_core::graph::cycles::{detect_cycle_on_add, CycleWarning};
23//! use bones_core::graph::blocking::BlockingGraph;
24//!
25//! let graph = BlockingGraph::from_states(&states);
26//! if let Some(warning) = detect_cycle_on_add(&graph, "bn-task1", "bn-task2") {
27//!     eprintln!("Warning: {warning}");
28//! }
29//! ```
30
31#![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// ---------------------------------------------------------------------------
47// CycleWarning
48// ---------------------------------------------------------------------------
49
50/// A warning emitted when a new blocking edge would close a cycle.
51///
52/// Contains the cycle path (ordered list of item IDs forming the loop)
53/// and the edge that triggered detection.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct CycleWarning {
56    /// The ordered list of item IDs forming the cycle.
57    ///
58    /// The path starts at the source of the new edge, follows blocking
59    /// dependencies, and ends at the source again. For example, if
60    /// adding edge A→B creates cycle A→B→C→A, the path is `["A", "B", "C", "A"]`.
61    pub cycle_path: Vec<String>,
62
63    /// The source of the newly added edge (the item being blocked).
64    pub edge_from: String,
65
66    /// The target of the newly added edge (the blocker).
67    pub edge_to: String,
68}
69
70impl CycleWarning {
71    /// Number of distinct items in the cycle (path length minus the repeated
72    /// start node).
73    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    /// Returns `true` if this is a self-loop (item blocks itself).
81    pub fn is_self_loop(&self) -> bool {
82        self.edge_from == self.edge_to
83    }
84
85    /// Returns `true` if this is a mutual block (2-node cycle: A↔B).
86    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
117// ---------------------------------------------------------------------------
118// Core detection
119// ---------------------------------------------------------------------------
120
121/// Detect whether adding a blocking edge `from → to` (meaning `from` is
122/// blocked by `to`) would create a cycle in the blocking graph.
123///
124/// This checks if there is already a path from `to` back to `from` in the
125/// existing graph. If so, adding `from → to` closes a cycle.
126///
127/// # Arguments
128///
129/// - `graph`: The current blocking graph (before the new edge is added).
130/// - `from`: The item that would be blocked (source of the new edge).
131/// - `to`: The blocker item (target of the new edge).
132///
133/// # Returns
134///
135/// `Some(CycleWarning)` if a cycle would be created, `None` otherwise.
136///
137/// # Complexity
138///
139/// O(V+E) where V is the number of items and E is the number of blocking
140/// edges. Each node and edge is visited at most once during the DFS.
141pub fn detect_cycle_on_add(graph: &BlockingGraph, from: &str, to: &str) -> Option<CycleWarning> {
142    // Self-loop: from blocks itself.
143    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    // DFS from `to`, looking for a path back to `from`.
152    // We follow the blocking direction: for each node, look at what it is
153    // blocked_by (i.e., its outgoing edges in the "blocked_by" graph).
154    //
155    // The blocking graph stores: item → set of items that block it.
156    // So if A is blocked by B, the edge is A→B in blocked_by.
157    // We want to find: is there a path from `to` → ... → `from`?
158    // Following blocked_by edges from `to`.
159
160    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        // Reconstruct path: from → to → ... → from
165        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
178/// Detect all cycles in the blocking graph using Tarjan-style DFS.
179///
180/// Returns a list of all cycles found. Each cycle is represented as a
181/// `CycleWarning` where `edge_from` and `edge_to` indicate one edge in the
182/// cycle (the back edge that closes it).
183///
184/// # Complexity
185///
186/// O(V+E) — standard DFS traversal.
187pub 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    // Initialize all nodes as White (unvisited).
193    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
206/// Check whether the blocking graph has any cycles at all.
207///
208/// More efficient than `find_all_cycles` when you only need a boolean answer
209/// — it short-circuits on the first cycle found.
210///
211/// # Complexity
212///
213/// O(V+E) in the worst case (no cycles). O(1) best case (immediate self-loop).
214pub 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// ---------------------------------------------------------------------------
233// DFS internals
234// ---------------------------------------------------------------------------
235
236/// DFS colors for cycle detection.
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238enum Color {
239    /// Not yet visited.
240    White,
241    /// Currently on the DFS stack (in progress).
242    Gray,
243    /// Fully processed (all descendants visited).
244    Black,
245}
246
247/// DFS to find a path from `current` to `target` following blocked_by edges.
248///
249/// Records the traversal in `parent_map` so the path can be reconstructed.
250/// Returns `true` if `target` is reachable from `current`.
251fn 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
278/// Reconstruct the path from `start` to `end` using the parent map.
279///
280/// Appends nodes to `path` in order from `start` toward `end`.
281fn reconstruct_path(
282    parent_map: &HashMap<String, String>,
283    start: &str,
284    end: &str,
285    path: &mut Vec<String>,
286) {
287    // Build the path from start to end by following parent_map backwards
288    // from end to start, then reversing.
289    let mut chain = Vec::new();
290    let mut current = end.to_string();
291
292    // Walk from end back to start through parent_map.
293    while current != start {
294        chain.push(current.clone());
295        match parent_map.get(&current) {
296            Some(parent) => current = parent.clone(),
297            None => break,
298        }
299    }
300
301    // chain is [end, ..., (node after start)] in reverse order.
302    // We want [start, ..., end] appended to path.
303    // start is already in path (or will be handled by caller).
304    // We push start first, then the reversed chain.
305    chain.push(start.to_string());
306    chain.reverse();
307
308    // Skip the first element if it's already the last in path.
309    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
320/// DFS to find all cycles, recording back edges.
321fn 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                // Back edge: node → neighbor, and neighbor is on the stack.
338                // This means there's a cycle from neighbor → ... → node → neighbor.
339                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            _ => {} // Black — already fully processed, no cycle through this edge.
357        }
358    }
359
360    color.insert(node.to_string(), Color::Black);
361}
362
363/// DFS that returns `true` as soon as any cycle (back edge) is found.
364fn 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; // Back edge found — cycle exists.
374            }
375            _ => {} // White (no cycle below) or Black — no cycle through this edge.
376        }
377    }
378
379    color.insert(node.to_string(), Color::Black);
380    false
381}
382
383// ---------------------------------------------------------------------------
384// Tests
385// ---------------------------------------------------------------------------
386
387#[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    // -----------------------------------------------------------------------
399    // Helpers
400    // -----------------------------------------------------------------------
401
402    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    /// Build a WorkItemState with the given blocking links applied.
428    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    /// Build a blocking graph from a list of (item_id, blocked_by_ids) pairs.
439    fn build_graph(edges: &[(&str, &[&str])]) -> BlockingGraph {
440        let mut states: HashMap<String, WorkItemState> = HashMap::new();
441        // First pass: ensure all item IDs exist in the map.
442        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        // Second pass: apply blocking links.
453        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    // -----------------------------------------------------------------------
461    // CycleWarning display and properties
462    // -----------------------------------------------------------------------
463
464    #[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    // -----------------------------------------------------------------------
515    // detect_cycle_on_add: self-loop
516    // -----------------------------------------------------------------------
517
518    #[test]
519    fn self_loop_detected() {
520        // Empty graph — adding A→A (A blocked by A) is a self-loop.
521        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    // -----------------------------------------------------------------------
531    // detect_cycle_on_add: 2-node mutual block
532    // -----------------------------------------------------------------------
533
534    #[test]
535    fn mutual_block_detected() {
536        // A is blocked by B. Adding B→A (B blocked by A) creates A↔B cycle.
537        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        // Path should be B → A → B
544        assert_eq!(w.cycle_path.first().unwrap(), "B");
545        assert_eq!(w.cycle_path.last().unwrap(), "B");
546    }
547
548    // -----------------------------------------------------------------------
549    // detect_cycle_on_add: 3-node cycle
550    // -----------------------------------------------------------------------
551
552    #[test]
553    fn three_node_cycle_detected() {
554        // A blocked by B, B blocked by C. Adding C→A (C blocked by A) closes cycle.
555        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        // Path: C → A → B → C
563        assert_eq!(w.cycle_path.first().unwrap(), "C");
564        assert_eq!(w.cycle_path.last().unwrap(), "C");
565    }
566
567    // -----------------------------------------------------------------------
568    // detect_cycle_on_add: no cycle
569    // -----------------------------------------------------------------------
570
571    #[test]
572    fn no_cycle_in_dag() {
573        // A → B → C (linear chain). Adding D→A doesn't create a cycle.
574        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        // A → B, C → D. Adding A→C doesn't create a cycle.
582        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        // Diamond: A → B, A → C, B → D, C → D. Adding E→A is safe.
590        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    // -----------------------------------------------------------------------
596    // detect_cycle_on_add: large cycle (10+ items)
597    // -----------------------------------------------------------------------
598
599    #[test]
600    fn large_cycle_detected() {
601        // Chain: item0 → item1 → item2 → ... → item9.
602        // Adding item9 → item0 closes a 10-node cycle.
603        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        // Convert to the format build_graph expects
611        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        // 50-item chain closed into a cycle.
628        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    // -----------------------------------------------------------------------
648    // detect_cycle_on_add: edge cases
649    // -----------------------------------------------------------------------
650
651    #[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        // A → B exists. Adding A → B again doesn't create a cycle.
661        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        // Disconnected: X → Y → Z and A → B.
669        // Adding B → A creates a 2-node cycle in the A-B subgraph.
670        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    // -----------------------------------------------------------------------
678    // find_all_cycles
679    // -----------------------------------------------------------------------
680
681    #[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        // Should find the self-loop.
701        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        // Two independent cycles: A↔B and C↔D.
718        let graph = build_graph(&[("A", &["B"]), ("B", &["A"]), ("C", &["D"]), ("D", &["C"])]);
719        let cycles = find_all_cycles(&graph);
720        // Should find at least one cycle in each pair.
721        assert!(cycles.len() >= 2);
722    }
723
724    // -----------------------------------------------------------------------
725    // has_cycles
726    // -----------------------------------------------------------------------
727
728    #[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        // Close the cycle.
755        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    // -----------------------------------------------------------------------
773    // Performance: O(V+E) correctness check
774    // -----------------------------------------------------------------------
775
776    #[test]
777    fn performance_large_dag_no_cycle() {
778        // Build a large DAG (1000 nodes in a chain). No cycle.
779        // This verifies O(V+E) — should complete quickly.
780        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        // detect_cycle_on_add: adding a new leaf doesn't create a cycle.
795        let warning = detect_cycle_on_add(&graph, "new_item", &names[0]);
796        assert!(warning.is_none());
797
798        // has_cycles should be false.
799        assert!(!has_cycles(&graph));
800    }
801
802    #[test]
803    fn performance_large_dag_with_cycle_at_end() {
804        // 1000-node chain with cycle at the end.
805        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        // Adding n999 → n0 closes a 1000-node cycle.
820        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    // -----------------------------------------------------------------------
826    // Integration with BlockingGraph
827    // -----------------------------------------------------------------------
828
829    #[test]
830    fn integration_with_crdt_state() {
831        // Build states from CRDT events, construct graph, detect cycle.
832        let mut states: HashMap<String, WorkItemState> = HashMap::new();
833
834        // A is blocked by B.
835        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        // B is blocked by C.
840        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        // C exists, no blockers.
845        states.insert("C".to_string(), WorkItemState::new());
846
847        let graph = BlockingGraph::from_states(&states);
848
849        // Adding C blocked by A would create A → B → C → A.
850        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}