1use super::{LabelMatch, apply_effective_points, open_board};
4use crate::backlog::{BacklogItem, Status, Workflow};
5use crate::error::{Error, Result};
6use crate::storage::BacklogItemRepository;
7use std::path::Path;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum SortKey {
12 Rank,
14 Done,
16 Created,
18}
19
20#[derive(Debug, Default, Clone)]
22pub struct BoardQuery {
23 pub roots_only: bool,
25 pub sprint: Option<String>,
27 pub labels: Vec<String>,
29 pub label_match: LabelMatch,
31 pub statuses: Vec<String>,
34 pub sort: Option<SortKey>,
37 pub reverse: bool,
39 pub search: Option<super::SearchFilter>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct BoardColumn {
46 pub status: Status,
48 pub items: Vec<BacklogItem>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct Board {
55 pub columns: Vec<BoardColumn>,
57 pub orphaned: Vec<BacklogItem>,
62}
63
64pub async fn board(project_dir: &Path, query: &BoardQuery) -> Result<Board> {
81 let (_board_dir, repo, config) = open_board(project_dir).await?;
82 let mut items = repo.list().await?; apply_effective_points(
84 &mut items,
85 config.points.aggregate_children,
86 &Status::new(&config.done_column),
87 );
88 if query.roots_only {
89 items.retain(|item| item.parent.is_none());
90 }
91 let sprints = if query.search.is_some() {
92 crate::storage::SprintRepository::list(&repo).await?
93 } else {
94 Vec::new()
95 };
96
97 for name in &query.statuses {
99 if !config.columns.iter().any(|c| c == name) {
100 return Err(Error::UnknownStatus(name.clone()));
101 }
102 }
103 let shown: Vec<&String> = if query.statuses.is_empty() {
105 config.columns.iter().collect()
106 } else {
107 config
108 .columns
109 .iter()
110 .filter(|c| query.statuses.iter().any(|s| &s == c))
111 .collect()
112 };
113
114 if let Some(sprint) = &query.sprint {
116 items.retain(|it| it.sprint.as_deref() == Some(sprint.as_str()));
117 }
118 if !query.labels.is_empty() {
119 items.retain(|it| query.label_match.matches(&it.labels, &query.labels));
120 }
121 if let Some(search) = &query.search {
122 items.retain(|item| {
123 let sprint = item
124 .sprint
125 .as_deref()
126 .and_then(|id| sprints.iter().find(|sprint| sprint.id.as_str() == id));
127 search.matches(item, sprint)
128 });
129 }
130
131 let workflow = Workflow::new(config.columns.iter().map(Status::new));
132 let terminal = Status::new(&config.done_column);
135
136 let columns = shown
137 .iter()
138 .map(|name| {
139 let status = Status::new(*name);
140 let mut col_items: Vec<BacklogItem> = items
141 .iter()
142 .filter(|it| it.status == status)
143 .cloned()
144 .collect();
145 match query.sort {
146 Some(key) => sort_items(&mut col_items, key, query.reverse),
148 None if terminal == status => {
150 sort_items(&mut col_items, SortKey::Done, true);
151 }
152 None => {}
153 }
154 BoardColumn {
157 status,
158 items: crate::service::hierarchical(col_items),
159 }
160 })
161 .collect();
162
163 let orphaned = if query.statuses.is_empty() {
165 let orphans: Vec<BacklogItem> = items
166 .into_iter()
167 .filter(|it| !workflow.contains(&it.status))
168 .collect();
169 crate::service::hierarchical(orphans)
170 } else {
171 Vec::new()
172 };
173
174 Ok(Board { columns, orphaned })
175}
176
177fn sort_items(items: &mut [BacklogItem], key: SortKey, reverse: bool) {
182 use std::cmp::Ordering;
183 let flip = |o: Ordering| if reverse { o.reverse() } else { o };
184 match key {
185 SortKey::Rank => items.sort_by(|a, b| flip(a.backlog_cmp(b))),
187 SortKey::Created => items.sort_by(|a, b| flip(a.created.cmp(&b.created))),
188 SortKey::Done => items.sort_by(|a, b| match (a.done_at, b.done_at) {
189 (Some(x), Some(y)) => flip(x.cmp(&y)), (Some(_), None) => Ordering::Less, (None, Some(_)) => Ordering::Greater,
192 (None, None) => Ordering::Equal, }),
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use crate::error::Error;
201 use crate::service::test_support::{init_temp, set_columns};
202 use crate::service::{NewItem, add_item, move_item};
203 use tempfile::TempDir;
204
205 #[tokio::test]
206 async fn board_groups_items_by_column_in_config_order() {
207 let dir = init_temp().await;
208 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
209 let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
210 move_item(dir.path(), &b.id, "in-progress").await.unwrap();
212
213 let board = board(dir.path(), &BoardQuery::default())
214 .await
215 .expect("board succeeds");
216
217 let names: Vec<_> = board
219 .columns
220 .iter()
221 .map(|c| c.status.as_str().to_string())
222 .collect();
223 assert_eq!(names, ["todo", "in-progress", "review", "done"]);
224
225 assert_eq!(
227 board.columns[0]
228 .items
229 .iter()
230 .map(|it| &it.id)
231 .collect::<Vec<_>>(),
232 [&a.id]
233 );
234 assert_eq!(
235 board.columns[1]
236 .items
237 .iter()
238 .map(|it| &it.id)
239 .collect::<Vec<_>>(),
240 [&b.id]
241 );
242 assert!(board.columns[2].items.is_empty());
243 assert!(board.columns[3].items.is_empty());
244 }
245
246 #[tokio::test]
247 async fn board_items_within_column_are_rank_ordered() {
248 let dir = init_temp().await;
249 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
250 let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
251 let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
252
253 let board = board(dir.path(), &BoardQuery::default()).await.unwrap();
254 let ids: Vec<_> = board.columns[0]
255 .items
256 .iter()
257 .map(|it| it.id.clone())
258 .collect();
259 assert_eq!(ids, [a.id, b.id, c.id]);
260 }
261
262 #[tokio::test]
263 async fn board_on_uninitialized_dir_prompts_init() {
264 let dir = TempDir::new().expect("temp dir");
265 let err = board(dir.path(), &BoardQuery::default()).await.unwrap_err();
266 assert!(
267 matches!(err, Error::NotInitialized { .. }),
268 "expected NotInitialized, got {err:?}"
269 );
270 }
271
272 #[tokio::test]
273 async fn board_reflects_custom_column_order_and_additions() {
274 let dir = init_temp().await;
275 set_columns(dir.path(), &["backlog", "doing", "done", "blocked"]).await;
277 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
278 move_item(dir.path(), &a.id, "blocked").await.unwrap();
280
281 let board = board(dir.path(), &BoardQuery::default())
282 .await
283 .expect("board succeeds");
284
285 let names: Vec<_> = board
286 .columns
287 .iter()
288 .map(|c| c.status.as_str().to_string())
289 .collect();
290 assert_eq!(names, ["backlog", "doing", "done", "blocked"]);
291 assert_eq!(board.columns[0].items, []);
293 assert_eq!(
294 board.columns[3]
295 .items
296 .iter()
297 .map(|it| &it.id)
298 .collect::<Vec<_>>(),
299 [&a.id]
300 );
301 assert!(board.orphaned.is_empty(), "no undefined-column items");
302 }
303
304 #[tokio::test]
305 async fn move_to_newly_added_column_succeeds() {
306 let dir = init_temp().await;
307 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
308 set_columns(dir.path(), &["todo", "in-progress", "review", "done", "qa"]).await;
309
310 let moved = move_item(dir.path(), &a.id, "qa")
311 .await
312 .expect("move to added column succeeds");
313 assert_eq!(moved.status, Status::new("qa"));
314 }
315
316 #[tokio::test]
317 async fn board_collects_items_in_undefined_columns_as_orphaned() {
318 let dir = init_temp().await;
319 let stay = add_item(dir.path(), "Stay", NewItem::default())
320 .await
321 .unwrap();
322 let orphan = add_item(dir.path(), "Orphan", NewItem::default())
323 .await
324 .unwrap();
325 move_item(dir.path(), &orphan.id, "review").await.unwrap();
326
327 set_columns(dir.path(), &["todo", "in-progress", "done"]).await;
329
330 let board = board(dir.path(), &BoardQuery::default())
331 .await
332 .expect("board succeeds");
333
334 let in_columns: Vec<_> = board
336 .columns
337 .iter()
338 .flat_map(|c| &c.items)
339 .map(|it| it.id.clone())
340 .collect();
341 assert_eq!(in_columns, [stay.id]);
342
343 assert_eq!(
345 board.orphaned.iter().map(|it| &it.id).collect::<Vec<_>>(),
346 [&orphan.id]
347 );
348 assert_eq!(board.orphaned[0].status, Status::new("review"));
349 }
350
351 #[tokio::test]
352 async fn board_scoped_to_sprint_shows_only_assigned_items() {
353 use crate::service::{assign_sprint, create_sprint};
354 use crate::sprint::SprintId;
355
356 let dir = init_temp().await;
357 let sprint = SprintId::new("S-1").unwrap();
358 create_sprint(dir.path(), &sprint, "Sprint 1", None, None)
359 .await
360 .unwrap();
361 let in_sprint = add_item(dir.path(), "In sprint", NewItem::default())
362 .await
363 .unwrap();
364 let _out = add_item(dir.path(), "Not in sprint", NewItem::default())
365 .await
366 .unwrap();
367 assign_sprint(dir.path(), &sprint, &in_sprint.id)
368 .await
369 .unwrap();
370
371 let board = board(
372 dir.path(),
373 &BoardQuery {
374 sprint: Some("S-1".to_string()),
375 ..Default::default()
376 },
377 )
378 .await
379 .expect("scoped board succeeds");
380
381 let ids: Vec<_> = board
383 .columns
384 .iter()
385 .flat_map(|c| &c.items)
386 .map(|it| it.id.clone())
387 .collect();
388 assert_eq!(ids, [in_sprint.id]);
389 }
390
391 #[tokio::test]
392 async fn board_filters_by_label() {
393 let dir = init_temp().await;
394 let backend = NewItem {
395 labels: vec!["backend".to_string()],
396 ..Default::default()
397 };
398 let matching = add_item(dir.path(), "Backend item", backend).await.unwrap();
399 let frontend = NewItem {
400 labels: vec!["frontend".to_string()],
401 ..Default::default()
402 };
403 add_item(dir.path(), "Frontend item", frontend)
404 .await
405 .unwrap();
406
407 let board = board(
408 dir.path(),
409 &BoardQuery {
410 labels: vec!["backend".to_string()],
411 ..Default::default()
412 },
413 )
414 .await
415 .expect("label-scoped board succeeds");
416
417 let ids: Vec<_> = board
418 .columns
419 .iter()
420 .flat_map(|column| &column.items)
421 .map(|item| item.id.clone())
422 .collect();
423 assert_eq!(ids, [matching.id]);
424 }
425
426 #[tokio::test]
427 async fn board_filters_by_multiple_labels_with_any_or_all_matching() {
428 let dir = init_temp().await;
429 let backend = add_item(
430 dir.path(),
431 "Backend item",
432 NewItem {
433 labels: vec!["backend".to_string()],
434 ..Default::default()
435 },
436 )
437 .await
438 .unwrap();
439 let frontend = add_item(
440 dir.path(),
441 "Frontend item",
442 NewItem {
443 labels: vec!["frontend".to_string()],
444 ..Default::default()
445 },
446 )
447 .await
448 .unwrap();
449 let both = add_item(
450 dir.path(),
451 "Both labels",
452 NewItem {
453 labels: vec!["backend".to_string(), "frontend".to_string()],
454 ..Default::default()
455 },
456 )
457 .await
458 .unwrap();
459 let labels = vec!["backend".to_string(), "frontend".to_string()];
460
461 let any = board(
462 dir.path(),
463 &BoardQuery {
464 labels: labels.clone(),
465 ..Default::default()
466 },
467 )
468 .await
469 .unwrap();
470 let any_ids: Vec<_> = any
471 .columns
472 .iter()
473 .flat_map(|column| &column.items)
474 .map(|item| item.id.clone())
475 .collect();
476 assert_eq!(
477 any_ids,
478 [backend.id.clone(), frontend.id.clone(), both.id.clone()]
479 );
480
481 let all = board(
482 dir.path(),
483 &BoardQuery {
484 labels,
485 label_match: LabelMatch::All,
486 ..Default::default()
487 },
488 )
489 .await
490 .unwrap();
491 let all_ids: Vec<_> = all
492 .columns
493 .iter()
494 .flat_map(|column| &column.items)
495 .map(|item| item.id.clone())
496 .collect();
497 assert_eq!(all_ids, [both.id]);
498 }
499
500 #[tokio::test]
503 async fn board_status_filter_shows_only_requested_columns_in_config_order() {
504 let dir = init_temp().await;
505 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
506 let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
507 move_item(dir.path(), &b.id, "done").await.unwrap();
508
509 let query = BoardQuery {
511 statuses: vec!["done".to_string(), "todo".to_string()],
512 ..Default::default()
513 };
514 let board = board(dir.path(), &query).await.expect("board succeeds");
515
516 let names: Vec<_> = board
517 .columns
518 .iter()
519 .map(|c| c.status.as_str().to_string())
520 .collect();
521 assert_eq!(
522 names,
523 ["todo", "done"],
524 "only requested columns, config order"
525 );
526 assert_eq!(
527 board.columns[0]
528 .items
529 .iter()
530 .map(|i| &i.id)
531 .collect::<Vec<_>>(),
532 [&a.id]
533 );
534 assert_eq!(
535 board.columns[1]
536 .items
537 .iter()
538 .map(|i| &i.id)
539 .collect::<Vec<_>>(),
540 [&b.id]
541 );
542 }
543
544 #[tokio::test]
545 async fn board_status_filter_rejects_unknown_column() {
546 let dir = init_temp().await;
547 let query = BoardQuery {
548 statuses: vec!["nonexistent".to_string()],
549 ..Default::default()
550 };
551 let err = board(dir.path(), &query).await.unwrap_err();
552 assert!(
553 matches!(err, Error::UnknownStatus(ref s) if s == "nonexistent"),
554 "got {err:?}"
555 );
556 }
557
558 #[tokio::test]
559 async fn board_status_filter_combines_with_sprint_scope() {
560 use crate::service::{assign_sprint, create_sprint};
561 use crate::sprint::SprintId;
562
563 let dir = init_temp().await;
564 let sprint = SprintId::new("S-1").unwrap();
565 create_sprint(dir.path(), &sprint, "Sprint", None, None)
566 .await
567 .unwrap();
568 let a = add_item(dir.path(), "In sprint todo", NewItem::default())
569 .await
570 .unwrap();
571 let b = add_item(dir.path(), "In sprint done", NewItem::default())
572 .await
573 .unwrap();
574 let c = add_item(dir.path(), "Off sprint todo", NewItem::default())
575 .await
576 .unwrap();
577 assign_sprint(dir.path(), &sprint, &a.id).await.unwrap();
578 assign_sprint(dir.path(), &sprint, &b.id).await.unwrap();
579 move_item(dir.path(), &b.id, "done").await.unwrap();
580
581 let query = BoardQuery {
583 sprint: Some("S-1".to_string()),
584 statuses: vec!["todo".to_string()],
585 ..Default::default()
586 };
587 let board = board(dir.path(), &query).await.expect("board succeeds");
588
589 assert_eq!(board.columns.len(), 1);
590 assert_eq!(board.columns[0].status.as_str(), "todo");
591 let ids: Vec<_> = board.columns[0]
592 .items
593 .iter()
594 .map(|i| i.id.clone())
595 .collect();
596 assert_eq!(ids, [a.id]);
597 let _ = c;
598 }
599
600 #[tokio::test]
601 async fn board_status_filter_suppresses_orphaned() {
602 let dir = init_temp().await;
603 let orphan = add_item(dir.path(), "Orphan", NewItem::default())
604 .await
605 .unwrap();
606 move_item(dir.path(), &orphan.id, "review").await.unwrap();
607 set_columns(dir.path(), &["todo", "in-progress", "done"]).await;
609
610 let query = BoardQuery {
611 statuses: vec!["todo".to_string()],
612 ..Default::default()
613 };
614 let board = board(dir.path(), &query).await.expect("board succeeds");
615
616 assert!(
617 board.orphaned.is_empty(),
618 "orphaned suppressed when filtering columns"
619 );
620 }
621
622 async fn set_done_at(dir: &Path, id: &crate::backlog::ItemId, done_at: Option<i64>) {
626 use crate::storage::{BacklogItemRepository, FileRepository};
627 let repo = FileRepository::new(dir.join(".pinto"));
628 let mut item = repo.load(id).await.expect("load");
629 item.done_at = done_at.map(|s| chrono::DateTime::from_timestamp(s, 0).expect("ts"));
630 repo.save(&item).await.expect("save");
631 }
632
633 #[tokio::test]
634 async fn board_done_column_orders_by_completion_desc_and_undated_last() {
635 let dir = init_temp().await;
636 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
637 let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
638 let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
639 let d = add_item(dir.path(), "D", NewItem::default()).await.unwrap();
640 let e = add_item(dir.path(), "E", NewItem::default()).await.unwrap();
641 for it in [&a, &b, &c, &d, &e] {
642 move_item(dir.path(), &it.id, "done").await.unwrap();
643 }
644 set_done_at(dir.path(), &a.id, Some(100)).await;
646 set_done_at(dir.path(), &b.id, Some(300)).await;
647 set_done_at(dir.path(), &c.id, Some(200)).await;
648 set_done_at(dir.path(), &d.id, None).await;
649 set_done_at(dir.path(), &e.id, None).await;
650
651 let board = board(dir.path(), &BoardQuery::default())
652 .await
653 .expect("board succeeds");
654 let done = board
655 .columns
656 .iter()
657 .find(|c| c.status.as_str() == "done")
658 .expect("done column");
659 let ids: Vec<_> = done.items.iter().map(|i| i.id.clone()).collect();
660
661 assert_eq!(ids, [b.id, c.id, a.id, d.id, e.id]);
663 }
664
665 #[tokio::test]
666 async fn board_non_done_columns_remain_rank_ordered() {
667 let dir = init_temp().await;
668 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
669 let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
670 let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
671 for it in [&a, &b, &c] {
672 move_item(dir.path(), &it.id, "in-progress").await.unwrap();
673 }
674
675 let board = board(dir.path(), &BoardQuery::default())
676 .await
677 .expect("board succeeds");
678 let wip = board
679 .columns
680 .iter()
681 .find(|c| c.status.as_str() == "in-progress")
682 .expect("in-progress column");
683 let ids: Vec<_> = wip.items.iter().map(|i| i.id.clone()).collect();
684 assert_eq!(
685 ids,
686 [a.id, b.id, c.id],
687 "non-terminal columns keep rank order"
688 );
689 }
690
691 #[tokio::test]
692 async fn board_done_column_setting_is_position_independent() {
693 let dir = init_temp().await;
694 set_columns(dir.path(), &["todo", "done", "archived"]).await;
696
697 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
698 let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
699 let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
700 for it in [&a, &b, &c] {
701 move_item(dir.path(), &it.id, "done").await.unwrap();
702 }
703 set_done_at(dir.path(), &a.id, Some(100)).await;
704 set_done_at(dir.path(), &b.id, Some(300)).await;
705 set_done_at(dir.path(), &c.id, Some(200)).await;
706
707 let board = board(dir.path(), &BoardQuery::default())
708 .await
709 .expect("board succeeds");
710 let done = board
711 .columns
712 .iter()
713 .find(|c| c.status.as_str() == "done")
714 .expect("done column");
715 let ids: Vec<_> = done.items.iter().map(|i| i.id.clone()).collect();
716 assert_eq!(ids, [b.id, c.id, a.id]);
718 }
719
720 #[tokio::test]
723 async fn board_sort_created_applies_to_all_columns() {
724 let dir = init_temp().await;
725 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
726 let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
727 let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
728 set_created(dir.path(), &a.id, 300).await;
730 set_created(dir.path(), &b.id, 100).await;
731 set_created(dir.path(), &c.id, 200).await;
732
733 let asc = BoardQuery {
735 sort: Some(SortKey::Created),
736 ..Default::default()
737 };
738 let asc_board = board(dir.path(), &asc).await.unwrap();
739 assert_eq!(
740 asc_board.columns[0]
741 .items
742 .iter()
743 .map(|i| i.id.clone())
744 .collect::<Vec<_>>(),
745 [b.id.clone(), c.id.clone(), a.id.clone()]
746 );
747
748 let desc = BoardQuery {
750 sort: Some(SortKey::Created),
751 reverse: true,
752 ..Default::default()
753 };
754 let desc_board = board(dir.path(), &desc).await.unwrap();
755 assert_eq!(
756 desc_board.columns[0]
757 .items
758 .iter()
759 .map(|i| i.id.clone())
760 .collect::<Vec<_>>(),
761 [a.id, c.id, b.id]
762 );
763 }
764
765 #[tokio::test]
766 async fn board_sort_rank_overrides_default_done_ordering() {
767 let dir = init_temp().await;
768 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
769 let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
770 let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
771 for it in [&a, &b, &c] {
772 move_item(dir.path(), &it.id, "done").await.unwrap();
773 }
774 set_done_at(dir.path(), &a.id, Some(100)).await;
776 set_done_at(dir.path(), &b.id, Some(200)).await;
777 set_done_at(dir.path(), &c.id, Some(300)).await;
778
779 let query = BoardQuery {
780 sort: Some(SortKey::Rank),
781 ..Default::default()
782 };
783 let board = board(dir.path(), &query).await.unwrap();
784 let done = board
785 .columns
786 .iter()
787 .find(|c| c.status.as_str() == "done")
788 .unwrap();
789 assert_eq!(
790 done.items.iter().map(|i| i.id.clone()).collect::<Vec<_>>(),
791 [a.id, b.id, c.id],
792 "explicit --sort rank overrides the default done ordering"
793 );
794 }
795
796 async fn set_created(dir: &Path, id: &crate::backlog::ItemId, created: i64) {
798 use crate::storage::{BacklogItemRepository, FileRepository};
799 let repo = FileRepository::new(dir.join(".pinto"));
800 let mut item = repo.load(id).await.expect("load");
801 item.created = chrono::DateTime::from_timestamp(created, 0).expect("ts");
802 repo.save(&item).await.expect("save");
803 }
804}