Skip to main content

pinto/service/
board.rs

1//! Board display service: group PBIs by workflow column.
2
3use 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/// Sort key used within a board column.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum SortKey {
12    /// Backlog rank order (ascending fractional index).
13    Rank,
14    /// Completion time (`done_at`). Incomplete items always remain at the end.
15    Done,
16    /// Creation time.
17    Created,
18}
19
20/// Filtering, scope, and sorting options for [`board`]. The default includes all PBIs and columns.
21#[derive(Debug, Default, Clone)]
22pub struct BoardQuery {
23    /// Include only PBIs whose persisted parent link is unset.
24    pub roots_only: bool,
25    /// Sprint scope. When set, include only PBIs assigned to this sprint.
26    pub sprint: Option<String>,
27    /// Exact assignee name to match.
28    pub assignee: Option<String>,
29    /// Labels to match. An empty list does not filter by label.
30    pub labels: Vec<String>,
31    /// Matching mode for [`Self::labels`].
32    pub label_match: LabelMatch,
33    /// Columns to display. An empty list includes all columns. Every supplied name must exist in
34    /// `config.toml`; otherwise return [`Error::UnknownStatus`].
35    pub statuses: Vec<String>,
36    /// Sort key within each column. `None` uses `done_at` descending order for the completion
37    /// column and rank ascending order elsewhere. `Some` applies the key to every column.
38    pub sort: Option<SortKey>,
39    /// Reverse the selected sort key; valid only when `sort` is `Some`.
40    pub reverse: bool,
41    /// Search the item's fields and assigned sprint metadata.
42    pub search: Option<super::SearchFilter>,
43}
44
45/// One board column with its status and PBIs in ascending rank order.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct BoardColumn {
48    /// Workflow status (the column name in `config.toml`).
49    pub status: Status,
50    /// PBIs belonging to this column, in ascending rank order.
51    pub items: Vec<BacklogItem>,
52}
53
54/// Board data with columns ordered according to `config.toml`.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Board {
57    /// Columns in workflow order, from left to right.
58    pub columns: Vec<BoardColumn>,
59    /// PBIs whose status is not present in `config.toml`, in ascending rank order.
60    ///
61    /// These items remain visible so users can detect and repair statuses after a column is deleted
62    /// or renamed; they are reported separately instead of being dropped.
63    pub orphaned: Vec<BacklogItem>,
64}
65
66/// Load the board in `project_dir`, grouping PBIs in the column order from `config.toml`.
67///
68/// PBIs in each known column use ascending rank order, matching `list`. Items whose statuses do not
69/// match a configured column are collected in [`Board::orphaned`] rather than discarded.
70///
71/// You can narrow down the display with [`BoardQuery`]:
72/// - If `roots_only` is set, include only PBIs whose persisted parent link is unset. This is
73///   evaluated before the other filters, so a child is not promoted when its parent is hidden.
74/// - If `sprint` is set, include only PBIs assigned to that sprint. Filters apply to both sorting
75///   and orphan detection.
76/// - If `assignee` is set, include only PBIs assigned to that person. Matching is exact and applies
77///   to both sorting and orphan detection.
78/// - If `labels` is specified, only PBIs matching the requested labels will be targeted. The
79///   default [`LabelMatch::Any`] mode is OR; [`LabelMatch::All`] is AND.
80/// - If `statuses` is set, display only those known columns in their configured order. Unknown
81///   names return [`Error::UnknownStatus`]. Orphaned PBIs are omitted when a subset is requested.
82///
83/// [`Error::NotInitialized`] if the board is uninitialized.
84pub async fn board(project_dir: &Path, query: &BoardQuery) -> Result<Board> {
85    let (_board_dir, repo, config) = open_board(project_dir).await?;
86    let mut items = repo.list().await?; // Already in canonical rank order.
87    apply_effective_points(
88        &mut items,
89        config.points.aggregate_children,
90        &Status::new(&config.done_column),
91    );
92    if query.roots_only {
93        items.retain(|item| item.parent.is_none());
94    }
95    let sprints = if query.search.is_some() {
96        crate::storage::SprintRepository::list(&repo).await?
97    } else {
98        Vec::new()
99    };
100
101    // Only configured columns are valid filter values.
102    for name in &query.statuses {
103        if !config.columns.iter().any(|c| c == name) {
104            return Err(Error::UnknownStatus(name.clone()));
105        }
106    }
107    // Select columns in configured order; an empty filter keeps all columns.
108    let shown: Vec<&String> = if query.statuses.is_empty() {
109        config.columns.iter().collect()
110    } else {
111        config
112            .columns
113            .iter()
114            .filter(|c| query.statuses.iter().any(|s| &s == c))
115            .collect()
116    };
117
118    // Apply the sprint scope before the remaining filters.
119    if let Some(sprint) = &query.sprint {
120        items.retain(|it| it.sprint.as_deref() == Some(sprint.as_str()));
121    }
122    if let Some(assignee) = &query.assignee {
123        items.retain(|it| it.assignee.as_deref() == Some(assignee.as_str()));
124    }
125    if !query.labels.is_empty() {
126        items.retain(|it| query.label_match.matches(&it.labels, &query.labels));
127    }
128    if let Some(search) = &query.search {
129        items.retain(|item| {
130            let sprint = item
131                .sprint
132                .as_deref()
133                .and_then(|id| sprints.iter().find(|sprint| sprint.id.as_str() == id));
134            search.matches(item, sprint)
135        });
136    }
137
138    let workflow = Workflow::new(config.columns.iter().map(Status::new));
139    // By default, sort the configured completion column by `done_at` descending and leave other
140    // columns in rank order. The choice is explicit in `config.done_column`, not positional.
141    let terminal = Status::new(&config.done_column);
142
143    let columns = shown
144        .iter()
145        .map(|name| {
146            let status = Status::new(*name);
147            let mut col_items: Vec<BacklogItem> = items
148                .iter()
149                .filter(|it| it.status == status)
150                .cloned()
151                .collect();
152            match query.sort {
153                // An explicit key applies uniformly to all columns.
154                Some(key) => sort_items(&mut col_items, key, query.reverse),
155                // The completion column is newest-first; other columns keep repository order.
156                None if terminal == status => {
157                    sort_items(&mut col_items, SortKey::Done, true);
158                }
159                None => {}
160            }
161            // Group into parent/child priority order, keeping the chosen sort as
162            // the root/sibling order (same canonical order as `list`, per column).
163            BoardColumn {
164                status,
165                items: crate::service::hierarchical(col_items),
166            }
167        })
168        .collect();
169
170    // Keep orphaned items only for an unfiltered board; a column subset intentionally hides them.
171    let orphaned = if query.statuses.is_empty() {
172        let orphans: Vec<BacklogItem> = items
173            .into_iter()
174            .filter(|it| !workflow.contains(&it.status))
175            .collect();
176        crate::service::hierarchical(orphans)
177    } else {
178        Vec::new()
179    };
180
181    Ok(Board { columns, orphaned })
182}
183
184/// Stable sorting of items in a column by `key` (optionally `reverse` in descending order).
185///
186/// The stable sort preserves input order (canonical rank order) for ties. For `Done`, items without
187/// `done_at` always remain at the end because they have no completion time to compare.
188fn sort_items(items: &mut [BacklogItem], key: SortKey, reverse: bool) {
189    use std::cmp::Ordering;
190    let flip = |o: Ordering| if reverse { o.reverse() } else { o };
191    match key {
192        // Reuse the canonical backlog order so an explicit `--sort rank` matches `list` exactly (ID tie-break included).
193        SortKey::Rank => items.sort_by(|a, b| flip(a.backlog_cmp(b))),
194        SortKey::Created => items.sort_by(|a, b| flip(a.created.cmp(&b.created))),
195        SortKey::Done => items.sort_by(|a, b| match (a.done_at, b.done_at) {
196            (Some(x), Some(y)) => flip(x.cmp(&y)), // Ascending by default; descending with reverse.
197            (Some(_), None) => Ordering::Less, // Completed items precede unset ones in either direction.
198            (None, Some(_)) => Ordering::Greater,
199            (None, None) => Ordering::Equal, // Stable sort preserves ascending rank order.
200        }),
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::error::Error;
208    use crate::service::test_support::{init_temp, set_columns};
209    use crate::service::{ItemEdit, NewItem, add_item, edit_item, move_item};
210    use tempfile::TempDir;
211
212    #[tokio::test]
213    async fn board_groups_items_by_column_in_config_order() {
214        let dir = init_temp().await;
215        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
216        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
217        // Move B to in-progress (A remains todo).
218        move_item(dir.path(), &b.id, "in-progress").await.unwrap();
219
220        let board = board(dir.path(), &BoardQuery::default())
221            .await
222            .expect("board succeeds");
223
224        // Columns are in config.toml order (default: todo → in-progress → review → done).
225        let names: Vec<_> = board
226            .columns
227            .iter()
228            .map(|c| c.status.as_str().to_string())
229            .collect();
230        assert_eq!(names, ["todo", "in-progress", "review", "done"]);
231
232        // The PBI to which it belongs is assigned to each column.
233        assert_eq!(
234            board.columns[0]
235                .items
236                .iter()
237                .map(|it| &it.id)
238                .collect::<Vec<_>>(),
239            [&a.id]
240        );
241        assert_eq!(
242            board.columns[1]
243                .items
244                .iter()
245                .map(|it| &it.id)
246                .collect::<Vec<_>>(),
247            [&b.id]
248        );
249        assert!(board.columns[2].items.is_empty());
250        assert!(board.columns[3].items.is_empty());
251    }
252
253    #[tokio::test]
254    async fn board_items_within_column_are_rank_ordered() {
255        let dir = init_temp().await;
256        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
257        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
258        let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
259
260        let board = board(dir.path(), &BoardQuery::default()).await.unwrap();
261        let ids: Vec<_> = board.columns[0]
262            .items
263            .iter()
264            .map(|it| it.id.clone())
265            .collect();
266        assert_eq!(ids, [a.id, b.id, c.id]);
267    }
268
269    #[tokio::test]
270    async fn board_on_uninitialized_dir_prompts_init() {
271        let dir = TempDir::new().expect("temp dir");
272        let err = board(dir.path(), &BoardQuery::default()).await.unwrap_err();
273        assert!(
274            matches!(err, Error::NotInitialized { .. }),
275            "expected NotInitialized, got {err:?}"
276        );
277    }
278
279    #[tokio::test]
280    async fn board_reflects_custom_column_order_and_additions() {
281        let dir = init_temp().await;
282        // Users define their own workflows (sort + add).
283        set_columns(dir.path(), &["backlog", "doing", "done", "blocked"]).await;
284        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
285        // You can transition to the added column `blocked` (reflected in move).
286        move_item(dir.path(), &a.id, "blocked").await.unwrap();
287
288        let board = board(dir.path(), &BoardQuery::default())
289            .await
290            .expect("board succeeds");
291
292        let names: Vec<_> = board
293            .columns
294            .iter()
295            .map(|c| c.status.as_str().to_string())
296            .collect();
297        assert_eq!(names, ["backlog", "doing", "done", "blocked"]);
298        // The default state is the first row (backlog), and after a move it belongs to blocked.
299        assert_eq!(board.columns[0].items, []);
300        assert_eq!(
301            board.columns[3]
302                .items
303                .iter()
304                .map(|it| &it.id)
305                .collect::<Vec<_>>(),
306            [&a.id]
307        );
308        assert!(board.orphaned.is_empty(), "no undefined-column items");
309    }
310
311    #[tokio::test]
312    async fn move_to_newly_added_column_succeeds() {
313        let dir = init_temp().await;
314        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
315        set_columns(dir.path(), &["todo", "in-progress", "review", "done", "qa"]).await;
316
317        let moved = move_item(dir.path(), &a.id, "qa")
318            .await
319            .expect("move to added column succeeds");
320        assert_eq!(moved.status, Status::new("qa"));
321    }
322
323    #[tokio::test]
324    async fn board_collects_items_in_undefined_columns_as_orphaned() {
325        let dir = init_temp().await;
326        let stay = add_item(dir.path(), "Stay", NewItem::default())
327            .await
328            .unwrap();
329        let orphan = add_item(dir.path(), "Orphan", NewItem::default())
330            .await
331            .unwrap();
332        move_item(dir.path(), &orphan.id, "review").await.unwrap();
333
334        // User edits config and deletes `review` column → orphan refers to undefined column.
335        set_columns(dir.path(), &["todo", "in-progress", "done"]).await;
336
337        let board = board(dir.path(), &BoardQuery::default())
338            .await
339            .expect("board succeeds");
340
341        // Only PBIs remaining in the known column (orphans do not appear in the column).
342        let in_columns: Vec<_> = board
343            .columns
344            .iter()
345            .flat_map(|c| &c.items)
346            .map(|it| it.id.clone())
347            .collect();
348        assert_eq!(in_columns, [stay.id]);
349
350        // Orphans are detected as orphaned (retaining their original undefined state).
351        assert_eq!(
352            board.orphaned.iter().map(|it| &it.id).collect::<Vec<_>>(),
353            [&orphan.id]
354        );
355        assert_eq!(board.orphaned[0].status, Status::new("review"));
356    }
357
358    #[tokio::test]
359    async fn board_scoped_to_sprint_shows_only_assigned_items() {
360        use crate::service::{assign_sprint, create_sprint};
361        use crate::sprint::SprintId;
362
363        let dir = init_temp().await;
364        let sprint = SprintId::new("S-1").unwrap();
365        create_sprint(dir.path(), &sprint, "Sprint 1", None, None)
366            .await
367            .unwrap();
368        let in_sprint = add_item(dir.path(), "In sprint", NewItem::default())
369            .await
370            .unwrap();
371        let _out = add_item(dir.path(), "Not in sprint", NewItem::default())
372            .await
373            .unwrap();
374        assign_sprint(dir.path(), &sprint, &in_sprint.id)
375            .await
376            .unwrap();
377
378        let board = board(
379            dir.path(),
380            &BoardQuery {
381                sprint: Some("S-1".to_string()),
382                ..Default::default()
383            },
384        )
385        .await
386        .expect("scoped board succeeds");
387
388        // Only PBIs belonging to Sprint (unassigned will not appear).
389        let ids: Vec<_> = board
390            .columns
391            .iter()
392            .flat_map(|c| &c.items)
393            .map(|it| it.id.clone())
394            .collect();
395        assert_eq!(ids, [in_sprint.id]);
396    }
397
398    #[tokio::test]
399    async fn board_filters_by_label() {
400        let dir = init_temp().await;
401        let backend = NewItem {
402            labels: vec!["backend".to_string()],
403            ..Default::default()
404        };
405        let matching = add_item(dir.path(), "Backend item", backend).await.unwrap();
406        let frontend = NewItem {
407            labels: vec!["frontend".to_string()],
408            ..Default::default()
409        };
410        add_item(dir.path(), "Frontend item", frontend)
411            .await
412            .unwrap();
413
414        let board = board(
415            dir.path(),
416            &BoardQuery {
417                labels: vec!["backend".to_string()],
418                ..Default::default()
419            },
420        )
421        .await
422        .expect("label-scoped board succeeds");
423
424        let ids: Vec<_> = board
425            .columns
426            .iter()
427            .flat_map(|column| &column.items)
428            .map(|item| item.id.clone())
429            .collect();
430        assert_eq!(ids, [matching.id]);
431    }
432
433    #[tokio::test]
434    async fn board_filters_by_assignee_across_workflow_columns() {
435        let dir = init_temp().await;
436        let alice_todo = add_item(dir.path(), "Alice todo", NewItem::default())
437            .await
438            .unwrap();
439        let bob_todo = add_item(dir.path(), "Bob todo", NewItem::default())
440            .await
441            .unwrap();
442        let alice_progress = add_item(dir.path(), "Alice progress", NewItem::default())
443            .await
444            .unwrap();
445        let bob_done = add_item(dir.path(), "Bob done", NewItem::default())
446            .await
447            .unwrap();
448
449        for (item, assignee) in [
450            (&alice_todo, "alice"),
451            (&bob_todo, "bob"),
452            (&alice_progress, "alice"),
453            (&bob_done, "bob"),
454        ] {
455            edit_item(
456                dir.path(),
457                &item.id,
458                ItemEdit {
459                    assignee: Some(assignee.to_string()),
460                    ..Default::default()
461                },
462            )
463            .await
464            .unwrap();
465        }
466        move_item(dir.path(), &alice_progress.id, "in-progress")
467            .await
468            .unwrap();
469        move_item(dir.path(), &bob_done.id, "done").await.unwrap();
470
471        let board = board(
472            dir.path(),
473            &BoardQuery {
474                assignee: Some("alice".to_string()),
475                ..Default::default()
476            },
477        )
478        .await
479        .expect("assignee-scoped board succeeds");
480
481        assert_eq!(
482            board.columns[0]
483                .items
484                .iter()
485                .map(|item| item.id.clone())
486                .collect::<Vec<_>>(),
487            [alice_todo.id]
488        );
489        assert_eq!(
490            board.columns[1]
491                .items
492                .iter()
493                .map(|item| item.id.clone())
494                .collect::<Vec<_>>(),
495            [alice_progress.id]
496        );
497        assert_eq!(board.columns[0].items[0].assignee.as_deref(), Some("alice"));
498        assert_eq!(board.columns[1].items[0].assignee.as_deref(), Some("alice"));
499        assert!(board.columns[2].items.is_empty());
500        assert!(board.columns[3].items.is_empty());
501        assert!(board.orphaned.is_empty());
502    }
503
504    #[tokio::test]
505    async fn board_filters_by_multiple_labels_with_any_or_all_matching() {
506        let dir = init_temp().await;
507        let backend = add_item(
508            dir.path(),
509            "Backend item",
510            NewItem {
511                labels: vec!["backend".to_string()],
512                ..Default::default()
513            },
514        )
515        .await
516        .unwrap();
517        let frontend = add_item(
518            dir.path(),
519            "Frontend item",
520            NewItem {
521                labels: vec!["frontend".to_string()],
522                ..Default::default()
523            },
524        )
525        .await
526        .unwrap();
527        let both = add_item(
528            dir.path(),
529            "Both labels",
530            NewItem {
531                labels: vec!["backend".to_string(), "frontend".to_string()],
532                ..Default::default()
533            },
534        )
535        .await
536        .unwrap();
537        let labels = vec!["backend".to_string(), "frontend".to_string()];
538
539        let any = board(
540            dir.path(),
541            &BoardQuery {
542                labels: labels.clone(),
543                ..Default::default()
544            },
545        )
546        .await
547        .unwrap();
548        let any_ids: Vec<_> = any
549            .columns
550            .iter()
551            .flat_map(|column| &column.items)
552            .map(|item| item.id.clone())
553            .collect();
554        assert_eq!(
555            any_ids,
556            [backend.id.clone(), frontend.id.clone(), both.id.clone()]
557        );
558
559        let all = board(
560            dir.path(),
561            &BoardQuery {
562                labels,
563                label_match: LabelMatch::All,
564                ..Default::default()
565            },
566        )
567        .await
568        .unwrap();
569        let all_ids: Vec<_> = all
570            .columns
571            .iter()
572            .flat_map(|column| &column.items)
573            .map(|item| item.id.clone())
574            .collect();
575        assert_eq!(all_ids, [both.id]);
576    }
577
578    // --- Column filter ---
579
580    #[tokio::test]
581    async fn board_status_filter_shows_only_requested_columns_in_config_order() {
582        let dir = init_temp().await;
583        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
584        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
585        move_item(dir.path(), &b.id, "done").await.unwrap();
586
587        // Even if the specified order is reversed, they are arranged in config order (todo → done).
588        let query = BoardQuery {
589            statuses: vec!["done".to_string(), "todo".to_string()],
590            ..Default::default()
591        };
592        let board = board(dir.path(), &query).await.expect("board succeeds");
593
594        let names: Vec<_> = board
595            .columns
596            .iter()
597            .map(|c| c.status.as_str().to_string())
598            .collect();
599        assert_eq!(
600            names,
601            ["todo", "done"],
602            "only requested columns, config order"
603        );
604        assert_eq!(
605            board.columns[0]
606                .items
607                .iter()
608                .map(|i| &i.id)
609                .collect::<Vec<_>>(),
610            [&a.id]
611        );
612        assert_eq!(
613            board.columns[1]
614                .items
615                .iter()
616                .map(|i| &i.id)
617                .collect::<Vec<_>>(),
618            [&b.id]
619        );
620    }
621
622    #[tokio::test]
623    async fn board_status_filter_rejects_unknown_column() {
624        let dir = init_temp().await;
625        let query = BoardQuery {
626            statuses: vec!["nonexistent".to_string()],
627            ..Default::default()
628        };
629        let err = board(dir.path(), &query).await.unwrap_err();
630        assert!(
631            matches!(err, Error::UnknownStatus(ref s) if s == "nonexistent"),
632            "got {err:?}"
633        );
634    }
635
636    #[tokio::test]
637    async fn board_status_filter_combines_with_sprint_scope() {
638        use crate::service::{assign_sprint, create_sprint};
639        use crate::sprint::SprintId;
640
641        let dir = init_temp().await;
642        let sprint = SprintId::new("S-1").unwrap();
643        create_sprint(dir.path(), &sprint, "Sprint", None, None)
644            .await
645            .unwrap();
646        let a = add_item(dir.path(), "In sprint todo", NewItem::default())
647            .await
648            .unwrap();
649        let b = add_item(dir.path(), "In sprint done", NewItem::default())
650            .await
651            .unwrap();
652        let c = add_item(dir.path(), "Off sprint todo", NewItem::default())
653            .await
654            .unwrap();
655        assign_sprint(dir.path(), &sprint, &a.id).await.unwrap();
656        assign_sprint(dir.path(), &sprint, &b.id).await.unwrap();
657        move_item(dir.path(), &b.id, "done").await.unwrap();
658
659        // sprint scope + todo column only → a only (b is done, c is outside sprint).
660        let query = BoardQuery {
661            sprint: Some("S-1".to_string()),
662            statuses: vec!["todo".to_string()],
663            ..Default::default()
664        };
665        let board = board(dir.path(), &query).await.expect("board succeeds");
666
667        assert_eq!(board.columns.len(), 1);
668        assert_eq!(board.columns[0].status.as_str(), "todo");
669        let ids: Vec<_> = board.columns[0]
670            .items
671            .iter()
672            .map(|i| i.id.clone())
673            .collect();
674        assert_eq!(ids, [a.id]);
675        let _ = c;
676    }
677
678    #[tokio::test]
679    async fn board_status_filter_suppresses_orphaned() {
680        let dir = init_temp().await;
681        let orphan = add_item(dir.path(), "Orphan", NewItem::default())
682            .await
683            .unwrap();
684        move_item(dir.path(), &orphan.id, "review").await.unwrap();
685        // Delete review column → orphan refers to undefined column.
686        set_columns(dir.path(), &["todo", "in-progress", "done"]).await;
687
688        let query = BoardQuery {
689            statuses: vec!["todo".to_string()],
690            ..Default::default()
691        };
692        let board = board(dir.path(), &query).await.expect("board succeeds");
693
694        assert!(
695            board.orphaned.is_empty(),
696            "orphaned suppressed when filtering columns"
697        );
698    }
699
700    // --- Completion order display ---
701
702    /// Overwrite `done_at` with any value (deterministic setup for completion order testing).
703    async fn set_done_at(dir: &Path, id: &crate::backlog::ItemId, done_at: Option<i64>) {
704        use crate::storage::{BacklogItemRepository, FileRepository};
705        let repo = FileRepository::new(dir.join(".pinto"));
706        let mut item = repo.load(id).await.expect("load");
707        item.done_at = done_at.map(|s| chrono::DateTime::from_timestamp(s, 0).expect("ts"));
708        repo.save(&item).await.expect("save");
709    }
710
711    #[tokio::test]
712    async fn board_done_column_orders_by_completion_desc_and_undated_last() {
713        let dir = init_temp().await;
714        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
715        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
716        let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
717        let d = add_item(dir.path(), "D", NewItem::default()).await.unwrap();
718        let e = add_item(dir.path(), "E", NewItem::default()).await.unwrap();
719        for it in [&a, &b, &c, &d, &e] {
720            move_item(dir.path(), &it.id, "done").await.unwrap();
721        }
722        // Give a definitive completion time (d and e are not set = last, addition order = maintain rank order).
723        set_done_at(dir.path(), &a.id, Some(100)).await;
724        set_done_at(dir.path(), &b.id, Some(300)).await;
725        set_done_at(dir.path(), &c.id, Some(200)).await;
726        set_done_at(dir.path(), &d.id, None).await;
727        set_done_at(dir.path(), &e.id, None).await;
728
729        let board = board(dir.path(), &BoardQuery::default())
730            .await
731            .expect("board succeeds");
732        let done = board
733            .columns
734            .iter()
735            .find(|c| c.status.as_str() == "done")
736            .expect("done column");
737        let ids: Vec<_> = done.items.iter().map(|i| i.id.clone()).collect();
738
739        // done_at Descending order (b=300, c=200, a=100) → If not set, go to the end rank Stable arrangement in ascending order (d, e).
740        assert_eq!(ids, [b.id, c.id, a.id, d.id, e.id]);
741    }
742
743    #[tokio::test]
744    async fn board_non_done_columns_remain_rank_ordered() {
745        let dir = init_temp().await;
746        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
747        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
748        let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
749        for it in [&a, &b, &c] {
750            move_item(dir.path(), &it.id, "in-progress").await.unwrap();
751        }
752
753        let board = board(dir.path(), &BoardQuery::default())
754            .await
755            .expect("board succeeds");
756        let wip = board
757            .columns
758            .iter()
759            .find(|c| c.status.as_str() == "in-progress")
760            .expect("in-progress column");
761        let ids: Vec<_> = wip.items.iter().map(|i| i.id.clone()).collect();
762        assert_eq!(
763            ids,
764            [a.id, b.id, c.id],
765            "non-terminal columns keep rank order"
766        );
767    }
768
769    #[tokio::test]
770    async fn board_done_column_setting_is_position_independent() {
771        let dir = init_temp().await;
772        // Move the completion column `done` to a location other than the end. done_column (default "done") follows.
773        set_columns(dir.path(), &["todo", "done", "archived"]).await;
774
775        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
776        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
777        let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
778        for it in [&a, &b, &c] {
779            move_item(dir.path(), &it.id, "done").await.unwrap();
780        }
781        set_done_at(dir.path(), &a.id, Some(100)).await;
782        set_done_at(dir.path(), &b.id, Some(300)).await;
783        set_done_at(dir.path(), &c.id, Some(200)).await;
784
785        let board = board(dir.path(), &BoardQuery::default())
786            .await
787            .expect("board succeeds");
788        let done = board
789            .columns
790            .iter()
791            .find(|c| c.status.as_str() == "done")
792            .expect("done column");
793        let ids: Vec<_> = done.items.iter().map(|i| i.id.clone()).collect();
794        // Even if it is not at the end, it is sorted in descending order by done_at (determined by done_column, not position).
795        assert_eq!(ids, [b.id, c.id, a.id]);
796    }
797
798    // --- Sort selection ---
799
800    #[tokio::test]
801    async fn board_sort_created_applies_to_all_columns() {
802        let dir = init_temp().await;
803        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
804        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
805        let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
806        // Definitively override created (a=300, b=100, c=200). Everyone remains in todo.
807        set_created(dir.path(), &a.id, 300).await;
808        set_created(dir.path(), &b.id, 100).await;
809        set_created(dir.path(), &c.id, 200).await;
810
811        // Ascending (default): b(100), c(200), a(300).
812        let asc = BoardQuery {
813            sort: Some(SortKey::Created),
814            ..Default::default()
815        };
816        let asc_board = board(dir.path(), &asc).await.unwrap();
817        assert_eq!(
818            asc_board.columns[0]
819                .items
820                .iter()
821                .map(|i| i.id.clone())
822                .collect::<Vec<_>>(),
823            [b.id.clone(), c.id.clone(), a.id.clone()]
824        );
825
826        // Descending (--reverse): a(300), c(200), b(100).
827        let desc = BoardQuery {
828            sort: Some(SortKey::Created),
829            reverse: true,
830            ..Default::default()
831        };
832        let desc_board = board(dir.path(), &desc).await.unwrap();
833        assert_eq!(
834            desc_board.columns[0]
835                .items
836                .iter()
837                .map(|i| i.id.clone())
838                .collect::<Vec<_>>(),
839            [a.id, c.id, b.id]
840        );
841    }
842
843    #[tokio::test]
844    async fn board_sort_rank_overrides_default_done_ordering() {
845        let dir = init_temp().await;
846        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
847        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
848        let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
849        for it in [&a, &b, &c] {
850            move_item(dir.path(), &it.id, "done").await.unwrap();
851        }
852        // The completion time is a<b<c, but --sort rank forces ascending rank order (a,b,c).
853        set_done_at(dir.path(), &a.id, Some(100)).await;
854        set_done_at(dir.path(), &b.id, Some(200)).await;
855        set_done_at(dir.path(), &c.id, Some(300)).await;
856
857        let query = BoardQuery {
858            sort: Some(SortKey::Rank),
859            ..Default::default()
860        };
861        let board = board(dir.path(), &query).await.unwrap();
862        let done = board
863            .columns
864            .iter()
865            .find(|c| c.status.as_str() == "done")
866            .unwrap();
867        assert_eq!(
868            done.items.iter().map(|i| i.id.clone()).collect::<Vec<_>>(),
869            [a.id, b.id, c.id],
870            "explicit --sort rank overrides the default done ordering"
871        );
872    }
873
874    #[test]
875    fn done_sort_places_completed_items_before_undated_items() {
876        let now = chrono::DateTime::from_timestamp(0, 0).expect("ts");
877        let mut completed = BacklogItem::new(
878            crate::backlog::ItemId::new("T", 1),
879            "Completed",
880            Status::new("done"),
881            crate::rank::Rank::after(None),
882            now,
883        )
884        .expect("item");
885        completed.done_at = Some(now);
886        let undated = BacklogItem::new(
887            crate::backlog::ItemId::new("T", 2),
888            "Undated",
889            Status::new("done"),
890            crate::rank::Rank::after(None),
891            now,
892        )
893        .expect("item");
894        let mut items = vec![completed, undated];
895
896        sort_items(&mut items, SortKey::Done, false);
897
898        assert!(items[0].done_at.is_some());
899        assert!(items[1].done_at.is_none());
900    }
901
902    /// Overwrite `created` with any value (definitive setup for sorting tests).
903    async fn set_created(dir: &Path, id: &crate::backlog::ItemId, created: i64) {
904        use crate::storage::{BacklogItemRepository, FileRepository};
905        let repo = FileRepository::new(dir.join(".pinto"));
906        let mut item = repo.load(id).await.expect("load");
907        item.created = chrono::DateTime::from_timestamp(created, 0).expect("ts");
908        repo.save(&item).await.expect("save");
909    }
910}