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    /// Labels to match. An empty list does not filter by label.
28    pub labels: Vec<String>,
29    /// Matching mode for [`Self::labels`].
30    pub label_match: LabelMatch,
31    /// Columns to display. An empty list includes all columns. Every supplied name must exist in
32    /// `config.toml`; otherwise return [`Error::UnknownStatus`].
33    pub statuses: Vec<String>,
34    /// Sort key within each column. `None` uses `done_at` descending order for the completion
35    /// column and rank ascending order elsewhere. `Some` applies the key to every column.
36    pub sort: Option<SortKey>,
37    /// Reverse the selected sort key; valid only when `sort` is `Some`.
38    pub reverse: bool,
39    /// Search the item's fields and assigned sprint metadata.
40    pub search: Option<super::SearchFilter>,
41}
42
43/// One board column with its status and PBIs in ascending rank order.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct BoardColumn {
46    /// Workflow status (the column name in `config.toml`).
47    pub status: Status,
48    /// PBIs belonging to this column, in ascending rank order.
49    pub items: Vec<BacklogItem>,
50}
51
52/// Board data with columns ordered according to `config.toml`.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct Board {
55    /// Columns in workflow order, from left to right.
56    pub columns: Vec<BoardColumn>,
57    /// PBIs whose status is not present in `config.toml`, in ascending rank order.
58    ///
59    /// These items remain visible so users can detect and repair statuses after a column is deleted
60    /// or renamed; they are reported separately instead of being dropped.
61    pub orphaned: Vec<BacklogItem>,
62}
63
64/// Load the board in `project_dir`, grouping PBIs in the column order from `config.toml`.
65///
66/// PBIs in each known column use ascending rank order, matching `list`. Items whose statuses do not
67/// match a configured column are collected in [`Board::orphaned`] rather than discarded.
68///
69/// You can narrow down the display with [`BoardQuery`]:
70/// - If `roots_only` is set, include only PBIs whose persisted parent link is unset. This is
71///   evaluated before the other filters, so a child is not promoted when its parent is hidden.
72/// - If `sprint` is set, include only PBIs assigned to that sprint. Filters apply to both sorting
73///   and orphan detection.
74/// - If `labels` is specified, only PBIs matching the requested labels will be targeted. The
75///   default [`LabelMatch::Any`] mode is OR; [`LabelMatch::All`] is AND.
76/// - If `statuses` is set, display only those known columns in their configured order. Unknown
77///   names return [`Error::UnknownStatus`]. Orphaned PBIs are omitted when a subset is requested.
78///
79/// [`Error::NotInitialized`] if the board is uninitialized.
80pub 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?; // Already in canonical rank order.
83    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    // Only configured columns are valid filter values.
98    for name in &query.statuses {
99        if !config.columns.iter().any(|c| c == name) {
100            return Err(Error::UnknownStatus(name.clone()));
101        }
102    }
103    // Select columns in configured order; an empty filter keeps all columns.
104    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    // Apply the sprint scope before the remaining filters.
115    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    // By default, sort the configured completion column by `done_at` descending and leave other
133    // columns in rank order. The choice is explicit in `config.done_column`, not positional.
134    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                // An explicit key applies uniformly to all columns.
147                Some(key) => sort_items(&mut col_items, key, query.reverse),
148                // The completion column is newest-first; other columns keep repository order.
149                None if terminal == status => {
150                    sort_items(&mut col_items, SortKey::Done, true);
151                }
152                None => {}
153            }
154            // Group into parent/child priority order, keeping the chosen sort as
155            // the root/sibling order (same canonical order as `list`, per column).
156            BoardColumn {
157                status,
158                items: crate::service::hierarchical(col_items),
159            }
160        })
161        .collect();
162
163    // Keep orphaned items only for an unfiltered board; a column subset intentionally hides them.
164    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
177/// Stable sorting of items in a column by `key` (optionally `reverse` in descending order).
178///
179/// The stable sort preserves input order (canonical rank order) for ties. For `Done`, items without
180/// `done_at` always remain at the end because they have no completion time to compare.
181fn 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        // Reuse the canonical backlog order so an explicit `--sort rank` matches `list` exactly (ID tie-break included).
186        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)), // Ascending by default; descending with reverse.
190            (Some(_), None) => Ordering::Less, // Completed items precede unset ones in either direction.
191            (None, Some(_)) => Ordering::Greater,
192            (None, None) => Ordering::Equal, // Stable sort preserves ascending rank order.
193        }),
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 B to in-progress (A remains todo).
211        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        // Columns are in config.toml order (default: todo → in-progress → review → done).
218        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        // The PBI to which it belongs is assigned to each column.
226        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        // Users define their own workflows (sort + add).
276        set_columns(dir.path(), &["backlog", "doing", "done", "blocked"]).await;
277        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
278        // You can transition to the added column `blocked` (reflected in move).
279        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        // The default state is the first row (backlog), and after a move it belongs to blocked.
292        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        // User edits config and deletes `review` column → orphan refers to undefined column.
328        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        // Only PBIs remaining in the known column (orphans do not appear in the column).
335        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        // Orphans are detected as orphaned (retaining their original undefined state).
344        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        // Only PBIs belonging to Sprint (unassigned will not appear).
382        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    // --- Column filter ---
501
502    #[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        // Even if the specified order is reversed, they are arranged in config order (todo → done).
510        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        // sprint scope + todo column only → a only (b is done, c is outside sprint).
582        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        // Delete review column → orphan refers to undefined column.
608        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    // --- Completion order display ---
623
624    /// Overwrite `done_at` with any value (deterministic setup for completion order testing).
625    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        // Give a definitive completion time (d and e are not set = last, addition order = maintain rank order).
645        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        // 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).
662        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        // Move the completion column `done` to a location other than the end. done_column (default "done") follows.
695        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        // Even if it is not at the end, it is sorted in descending order by done_at (determined by done_column, not position).
717        assert_eq!(ids, [b.id, c.id, a.id]);
718    }
719
720    // --- Sort selection ---
721
722    #[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        // Definitively override created (a=300, b=100, c=200). Everyone remains in todo.
729        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        // Ascending (default): b(100), c(200), a(300).
734        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        // Descending (--reverse): a(300), c(200), b(100).
749        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        // The completion time is a<b<c, but --sort rank forces ascending rank order (a,b,c).
775        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    #[test]
797    fn done_sort_places_completed_items_before_undated_items() {
798        let now = chrono::DateTime::from_timestamp(0, 0).expect("ts");
799        let mut completed = BacklogItem::new(
800            crate::backlog::ItemId::new("T", 1),
801            "Completed",
802            Status::new("done"),
803            crate::rank::Rank::after(None),
804            now,
805        )
806        .expect("item");
807        completed.done_at = Some(now);
808        let undated = BacklogItem::new(
809            crate::backlog::ItemId::new("T", 2),
810            "Undated",
811            Status::new("done"),
812            crate::rank::Rank::after(None),
813            now,
814        )
815        .expect("item");
816        let mut items = vec![completed, undated];
817
818        sort_items(&mut items, SortKey::Done, false);
819
820        assert!(items[0].done_at.is_some());
821        assert!(items[1].done_at.is_none());
822    }
823
824    /// Overwrite `created` with any value (definitive setup for sorting tests).
825    async fn set_created(dir: &Path, id: &crate::backlog::ItemId, created: i64) {
826        use crate::storage::{BacklogItemRepository, FileRepository};
827        let repo = FileRepository::new(dir.join(".pinto"));
828        let mut item = repo.load(id).await.expect("load");
829        item.created = chrono::DateTime::from_timestamp(created, 0).expect("ts");
830        repo.save(&item).await.expect("save");
831    }
832}