Skip to main content

pinto/service/item/
reorder.rs

1//! Ordering operations: reorder a single item and rebalance a bloated rank space.
2
3use crate::backlog::{BacklogItem, ItemId, Status};
4use crate::error::{Error, Result};
5use crate::rank::{Rank, RankStats};
6use crate::service::open_board_locked;
7use crate::storage::BacklogItemRepository;
8use chrono::Utc;
9use std::collections::HashMap;
10use std::path::Path;
11
12/// Specify the destination of [`reorder_item`] (how to change `rank`).
13///
14/// All variants move within the item's sibling group (same parent and status); see
15/// [`reorder_item`] for the exact semantics and errors.
16#[derive(Debug, Clone)]
17pub enum ReorderTarget {
18    /// Move to just before the given **sibling** (rank decreases to land ahead of it).
19    Before(ItemId),
20    /// Move to just after the given **sibling** (rank increases to land behind it).
21    After(ItemId),
22    /// Move to the front of the sibling group (no-op when alone in the group).
23    Top,
24    /// Move to the back of the sibling group (no-op when alone in the group).
25    Bottom,
26}
27
28/// Sort the PBI by `id` **within its sibling group** and return the saved [`BacklogItem`].
29/// Change only `rank`; keep `status` and `parent`.
30///
31/// Under the hierarchical display order (see [`crate::service::hierarchical_order`]),
32/// `rank` orders siblings and the tree decides overall priority. Reorder therefore
33/// operates only inside the sibling group — items sharing the same `parent` **and**
34/// the same `status`. Moving a parent implicitly carries its whole subtree, because
35/// children stay grouped under it regardless of its rank.
36///
37/// Adjacent numbering of the fractional index ([`Rank::between`] / [`Rank::before`] /
38/// [`Rank::after`]) replaces only this one item without reassigning others.
39///
40/// - [`ReorderTarget::Before`] / [`ReorderTarget::After`]: move next to the reference,
41///   which must be a sibling. [`Error::SelfReference`] if it is `id` itself,
42///   [`Error::NotSibling`] if it is not a sibling, [`Error::NotFound`] if it is absent.
43/// - [`ReorderTarget::Top`] / [`ReorderTarget::Bottom`]: move to the front/back of the
44///   sibling group. A no-op (rank unchanged) when the item is alone in its group,
45///   even if other same-status items exist in other groups.
46///
47/// [`Error::NotInitialized`] if the board is uninitialized, [`Error::NotFound`] if `id` is absent.
48pub async fn reorder_item(
49    project_dir: &Path,
50    id: &ItemId,
51    target: ReorderTarget,
52) -> Result<BacklogItem> {
53    let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
54    let items = repo.list().await?; // Ascending rank order.
55    let mut item = items
56        .iter()
57        .find(|it| &it.id == id)
58        .cloned()
59        .ok_or_else(|| Error::NotFound(id.clone()))?;
60    // Sibling group in ascending rank order, excluding the item being moved:
61    // same parent and same status (the group that shares one rank axis on the board).
62    let siblings: Vec<&BacklogItem> = items
63        .iter()
64        .filter(|it| &it.id != id && it.parent == item.parent && it.status == item.status)
65        .collect();
66
67    let new_rank = match &target {
68        ReorderTarget::Before(reference) => {
69            if reference == id {
70                return Err(Error::SelfReference(id.clone()));
71            }
72            let pos = sibling_pos(&items, &siblings, id, reference)?;
73            let hi = &siblings[pos].rank;
74            match pos.checked_sub(1) {
75                Some(prev) => Rank::between(Some(&siblings[prev].rank), Some(hi))?,
76                None => Rank::before(Some(hi)),
77            }
78        }
79        ReorderTarget::After(reference) => {
80            if reference == id {
81                return Err(Error::SelfReference(id.clone()));
82            }
83            let pos = sibling_pos(&items, &siblings, id, reference)?;
84            let lo = &siblings[pos].rank;
85            match siblings.get(pos + 1) {
86                Some(next) => Rank::between(Some(lo), Some(&next.rank))?,
87                None => Rank::after(Some(lo)),
88            }
89        }
90        // The first/last neighbours are taken from the sibling group (ascending rank).
91        ReorderTarget::Top => match siblings.first().map(|it| &it.rank) {
92            Some(first) => Rank::before(Some(first)),
93            None => item.rank.clone(),
94        },
95        ReorderTarget::Bottom => match siblings.last().map(|it| &it.rank) {
96            Some(last) => Rank::after(Some(last)),
97            None => item.rank.clone(),
98        },
99    };
100
101    // If the position does not change (e.g. Top/Bottom in the same state), return it as no-op.
102    // Avoid changing `updated` or rewriting the file for a no-op.
103    if new_rank == item.rank {
104        return Ok(item);
105    }
106    item.rank = new_rank;
107    item.updated = Utc::now();
108    repo.save(&item).await?;
109    repo.commit(&format!("pinto: update {}", item.id)).await?;
110    Ok(item)
111}
112
113/// Position of `reference` within `siblings` for a Before/After reorder of `id`.
114///
115/// Distinguishes the two user errors: [`Error::NotFound`] when `reference` is not on
116/// the board at all, [`Error::NotSibling`] when it exists but is in another group.
117fn sibling_pos(
118    items: &[BacklogItem],
119    siblings: &[&BacklogItem],
120    id: &ItemId,
121    reference: &ItemId,
122) -> Result<usize> {
123    if let Some(pos) = siblings.iter().position(|it| &it.id == reference) {
124        return Ok(pos);
125    }
126    if items.iter().any(|it| &it.id == reference) {
127        Err(Error::NotSibling {
128            item: id.clone(),
129            reference: reference.clone(),
130        })
131    } else {
132        Err(Error::NotFound(reference.clone()))
133    }
134}
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::service::test_support::init_temp;
139    use crate::service::{ListFilter, NewItem, add_item, list_items, move_item};
140    use std::path::Path;
141
142    /// Add a PBI, optionally parented, and return it.
143    async fn add(dir: &Path, title: &str, parent: Option<&ItemId>) -> BacklogItem {
144        let new = NewItem {
145            parent: parent.cloned(),
146            ..NewItem::default()
147        };
148        add_item(dir, title, new).await.expect("add succeeds")
149    }
150
151    /// IDs of the whole board in hierarchical order.
152    async fn order(dir: &Path) -> Vec<String> {
153        list_items(dir, &ListFilter::default())
154            .await
155            .expect("list")
156            .into_iter()
157            .map(|it| it.id.to_string())
158            .collect()
159    }
160
161    #[tokio::test]
162    async fn top_is_a_noop_for_an_only_child() {
163        let dir = init_temp().await;
164        let p = add(dir.path(), "P", None).await; // T-1
165        let c = add(dir.path(), "C", Some(&p.id)).await; // T-2 (only child)
166        let _p2 = add(dir.path(), "P2", None).await; // T-3 (another root)
167
168        // The child is alone in its sibling group, so Top must not change its
169        // rank. The previous behavior moved it ahead of unrelated same-status items.
170        let after = reorder_item(dir.path(), &c.id, ReorderTarget::Top)
171            .await
172            .expect("reorder");
173        assert_eq!(after.rank, c.rank, "only child: Top is a no-op");
174    }
175
176    #[tokio::test]
177    async fn bottom_is_a_noop_for_an_only_child() {
178        let dir = init_temp().await;
179        let p = add(dir.path(), "P", None).await; // T-1
180        let c = add(dir.path(), "C", Some(&p.id)).await; // T-2 (only child)
181        let _p2 = add(dir.path(), "P2", None).await; // T-3
182
183        let after = reorder_item(dir.path(), &c.id, ReorderTarget::Bottom)
184            .await
185            .expect("reorder");
186        assert_eq!(after.rank, c.rank, "only child: Bottom is a no-op");
187    }
188
189    #[tokio::test]
190    async fn before_a_non_sibling_is_rejected() {
191        let dir = init_temp().await;
192        let p = add(dir.path(), "P", None).await; // T-1
193        let c = add(dir.path(), "C", Some(&p.id)).await; // T-2 (child of P)
194        let r = add(dir.path(), "R", None).await; // T-3 (root, not a sibling of C)
195
196        let err = reorder_item(dir.path(), &c.id, ReorderTarget::Before(r.id.clone()))
197            .await
198            .expect_err("non-sibling reference must be rejected");
199        assert!(
200            matches!(err, Error::NotSibling { .. }),
201            "expected NotSibling, got {err:?}"
202        );
203    }
204
205    #[tokio::test]
206    async fn before_a_sibling_reorders_within_the_group() {
207        let dir = init_temp().await;
208        let p = add(dir.path(), "P", None).await; // T-1
209        let c1 = add(dir.path(), "C1", Some(&p.id)).await; // T-2
210        let _c2 = add(dir.path(), "C2", Some(&p.id)).await; // T-3
211        let c3 = add(dir.path(), "C3", Some(&p.id)).await; // T-4
212
213        reorder_item(dir.path(), &c3.id, ReorderTarget::Before(c1.id.clone()))
214            .await
215            .expect("reorder");
216        // Subtree stays under P; C3 leads its siblings.
217        assert_eq!(order(dir.path()).await, ["T-1", "T-4", "T-2", "T-3"]);
218    }
219
220    #[tokio::test]
221    async fn moving_a_parent_carries_its_whole_subtree() {
222        let dir = init_temp().await;
223        let p1 = add(dir.path(), "P1", None).await; // T-1
224        let _c1 = add(dir.path(), "C1", Some(&p1.id)).await; // T-2
225        let _c2 = add(dir.path(), "C2", Some(&p1.id)).await; // T-3
226        let _p2 = add(dir.path(), "P2", None).await; // T-4
227
228        // Send P1 below its root sibling P2; the children follow their parent.
229        reorder_item(dir.path(), &p1.id, ReorderTarget::Bottom)
230            .await
231            .expect("reorder");
232        assert_eq!(order(dir.path()).await, ["T-4", "T-1", "T-2", "T-3"]);
233    }
234
235    #[tokio::test]
236    async fn siblings_are_grouped_by_parent_even_across_columns() {
237        // Two children of P whose parent sits in another column still reorder
238        // among themselves: sibling grouping is by `parent`, matching the
239        // canonical `list` hierarchy (where P is present as their parent).
240        let dir = init_temp().await;
241        let p = add(dir.path(), "P", None).await; // T-1
242        let c1 = add(dir.path(), "C1", Some(&p.id)).await; // T-2
243        let c2 = add(dir.path(), "C2", Some(&p.id)).await; // T-3
244        let root = add(dir.path(), "ROOT", None).await; // T-4 (unrelated todo root)
245        move_item(dir.path(), &p.id, "in-progress")
246            .await
247            .expect("move parent to another column");
248
249        // C2 --top reorders only against its sibling C1, not the unrelated root.
250        reorder_item(dir.path(), &c2.id, ReorderTarget::Top)
251            .await
252            .expect("reorder");
253        let todo = list_items(
254            dir.path(),
255            &ListFilter {
256                status: vec!["todo".to_string()],
257                ..ListFilter::default()
258            },
259        )
260        .await
261        .expect("list todo");
262        let ids: Vec<String> = todo.iter().map(|it| it.id.to_string()).collect();
263        // C2 now precedes C1; ROOT keeps its own position (still a distinct group).
264        assert_eq!(
265            ids.iter().position(|i| i == &c2.id.to_string()),
266            ids.iter()
267                .position(|i| i == &c1.id.to_string())
268                .map(|p| p - 1),
269            "C2 sits immediately before its sibling C1"
270        );
271        assert!(ids.contains(&root.id.to_string()), "unrelated root remains");
272
273        // Reordering a child relative to the unrelated root is rejected.
274        let err = reorder_item(dir.path(), &c2.id, ReorderTarget::After(root.id.clone()))
275            .await
276            .expect_err("root is not a sibling of C2");
277        assert!(matches!(err, Error::NotSibling { .. }), "got {err:?}");
278    }
279
280    #[tokio::test]
281    async fn top_moves_a_child_ahead_of_its_siblings_only() {
282        let dir = init_temp().await;
283        let p = add(dir.path(), "P", None).await; // T-1
284        let _c1 = add(dir.path(), "C1", Some(&p.id)).await; // T-2
285        let _c2 = add(dir.path(), "C2", Some(&p.id)).await; // T-3
286        let c3 = add(dir.path(), "C3", Some(&p.id)).await; // T-4
287        let _r = add(dir.path(), "R", None).await; // T-5 (root; must stay put)
288
289        reorder_item(dir.path(), &c3.id, ReorderTarget::Top)
290            .await
291            .expect("reorder");
292        // C3 leads P's children; P and root R keep their positions.
293        assert_eq!(order(dir.path()).await, ["T-1", "T-4", "T-2", "T-3", "T-5"]);
294    }
295}
296
297/// Result of [`rebalance`]. Indicates the status of resolution of rank bloat.
298#[derive(Debug, Clone, PartialEq, Eq)]
299pub struct RebalanceOutcome {
300    /// Total number of PBIs on the board.
301    pub total: usize,
302    /// Number of PBIs whose rank would be updated (or was updated).
303    pub changed: usize,
304    /// Rank length statistics before execution.
305    pub before: RankStats,
306    /// Rank length statistics after execution (=after reallocation).
307    pub after: RankStats,
308}
309
310/// Reassign oversized ranks within each `(status, parent)` sibling scope.
311///
312/// A rank is generated by [`Rank::rebalance`] when its scope has grown beyond
313/// the shortest fixed width for that number of siblings. The existing order
314/// within each scope is retained, while other scopes keep both their ranks and
315/// timestamps. If `dry_run` is true, only the planned statistics are returned.
316///
317/// Only PBIs whose rank changes receive a new `updated` timestamp and are
318/// saved. [`Error::NotInitialized`] if the board is uninitialized.
319pub async fn rebalance(project_dir: &Path, dry_run: bool) -> Result<RebalanceOutcome> {
320    let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
321    let mut items = repo.list().await?; // Ascending rank order.
322
323    let before = RankStats::collect(items.iter().map(|it| &it.rank));
324    let mut scopes: HashMap<(Status, Option<ItemId>), Vec<usize>> = HashMap::new();
325    for (index, item) in items.iter().enumerate() {
326        scopes
327            .entry((item.status.clone(), item.parent.clone()))
328            .or_default()
329            .push(index);
330    }
331
332    let mut planned = items
333        .iter()
334        .map(|item| item.rank.clone())
335        .collect::<Vec<_>>();
336    for indexes in scopes.values() {
337        let current = RankStats::collect(indexes.iter().map(|&index| &items[index].rank));
338        let fresh = Rank::rebalance(indexes.len());
339        let target = RankStats::collect(fresh.iter());
340        if current.max_len <= target.max_len {
341            continue;
342        }
343        for (&index, rank) in indexes.iter().zip(fresh) {
344            planned[index] = rank;
345        }
346    }
347    let after = RankStats::collect(planned.iter());
348
349    let now = Utc::now();
350    let mut changed = 0;
351    for (item, rank) in items.iter_mut().zip(planned) {
352        if item.rank == rank {
353            continue; // Planned rank already present; avoid needless diffs and I/O.
354        }
355        changed += 1;
356        if !dry_run {
357            item.rank = rank;
358            item.updated = now;
359            repo.save(item).await?;
360        }
361    }
362
363    if !dry_run && changed > 0 {
364        repo.commit("pinto: rebalance").await?;
365    }
366
367    Ok(RebalanceOutcome {
368        total: items.len(),
369        changed,
370        before,
371        after,
372    })
373}