Skip to main content

pinto/backlog/
graph.rs

1//! Graph inspection of inter-PBI links.
2
3use super::{BacklogItem, ItemId};
4use std::collections::{HashMap, HashSet};
5
6/// Return whether assigning `parent` as `child`'s parent would create a cycle in `items`.
7///
8/// Parent-child links must remain acyclic. Trace the ancestors of `parent`; reaching `child` means
9/// the new link would close a cycle. Track visited IDs so malformed existing data cannot cause an
10/// infinite loop.
11#[must_use]
12pub fn parent_creates_cycle(items: &[BacklogItem], child: &ItemId, parent: &ItemId) -> bool {
13    if child == parent {
14        return true;
15    }
16    let parents: HashMap<&ItemId, &ItemId> = items
17        .iter()
18        .filter_map(|it| it.parent.as_ref().map(|p| (&it.id, p)))
19        .collect();
20    let mut current = Some(parent);
21    let mut seen = HashSet::new();
22    while let Some(id) = current {
23        if id == child {
24            return true;
25        }
26        if !seen.insert(id) {
27            break;
28        }
29        current = parents.get(id).copied();
30    }
31    false
32}
33
34/// Return whether adding dependency `dep` to `item` would create a cycle.
35///
36/// Follow `dep`'s transitive dependencies until `item` is reached. Dependency cycles are warnings
37/// rather than errors; the visited set ensures traversal terminates even when the existing graph
38/// already contains a cycle.
39#[must_use]
40pub fn dependency_creates_cycle(items: &[BacklogItem], item: &ItemId, dep: &ItemId) -> bool {
41    if item == dep {
42        return true;
43    }
44    let deps: HashMap<&ItemId, &[ItemId]> = items
45        .iter()
46        .map(|it| (&it.id, it.depends_on.as_slice()))
47        .collect();
48    let mut stack = vec![dep];
49    let mut seen = HashSet::new();
50    while let Some(id) = stack.pop() {
51        if id == item {
52            return true;
53        }
54        if !seen.insert(id) {
55            continue;
56        }
57        if let Some(edges) = deps.get(id) {
58            stack.extend(edges.iter());
59        }
60    }
61    false
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use crate::backlog::Status;
68    use crate::rank::Rank;
69    use chrono::DateTime;
70
71    /// Create a minimal PBI with number `n` and no parent or dependencies.
72    fn item(n: u32) -> BacklogItem {
73        BacklogItem::new(
74            ItemId::new("T", n),
75            format!("Item {n}"),
76            Status::new("todo"),
77            Rank::between(None, None).expect("open bounds produce a rank"),
78            DateTime::from_timestamp(0, 0).expect("valid epoch"),
79        )
80        .expect("valid item")
81    }
82
83    /// Create a PBI with parent `parent`.
84    fn item_with_parent(n: u32, parent: u32) -> BacklogItem {
85        let mut it = item(n);
86        it.parent = Some(ItemId::new("T", parent));
87        it
88    }
89
90    /// Create a PBI with dependencies `deps`.
91    fn item_with_deps(n: u32, deps: &[u32]) -> BacklogItem {
92        let mut it = item(n);
93        it.depends_on = deps.iter().map(|&d| ItemId::new("T", d)).collect();
94        it
95    }
96
97    // --- parent_creates_cycle ---
98
99    #[test]
100    fn parent_self_reference_is_a_cycle() {
101        let items = [item(1)];
102        assert!(parent_creates_cycle(
103            &items,
104            &ItemId::new("T", 1),
105            &ItemId::new("T", 1)
106        ));
107    }
108
109    #[test]
110    fn parent_to_unrelated_item_is_acyclic() {
111        // 1 and 2 are irrelevant. Even if you change the parent of 1 to 2, it will not cycle.
112        let items = [item(1), item(2)];
113        assert!(!parent_creates_cycle(
114            &items,
115            &ItemId::new("T", 1),
116            &ItemId::new("T", 2)
117        ));
118    }
119
120    #[test]
121    fn parent_to_descendant_is_a_cycle() {
122        // The parent of 2 is 1 (parent and child of 1 → 2). If the parent of 1 is set to 2, it cycles as 1↔2.
123        let items = [item(1), item_with_parent(2, 1)];
124        assert!(parent_creates_cycle(
125            &items,
126            &ItemId::new("T", 1),
127            &ItemId::new("T", 2)
128        ));
129    }
130
131    #[test]
132    fn parent_to_transitive_descendant_is_a_cycle() {
133        // 1 ← 2 ← 3 (parent 2 of 3, parent 1 of 2). If you change the parent of 1 to 3, it becomes a cycle.
134        let items = [item(1), item_with_parent(2, 1), item_with_parent(3, 2)];
135        assert!(parent_creates_cycle(
136            &items,
137            &ItemId::new("T", 1),
138            &ItemId::new("T", 3)
139        ));
140    }
141
142    #[test]
143    fn parent_to_sibling_subtree_is_acyclic() {
144        // 1 ← 2, 1 ← 3. Even if you change the parent of 3 to 2 (2 is not a descendant of 3), it will not cycle.
145        let items = [item(1), item_with_parent(2, 1), item_with_parent(3, 1)];
146        assert!(!parent_creates_cycle(
147            &items,
148            &ItemId::new("T", 3),
149            &ItemId::new("T", 2)
150        ));
151    }
152
153    #[test]
154    fn parent_traversal_stops_on_an_existing_cycle() {
155        let items = [item_with_parent(1, 2), item_with_parent(2, 1)];
156        assert!(!parent_creates_cycle(
157            &items,
158            &ItemId::new("T", 3),
159            &ItemId::new("T", 1)
160        ));
161    }
162
163    // --- dependency_creates_cycle ---
164
165    #[test]
166    fn dependency_self_reference_is_a_cycle() {
167        let items = [item(1)];
168        assert!(dependency_creates_cycle(
169            &items,
170            &ItemId::new("T", 1),
171            &ItemId::new("T", 1)
172        ));
173    }
174
175    #[test]
176    fn dependency_on_independent_item_is_acyclic() {
177        let items = [item(1), item(2)];
178        assert!(!dependency_creates_cycle(
179            &items,
180            &ItemId::new("T", 1),
181            &ItemId::new("T", 2)
182        ));
183    }
184
185    #[test]
186    fn dependency_back_edge_is_a_cycle() {
187        // 2 depends on 1. If you add "depends on 2" to 1, it will cycle as 1↔2.
188        let items = [item(1), item_with_deps(2, &[1])];
189        assert!(dependency_creates_cycle(
190            &items,
191            &ItemId::new("T", 1),
192            &ItemId::new("T", 2)
193        ));
194    }
195
196    #[test]
197    fn dependency_transitive_back_edge_is_a_cycle() {
198        // 3→2→1 dependent chain. Adding “dependence on 3” to 1 creates a cycle.
199        let items = [item(1), item_with_deps(2, &[1]), item_with_deps(3, &[2])];
200        assert!(dependency_creates_cycle(
201            &items,
202            &ItemId::new("T", 1),
203            &ItemId::new("T", 3)
204        ));
205    }
206
207    #[test]
208    fn dependency_shared_diamond_is_acyclic() {
209        // 2→1, 3→1, 4 depends on 2 and 3 (diamond). Adding 4→1 does not cycle.
210        let items = [
211            item(1),
212            item_with_deps(2, &[1]),
213            item_with_deps(3, &[1]),
214            item_with_deps(4, &[2, 3]),
215        ];
216        assert!(!dependency_creates_cycle(
217            &items,
218            &ItemId::new("T", 4),
219            &ItemId::new("T", 1)
220        ));
221    }
222
223    #[test]
224    fn dependency_traversal_ignores_an_existing_cycle_not_reaching_the_item() {
225        let items = [item_with_deps(1, &[2]), item_with_deps(2, &[1])];
226        assert!(!dependency_creates_cycle(
227            &items,
228            &ItemId::new("T", 3),
229            &ItemId::new("T", 1)
230        ));
231    }
232}