Skip to main content

pinto/service/
order.rs

1//! Canonical hierarchical (parent/child tree) ordering shared by `list`,
2//! `board`, `kanban`, and JSON output.
3//!
4//! Priority is hierarchical: a parent's position dominates its descendants', so
5//! Rank orders siblings (roots among themselves and the children of one parent)
6//! while the tree structure decides the overall order. Every view flattens the
7//! same forest with [`hierarchical_order`] so a child never floats above an
8//! unrelated, higher-priority item just because its raw rank happens to be lower.
9
10use crate::backlog::{BacklogItem, ItemId};
11use std::collections::HashMap;
12
13/// Parent/child forest over a set of items, classified by membership in that set.
14///
15/// The input is assumed to already be in canonical backlog order
16/// ([`BacklogItem::backlog_cmp`]); this type preserves that order so roots and
17/// siblings stay rank-ordered.
18pub struct Forest {
19    /// Root item indices (no parent, parent outside the set, or self-parent), in input order.
20    pub roots: Vec<usize>,
21    /// Direct children per item index (by input order); empty slice when none.
22    pub children: Vec<Vec<usize>>,
23    /// Indices unreachable from any root (parent-link cycles), in input order.
24    pub unreachable: Vec<usize>,
25}
26
27/// Classify `items` into a [`Forest`] by parent membership within the same set.
28///
29/// An item is a root when it has no parent, its parent is not part of `items`
30/// (e.g. filtered out or living in another column), or it points at itself.
31/// Items reachable only through a parent-link cycle land in
32/// [`Forest::unreachable`] so nothing is dropped from the view.
33#[must_use]
34pub fn build_forest(items: &[BacklogItem]) -> Forest {
35    let index_of: HashMap<&ItemId, usize> = items
36        .iter()
37        .enumerate()
38        .map(|(i, it)| (&it.id, i))
39        .collect();
40    let mut children: Vec<Vec<usize>> = vec![Vec::new(); items.len()];
41    let mut roots: Vec<usize> = Vec::new();
42    for (i, it) in items.iter().enumerate() {
43        match it.parent.as_ref().and_then(|p| index_of.get(p).copied()) {
44            Some(pi) if pi != i => children[pi].push(i),
45            _ => roots.push(i),
46        }
47    }
48    // Everything reachable from a root (following children regardless of fold state).
49    let mut reachable = vec![false; items.len()];
50    let mut stack = roots.clone();
51    while let Some(i) = stack.pop() {
52        if std::mem::replace(&mut reachable[i], true) {
53            continue;
54        }
55        stack.extend(children[i].iter().copied());
56    }
57    let unreachable = (0..items.len()).filter(|&i| !reachable[i]).collect();
58    Forest {
59        roots,
60        children,
61        unreachable,
62    }
63}
64
65/// Pre-order traversal of the fully-expanded forest: every root in input order,
66/// each parent immediately followed by its subtree (children in input order).
67/// Cycle-only items are appended at the end so no item is lost.
68///
69/// Returns indices into `items`. Callers reorder their own collection by it.
70#[must_use]
71pub fn hierarchical_order(items: &[BacklogItem]) -> Vec<usize> {
72    let forest = build_forest(items);
73    let mut out = Vec::with_capacity(items.len());
74    let mut emitted = vec![false; items.len()];
75    for &root in &forest.roots {
76        visit(root, &forest.children, &mut emitted, &mut out);
77    }
78    // Cycle-only items (no root ancestor): keep them so nothing is dropped.
79    for i in forest.unreachable {
80        if !emitted[i] {
81            emitted[i] = true;
82            out.push(i);
83        }
84    }
85    out
86}
87
88/// Reorder `items` into canonical hierarchical priority order.
89///
90/// Convenience wrapper over [`hierarchical_order`] for callers that hold an owned
91/// `Vec` (`list`, each `board` column). `items` must already be in canonical
92/// backlog order so roots and siblings come out rank-ordered.
93pub fn hierarchical(items: Vec<BacklogItem>) -> Vec<BacklogItem> {
94    let order = hierarchical_order(&items);
95    let mut taken: Vec<Option<BacklogItem>> = items.into_iter().map(Some).collect();
96    order
97        .into_iter()
98        .map(|i| taken[i].take().expect("each index visited exactly once"))
99        .collect()
100}
101
102/// Depth-first pre-order emit of `i` then its subtree. `emitted` breaks any
103/// (invalid) cycle reached through children.
104fn visit(i: usize, children: &[Vec<usize>], emitted: &mut [bool], out: &mut Vec<usize>) {
105    if std::mem::replace(&mut emitted[i], true) {
106        return;
107    }
108    out.push(i);
109    for &c in &children[i] {
110        visit(c, children, emitted, out);
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::backlog::Status;
118    use crate::rank::Rank;
119
120    /// Item with an explicit rank (and optional parent), as a repository hands
121    /// it to a view: already sorted into canonical backlog order.
122    fn item(id: &str, rank: &str, parent: Option<&str>) -> BacklogItem {
123        let mut it = BacklogItem::new(
124            id.parse::<ItemId>().unwrap(),
125            id.to_string(),
126            Status::new("todo"),
127            Rank::parse(rank).expect("valid rank"),
128            chrono::Utc::now(),
129        )
130        .unwrap();
131        it.parent = parent.map(|p| p.parse::<ItemId>().unwrap());
132        it
133    }
134
135    fn ordered_ids(items: &[BacklogItem]) -> Vec<String> {
136        hierarchical_order(items)
137            .into_iter()
138            .map(|i| items[i].id.to_string())
139            .collect()
140    }
141
142    #[test]
143    fn subtree_follows_its_parent_below_a_higher_priority_root() {
144        // A child whose raw rank sorts above an unrelated higher-priority root
145        // must still render below it, grouped under its lower-priority parent.
146        //
147        // Flat rank order (input): C_a, HIGH, PARENT, C_b
148        // Hierarchical order:      HIGH, PARENT, C_a, C_b
149        let items = [
150            item("A-1", "b", Some("A-4")), // child, but low raw rank
151            item("A-2", "c", None),        // higher-priority standalone root
152            item("A-4", "d", None),        // lower-priority parent
153            item("A-3", "e", Some("A-4")), // child below its parent
154        ];
155        assert_eq!(
156            ordered_ids(&items),
157            ["A-2", "A-4", "A-1", "A-3"],
158            "the parent's subtree groups under it, below the higher-priority root"
159        );
160    }
161
162    #[test]
163    fn roots_and_siblings_keep_rank_order() {
164        let items = [
165            item("T-1", "a", None),        // root
166            item("T-5", "c", None),        // root
167            item("T-2", "g", Some("T-1")), // child of T-1
168            item("T-3", "m", Some("T-1")), // child of T-1 (lower rank)
169            item("T-4", "t", None),        // root
170        ];
171        assert_eq!(
172            ordered_ids(&items),
173            ["T-1", "T-2", "T-3", "T-5", "T-4"],
174            "roots in rank order; siblings under a parent in rank order"
175        );
176    }
177
178    #[test]
179    fn cycle_only_items_are_appended_not_dropped() {
180        // A ↔ B form a parent cycle with no root ancestor; keep both, at the end.
181        let mut a = item("T-1", "a", Some("T-2"));
182        let b = item("T-2", "b", Some("T-1"));
183        a.parent = Some("T-2".parse().unwrap());
184        let items = [a, b];
185        let ids = ordered_ids(&items);
186        assert_eq!(ids.len(), 2, "no item dropped");
187        assert!(ids.contains(&"T-1".to_string()) && ids.contains(&"T-2".to_string()));
188    }
189}