1use 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#[derive(Debug, Clone)]
17pub enum ReorderTarget {
18 Before(ItemId),
20 After(ItemId),
22 Top,
24 Bottom,
26}
27
28pub 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?; let mut item = items
56 .iter()
57 .find(|it| &it.id == id)
58 .cloned()
59 .ok_or_else(|| Error::NotFound(id.clone()))?;
60 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 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 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
113fn 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 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 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; 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)
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; 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)
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; 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()))
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; 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()))
214 .await
215 .expect("reorder");
216 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; 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)
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 let dir = init_temp().await;
241 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")
246 .await
247 .expect("move parent to another column");
248
249 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 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 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; 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)
290 .await
291 .expect("reorder");
292 assert_eq!(order(dir.path()).await, ["T-1", "T-4", "T-2", "T-3", "T-5"]);
294 }
295}
296
297#[derive(Debug, Clone, PartialEq, Eq)]
299pub struct RebalanceOutcome {
300 pub total: usize,
302 pub changed: usize,
304 pub before: RankStats,
306 pub after: RankStats,
308}
309
310pub 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?; 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; }
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}