use crate::backlog::{BacklogItem, ItemId, Status};
use crate::error::{Error, Result};
use crate::rank::{Rank, RankStats};
use crate::service::open_board_locked;
use crate::storage::BacklogItemRepository;
use chrono::Utc;
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone)]
pub enum ReorderTarget {
Before(ItemId),
After(ItemId),
Top,
Bottom,
}
pub async fn reorder_item(
project_dir: &Path,
id: &ItemId,
target: ReorderTarget,
) -> Result<BacklogItem> {
let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
let items = repo.list().await?; let mut item = items
.iter()
.find(|it| &it.id == id)
.cloned()
.ok_or_else(|| Error::NotFound(id.clone()))?;
let siblings: Vec<&BacklogItem> = items
.iter()
.filter(|it| &it.id != id && it.parent == item.parent && it.status == item.status)
.collect();
let new_rank = match &target {
ReorderTarget::Before(reference) => {
if reference == id {
return Err(Error::SelfReference(id.clone()));
}
let pos = sibling_pos(&items, &siblings, id, reference)?;
let hi = &siblings[pos].rank;
match pos.checked_sub(1) {
Some(prev) => Rank::between(Some(&siblings[prev].rank), Some(hi))?,
None => Rank::before(Some(hi)),
}
}
ReorderTarget::After(reference) => {
if reference == id {
return Err(Error::SelfReference(id.clone()));
}
let pos = sibling_pos(&items, &siblings, id, reference)?;
let lo = &siblings[pos].rank;
match siblings.get(pos + 1) {
Some(next) => Rank::between(Some(lo), Some(&next.rank))?,
None => Rank::after(Some(lo)),
}
}
ReorderTarget::Top => match siblings.first().map(|it| &it.rank) {
Some(first) => Rank::before(Some(first)),
None => item.rank.clone(),
},
ReorderTarget::Bottom => match siblings.last().map(|it| &it.rank) {
Some(last) => Rank::after(Some(last)),
None => item.rank.clone(),
},
};
if new_rank == item.rank {
return Ok(item);
}
item.rank = new_rank;
item.updated = Utc::now();
repo.save(&item).await?;
repo.commit(&format!("pinto: update {}", item.id)).await?;
Ok(item)
}
fn sibling_pos(
items: &[BacklogItem],
siblings: &[&BacklogItem],
id: &ItemId,
reference: &ItemId,
) -> Result<usize> {
if let Some(pos) = siblings.iter().position(|it| &it.id == reference) {
return Ok(pos);
}
if items.iter().any(|it| &it.id == reference) {
Err(Error::NotSibling {
item: id.clone(),
reference: reference.clone(),
})
} else {
Err(Error::NotFound(reference.clone()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::service::test_support::init_temp;
use crate::service::{ListFilter, NewItem, add_item, list_items, move_item};
use std::path::Path;
async fn add(dir: &Path, title: &str, parent: Option<&ItemId>) -> BacklogItem {
let new = NewItem {
parent: parent.cloned(),
..NewItem::default()
};
add_item(dir, title, new).await.expect("add succeeds")
}
async fn order(dir: &Path) -> Vec<String> {
list_items(dir, &ListFilter::default())
.await
.expect("list")
.into_iter()
.map(|it| it.id.to_string())
.collect()
}
#[tokio::test]
async fn top_is_a_noop_for_an_only_child() {
let dir = init_temp().await;
let p = add(dir.path(), "P", None).await; let c = add(dir.path(), "C", Some(&p.id)).await; let _p2 = add(dir.path(), "P2", None).await;
let after = reorder_item(dir.path(), &c.id, ReorderTarget::Top)
.await
.expect("reorder");
assert_eq!(after.rank, c.rank, "only child: Top is a no-op");
}
#[tokio::test]
async fn bottom_is_a_noop_for_an_only_child() {
let dir = init_temp().await;
let p = add(dir.path(), "P", None).await; let c = add(dir.path(), "C", Some(&p.id)).await; let _p2 = add(dir.path(), "P2", None).await;
let after = reorder_item(dir.path(), &c.id, ReorderTarget::Bottom)
.await
.expect("reorder");
assert_eq!(after.rank, c.rank, "only child: Bottom is a no-op");
}
#[tokio::test]
async fn before_a_non_sibling_is_rejected() {
let dir = init_temp().await;
let p = add(dir.path(), "P", None).await; let c = add(dir.path(), "C", Some(&p.id)).await; let r = add(dir.path(), "R", None).await;
let err = reorder_item(dir.path(), &c.id, ReorderTarget::Before(r.id.clone()))
.await
.expect_err("non-sibling reference must be rejected");
assert!(
matches!(err, Error::NotSibling { .. }),
"expected NotSibling, got {err:?}"
);
}
#[tokio::test]
async fn before_a_sibling_reorders_within_the_group() {
let dir = init_temp().await;
let p = add(dir.path(), "P", None).await; let c1 = add(dir.path(), "C1", Some(&p.id)).await; let _c2 = add(dir.path(), "C2", Some(&p.id)).await; let c3 = add(dir.path(), "C3", Some(&p.id)).await;
reorder_item(dir.path(), &c3.id, ReorderTarget::Before(c1.id.clone()))
.await
.expect("reorder");
assert_eq!(order(dir.path()).await, ["T-1", "T-4", "T-2", "T-3"]);
}
#[tokio::test]
async fn moving_a_parent_carries_its_whole_subtree() {
let dir = init_temp().await;
let p1 = add(dir.path(), "P1", None).await; let _c1 = add(dir.path(), "C1", Some(&p1.id)).await; let _c2 = add(dir.path(), "C2", Some(&p1.id)).await; let _p2 = add(dir.path(), "P2", None).await;
reorder_item(dir.path(), &p1.id, ReorderTarget::Bottom)
.await
.expect("reorder");
assert_eq!(order(dir.path()).await, ["T-4", "T-1", "T-2", "T-3"]);
}
#[tokio::test]
async fn siblings_are_grouped_by_parent_even_across_columns() {
let dir = init_temp().await;
let p = add(dir.path(), "P", None).await; let c1 = add(dir.path(), "C1", Some(&p.id)).await; let c2 = add(dir.path(), "C2", Some(&p.id)).await; let root = add(dir.path(), "ROOT", None).await; move_item(dir.path(), &p.id, "in-progress")
.await
.expect("move parent to another column");
reorder_item(dir.path(), &c2.id, ReorderTarget::Top)
.await
.expect("reorder");
let todo = list_items(
dir.path(),
&ListFilter {
status: vec!["todo".to_string()],
..ListFilter::default()
},
)
.await
.expect("list todo");
let ids: Vec<String> = todo.iter().map(|it| it.id.to_string()).collect();
assert_eq!(
ids.iter().position(|i| i == &c2.id.to_string()),
ids.iter()
.position(|i| i == &c1.id.to_string())
.map(|p| p - 1),
"C2 sits immediately before its sibling C1"
);
assert!(ids.contains(&root.id.to_string()), "unrelated root remains");
let err = reorder_item(dir.path(), &c2.id, ReorderTarget::After(root.id.clone()))
.await
.expect_err("root is not a sibling of C2");
assert!(matches!(err, Error::NotSibling { .. }), "got {err:?}");
}
#[tokio::test]
async fn top_moves_a_child_ahead_of_its_siblings_only() {
let dir = init_temp().await;
let p = add(dir.path(), "P", None).await; let _c1 = add(dir.path(), "C1", Some(&p.id)).await; let _c2 = add(dir.path(), "C2", Some(&p.id)).await; let c3 = add(dir.path(), "C3", Some(&p.id)).await; let _r = add(dir.path(), "R", None).await;
reorder_item(dir.path(), &c3.id, ReorderTarget::Top)
.await
.expect("reorder");
assert_eq!(order(dir.path()).await, ["T-1", "T-4", "T-2", "T-3", "T-5"]);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RebalanceOutcome {
pub total: usize,
pub changed: usize,
pub before: RankStats,
pub after: RankStats,
}
pub async fn rebalance(project_dir: &Path, dry_run: bool) -> Result<RebalanceOutcome> {
let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
let mut items = repo.list().await?;
let before = RankStats::collect(items.iter().map(|it| &it.rank));
let mut scopes: HashMap<(Status, Option<ItemId>), Vec<usize>> = HashMap::new();
for (index, item) in items.iter().enumerate() {
scopes
.entry((item.status.clone(), item.parent.clone()))
.or_default()
.push(index);
}
let mut planned = items
.iter()
.map(|item| item.rank.clone())
.collect::<Vec<_>>();
for indexes in scopes.values() {
let current = RankStats::collect(indexes.iter().map(|&index| &items[index].rank));
let fresh = Rank::rebalance(indexes.len());
let target = RankStats::collect(fresh.iter());
if current.max_len <= target.max_len {
continue;
}
for (&index, rank) in indexes.iter().zip(fresh) {
planned[index] = rank;
}
}
let after = RankStats::collect(planned.iter());
let now = Utc::now();
let mut changed = 0;
for (item, rank) in items.iter_mut().zip(planned) {
if item.rank == rank {
continue; }
changed += 1;
if !dry_run {
item.rank = rank;
item.updated = now;
repo.save(item).await?;
}
}
if !dry_run && changed > 0 {
repo.commit("pinto: rebalance").await?;
}
Ok(RebalanceOutcome {
total: items.len(),
changed,
before,
after,
})
}