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) {
369            Some(Color::White) => {
370                if dfs_has_cycle(graph, neighbor, color) {
371                    return true;
372                }
373            }
374            Some(Color::Gray) => {
375                return true; // Back edge found — cycle exists.
376            }
377            _ => {} // Black — no cycle through this edge.
378        }
379    }
380
381    color.insert(node.to_string(), Color::Black);
382    false
383}
384
385// ---------------------------------------------------------------------------
386// Tests
387// ---------------------------------------------------------------------------
388
389#[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    // -----------------------------------------------------------------------
401    // Helpers
402    // -----------------------------------------------------------------------
403
404    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    /// Build a WorkItemState with the given blocking links applied.
430    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    /// Build a blocking graph from a list of (item_id, blocked_by_ids) pairs.
441    fn build_graph(edges: &[(&str, &[&str])]) -> BlockingGraph {
442        let mut states: HashMap<String, WorkItemState> = HashMap::new();
443        // First pass: ensure all item IDs exist in the map.
444        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        // Second pass: apply blocking links.
455        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    // -----------------------------------------------------------------------
463    // CycleWarning display and properties
464    // -----------------------------------------------------------------------
465
466    #[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    // -----------------------------------------------------------------------
517    // detect_cycle_on_add: self-loop
518    // -----------------------------------------------------------------------
519
520    #[test]
521    fn self_loop_detected() {
522        // Empty graph — adding A→A (A blocked by A) is a self-loop.
523        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    // -----------------------------------------------------------------------
533    // detect_cycle_on_add: 2-node mutual block
534    // -----------------------------------------------------------------------
535
536    #[test]
537    fn mutual_block_detected() {
538        // A is blocked by B. Adding B→A (B blocked by A) creates A↔B cycle.
539        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        // Path should be B → A → B
546        assert_eq!(w.cycle_path.first().unwrap(), "B");
547        assert_eq!(w.cycle_path.last().unwrap(), "B");
548    }
549
550    // -----------------------------------------------------------------------
551    // detect_cycle_on_add: 3-node cycle
552    // -----------------------------------------------------------------------
553
554    #[test]
555    fn three_node_cycle_detected() {
556        // A blocked by B, B blocked by C. Adding C→A (C blocked by A) closes cycle.
557        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        // Path: C → A → B → C
565        assert_eq!(w.cycle_path.first().unwrap(), "C");
566        assert_eq!(w.cycle_path.last().unwrap(), "C");
567    }
568
569    // -----------------------------------------------------------------------
570    // detect_cycle_on_add: no cycle
571    // -----------------------------------------------------------------------
572
573    #[test]
574    fn no_cycle_in_dag() {
575        // A → B → C (linear chain). Adding D→A doesn't create a cycle.
576        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        // A → B, C → D. Adding A→C doesn't create a cycle.
584        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        // Diamond: A → B, A → C, B → D, C → D. Adding E→A is safe.
592        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    // -----------------------------------------------------------------------
598    // detect_cycle_on_add: large cycle (10+ items)
599    // -----------------------------------------------------------------------
600
601    #[test]
602    fn large_cycle_detected() {
603        // Chain: item0 → item1 → item2 → ... → item9.
604        // Adding item9 → item0 closes a 10-node cycle.
605        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        // Convert to the format build_graph expects
613        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        // 50-item chain closed into a cycle.
630        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    // -----------------------------------------------------------------------
650    // detect_cycle_on_add: edge cases
651    // -----------------------------------------------------------------------
652
653    #[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        // A → B exists. Adding A → B again doesn't create a cycle.
663        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        // Disconnected: X → Y → Z and A → B.
671        // Adding B → A creates a 2-node cycle in the A-B subgraph.
672        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    // -----------------------------------------------------------------------
680    // find_all_cycles
681    // -----------------------------------------------------------------------
682
683    #[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        // Should find the self-loop.
703        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        // Two independent cycles: A↔B and C↔D.
720        let graph = build_graph(&[("A", &["B"]), ("B", &["A"]), ("C", &["D"]), ("D", &["C"])]);
721        let cycles = find_all_cycles(&graph);
722        // Should find at least one cycle in each pair.
723        assert!(cycles.len() >= 2);
724    }
725
726    // -----------------------------------------------------------------------
727    // has_cycles
728    // -----------------------------------------------------------------------
729
730    #[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        // Close the cycle.
757        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    // -----------------------------------------------------------------------
775    // Performance: O(V+E) correctness check
776    // -----------------------------------------------------------------------
777
778    #[test]
779    fn performance_large_dag_no_cycle() {
780        // Build a large DAG (1000 nodes in a chain). No cycle.
781        // This verifies O(V+E) — should complete quickly.
782        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        // detect_cycle_on_add: adding a new leaf doesn't create a cycle.
797        let warning = detect_cycle_on_add(&graph, "new_item", &names[0]);
798        assert!(warning.is_none());
799
800        // has_cycles should be false.
801        assert!(!has_cycles(&graph));
802    }
803
804    #[test]
805    fn performance_large_dag_with_cycle_at_end() {
806        // 1000-node chain with cycle at the end.
807        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        // Adding n999 → n0 closes a 1000-node cycle.
822        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    // -----------------------------------------------------------------------
828    // Integration with BlockingGraph
829    // -----------------------------------------------------------------------
830
831    #[test]
832    fn integration_with_crdt_state() {
833        // Build states from CRDT events, construct graph, detect cycle.
834        let mut states: HashMap<String, WorkItemState> = HashMap::new();
835
836        // A is blocked by B.
837        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        // B is blocked by C.
842        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        // C exists, no blockers.
847        states.insert("C".to_string(), WorkItemState::new());
848
849        let graph = BlockingGraph::from_states(&states);
850
851        // Adding C blocked by A would create A → B → C → A.
852        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}