use super::{LabelMatch, apply_effective_points, open_board};
use crate::backlog::{BacklogItem, Status, Workflow};
use crate::error::{Error, Result};
use crate::storage::BacklogItemRepository;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortKey {
Rank,
Done,
Created,
}
#[derive(Debug, Default, Clone)]
pub struct BoardQuery {
pub roots_only: bool,
pub sprint: Option<String>,
pub labels: Vec<String>,
pub label_match: LabelMatch,
pub statuses: Vec<String>,
pub sort: Option<SortKey>,
pub reverse: bool,
pub search: Option<super::SearchFilter>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoardColumn {
pub status: Status,
pub items: Vec<BacklogItem>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Board {
pub columns: Vec<BoardColumn>,
pub orphaned: Vec<BacklogItem>,
}
pub async fn board(project_dir: &Path, query: &BoardQuery) -> Result<Board> {
let (_board_dir, repo, config) = open_board(project_dir).await?;
let mut items = repo.list().await?; apply_effective_points(
&mut items,
config.points.aggregate_children,
&Status::new(&config.done_column),
);
if query.roots_only {
items.retain(|item| item.parent.is_none());
}
let sprints = if query.search.is_some() {
crate::storage::SprintRepository::list(&repo).await?
} else {
Vec::new()
};
for name in &query.statuses {
if !config.columns.iter().any(|c| c == name) {
return Err(Error::UnknownStatus(name.clone()));
}
}
let shown: Vec<&String> = if query.statuses.is_empty() {
config.columns.iter().collect()
} else {
config
.columns
.iter()
.filter(|c| query.statuses.iter().any(|s| &s == c))
.collect()
};
if let Some(sprint) = &query.sprint {
items.retain(|it| it.sprint.as_deref() == Some(sprint.as_str()));
}
if !query.labels.is_empty() {
items.retain(|it| query.label_match.matches(&it.labels, &query.labels));
}
if let Some(search) = &query.search {
items.retain(|item| {
let sprint = item
.sprint
.as_deref()
.and_then(|id| sprints.iter().find(|sprint| sprint.id.as_str() == id));
search.matches(item, sprint)
});
}
let workflow = Workflow::new(config.columns.iter().map(Status::new));
let terminal = Status::new(&config.done_column);
let columns = shown
.iter()
.map(|name| {
let status = Status::new(*name);
let mut col_items: Vec<BacklogItem> = items
.iter()
.filter(|it| it.status == status)
.cloned()
.collect();
match query.sort {
Some(key) => sort_items(&mut col_items, key, query.reverse),
None if terminal == status => {
sort_items(&mut col_items, SortKey::Done, true);
}
None => {}
}
BoardColumn {
status,
items: crate::service::hierarchical(col_items),
}
})
.collect();
let orphaned = if query.statuses.is_empty() {
let orphans: Vec<BacklogItem> = items
.into_iter()
.filter(|it| !workflow.contains(&it.status))
.collect();
crate::service::hierarchical(orphans)
} else {
Vec::new()
};
Ok(Board { columns, orphaned })
}
fn sort_items(items: &mut [BacklogItem], key: SortKey, reverse: bool) {
use std::cmp::Ordering;
let flip = |o: Ordering| if reverse { o.reverse() } else { o };
match key {
SortKey::Rank => items.sort_by(|a, b| flip(a.backlog_cmp(b))),
SortKey::Created => items.sort_by(|a, b| flip(a.created.cmp(&b.created))),
SortKey::Done => items.sort_by(|a, b| match (a.done_at, b.done_at) {
(Some(x), Some(y)) => flip(x.cmp(&y)), (Some(_), None) => Ordering::Less, (None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal, }),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use crate::service::test_support::{init_temp, set_columns};
use crate::service::{NewItem, add_item, move_item};
use tempfile::TempDir;
#[tokio::test]
async fn board_groups_items_by_column_in_config_order() {
let dir = init_temp().await;
let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
move_item(dir.path(), &b.id, "in-progress").await.unwrap();
let board = board(dir.path(), &BoardQuery::default())
.await
.expect("board succeeds");
let names: Vec<_> = board
.columns
.iter()
.map(|c| c.status.as_str().to_string())
.collect();
assert_eq!(names, ["todo", "in-progress", "review", "done"]);
assert_eq!(
board.columns[0]
.items
.iter()
.map(|it| &it.id)
.collect::<Vec<_>>(),
[&a.id]
);
assert_eq!(
board.columns[1]
.items
.iter()
.map(|it| &it.id)
.collect::<Vec<_>>(),
[&b.id]
);
assert!(board.columns[2].items.is_empty());
assert!(board.columns[3].items.is_empty());
}
#[tokio::test]
async fn board_items_within_column_are_rank_ordered() {
let dir = init_temp().await;
let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
let board = board(dir.path(), &BoardQuery::default()).await.unwrap();
let ids: Vec<_> = board.columns[0]
.items
.iter()
.map(|it| it.id.clone())
.collect();
assert_eq!(ids, [a.id, b.id, c.id]);
}
#[tokio::test]
async fn board_on_uninitialized_dir_prompts_init() {
let dir = TempDir::new().expect("temp dir");
let err = board(dir.path(), &BoardQuery::default()).await.unwrap_err();
assert!(
matches!(err, Error::NotInitialized { .. }),
"expected NotInitialized, got {err:?}"
);
}
#[tokio::test]
async fn board_reflects_custom_column_order_and_additions() {
let dir = init_temp().await;
set_columns(dir.path(), &["backlog", "doing", "done", "blocked"]).await;
let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
move_item(dir.path(), &a.id, "blocked").await.unwrap();
let board = board(dir.path(), &BoardQuery::default())
.await
.expect("board succeeds");
let names: Vec<_> = board
.columns
.iter()
.map(|c| c.status.as_str().to_string())
.collect();
assert_eq!(names, ["backlog", "doing", "done", "blocked"]);
assert_eq!(board.columns[0].items, []);
assert_eq!(
board.columns[3]
.items
.iter()
.map(|it| &it.id)
.collect::<Vec<_>>(),
[&a.id]
);
assert!(board.orphaned.is_empty(), "no undefined-column items");
}
#[tokio::test]
async fn move_to_newly_added_column_succeeds() {
let dir = init_temp().await;
let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
set_columns(dir.path(), &["todo", "in-progress", "review", "done", "qa"]).await;
let moved = move_item(dir.path(), &a.id, "qa")
.await
.expect("move to added column succeeds");
assert_eq!(moved.status, Status::new("qa"));
}
#[tokio::test]
async fn board_collects_items_in_undefined_columns_as_orphaned() {
let dir = init_temp().await;
let stay = add_item(dir.path(), "Stay", NewItem::default())
.await
.unwrap();
let orphan = add_item(dir.path(), "Orphan", NewItem::default())
.await
.unwrap();
move_item(dir.path(), &orphan.id, "review").await.unwrap();
set_columns(dir.path(), &["todo", "in-progress", "done"]).await;
let board = board(dir.path(), &BoardQuery::default())
.await
.expect("board succeeds");
let in_columns: Vec<_> = board
.columns
.iter()
.flat_map(|c| &c.items)
.map(|it| it.id.clone())
.collect();
assert_eq!(in_columns, [stay.id]);
assert_eq!(
board.orphaned.iter().map(|it| &it.id).collect::<Vec<_>>(),
[&orphan.id]
);
assert_eq!(board.orphaned[0].status, Status::new("review"));
}
#[tokio::test]
async fn board_scoped_to_sprint_shows_only_assigned_items() {
use crate::service::{assign_sprint, create_sprint};
use crate::sprint::SprintId;
let dir = init_temp().await;
let sprint = SprintId::new("S-1").unwrap();
create_sprint(dir.path(), &sprint, "Sprint 1", None, None)
.await
.unwrap();
let in_sprint = add_item(dir.path(), "In sprint", NewItem::default())
.await
.unwrap();
let _out = add_item(dir.path(), "Not in sprint", NewItem::default())
.await
.unwrap();
assign_sprint(dir.path(), &sprint, &in_sprint.id)
.await
.unwrap();
let board = board(
dir.path(),
&BoardQuery {
sprint: Some("S-1".to_string()),
..Default::default()
},
)
.await
.expect("scoped board succeeds");
let ids: Vec<_> = board
.columns
.iter()
.flat_map(|c| &c.items)
.map(|it| it.id.clone())
.collect();
assert_eq!(ids, [in_sprint.id]);
}
#[tokio::test]
async fn board_filters_by_label() {
let dir = init_temp().await;
let backend = NewItem {
labels: vec!["backend".to_string()],
..Default::default()
};
let matching = add_item(dir.path(), "Backend item", backend).await.unwrap();
let frontend = NewItem {
labels: vec!["frontend".to_string()],
..Default::default()
};
add_item(dir.path(), "Frontend item", frontend)
.await
.unwrap();
let board = board(
dir.path(),
&BoardQuery {
labels: vec!["backend".to_string()],
..Default::default()
},
)
.await
.expect("label-scoped board succeeds");
let ids: Vec<_> = board
.columns
.iter()
.flat_map(|column| &column.items)
.map(|item| item.id.clone())
.collect();
assert_eq!(ids, [matching.id]);
}
#[tokio::test]
async fn board_filters_by_multiple_labels_with_any_or_all_matching() {
let dir = init_temp().await;
let backend = add_item(
dir.path(),
"Backend item",
NewItem {
labels: vec!["backend".to_string()],
..Default::default()
},
)
.await
.unwrap();
let frontend = add_item(
dir.path(),
"Frontend item",
NewItem {
labels: vec!["frontend".to_string()],
..Default::default()
},
)
.await
.unwrap();
let both = add_item(
dir.path(),
"Both labels",
NewItem {
labels: vec!["backend".to_string(), "frontend".to_string()],
..Default::default()
},
)
.await
.unwrap();
let labels = vec!["backend".to_string(), "frontend".to_string()];
let any = board(
dir.path(),
&BoardQuery {
labels: labels.clone(),
..Default::default()
},
)
.await
.unwrap();
let any_ids: Vec<_> = any
.columns
.iter()
.flat_map(|column| &column.items)
.map(|item| item.id.clone())
.collect();
assert_eq!(
any_ids,
[backend.id.clone(), frontend.id.clone(), both.id.clone()]
);
let all = board(
dir.path(),
&BoardQuery {
labels,
label_match: LabelMatch::All,
..Default::default()
},
)
.await
.unwrap();
let all_ids: Vec<_> = all
.columns
.iter()
.flat_map(|column| &column.items)
.map(|item| item.id.clone())
.collect();
assert_eq!(all_ids, [both.id]);
}
#[tokio::test]
async fn board_status_filter_shows_only_requested_columns_in_config_order() {
let dir = init_temp().await;
let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
move_item(dir.path(), &b.id, "done").await.unwrap();
let query = BoardQuery {
statuses: vec!["done".to_string(), "todo".to_string()],
..Default::default()
};
let board = board(dir.path(), &query).await.expect("board succeeds");
let names: Vec<_> = board
.columns
.iter()
.map(|c| c.status.as_str().to_string())
.collect();
assert_eq!(
names,
["todo", "done"],
"only requested columns, config order"
);
assert_eq!(
board.columns[0]
.items
.iter()
.map(|i| &i.id)
.collect::<Vec<_>>(),
[&a.id]
);
assert_eq!(
board.columns[1]
.items
.iter()
.map(|i| &i.id)
.collect::<Vec<_>>(),
[&b.id]
);
}
#[tokio::test]
async fn board_status_filter_rejects_unknown_column() {
let dir = init_temp().await;
let query = BoardQuery {
statuses: vec!["nonexistent".to_string()],
..Default::default()
};
let err = board(dir.path(), &query).await.unwrap_err();
assert!(
matches!(err, Error::UnknownStatus(ref s) if s == "nonexistent"),
"got {err:?}"
);
}
#[tokio::test]
async fn board_status_filter_combines_with_sprint_scope() {
use crate::service::{assign_sprint, create_sprint};
use crate::sprint::SprintId;
let dir = init_temp().await;
let sprint = SprintId::new("S-1").unwrap();
create_sprint(dir.path(), &sprint, "Sprint", None, None)
.await
.unwrap();
let a = add_item(dir.path(), "In sprint todo", NewItem::default())
.await
.unwrap();
let b = add_item(dir.path(), "In sprint done", NewItem::default())
.await
.unwrap();
let c = add_item(dir.path(), "Off sprint todo", NewItem::default())
.await
.unwrap();
assign_sprint(dir.path(), &sprint, &a.id).await.unwrap();
assign_sprint(dir.path(), &sprint, &b.id).await.unwrap();
move_item(dir.path(), &b.id, "done").await.unwrap();
let query = BoardQuery {
sprint: Some("S-1".to_string()),
statuses: vec!["todo".to_string()],
..Default::default()
};
let board = board(dir.path(), &query).await.expect("board succeeds");
assert_eq!(board.columns.len(), 1);
assert_eq!(board.columns[0].status.as_str(), "todo");
let ids: Vec<_> = board.columns[0]
.items
.iter()
.map(|i| i.id.clone())
.collect();
assert_eq!(ids, [a.id]);
let _ = c;
}
#[tokio::test]
async fn board_status_filter_suppresses_orphaned() {
let dir = init_temp().await;
let orphan = add_item(dir.path(), "Orphan", NewItem::default())
.await
.unwrap();
move_item(dir.path(), &orphan.id, "review").await.unwrap();
set_columns(dir.path(), &["todo", "in-progress", "done"]).await;
let query = BoardQuery {
statuses: vec!["todo".to_string()],
..Default::default()
};
let board = board(dir.path(), &query).await.expect("board succeeds");
assert!(
board.orphaned.is_empty(),
"orphaned suppressed when filtering columns"
);
}
async fn set_done_at(dir: &Path, id: &crate::backlog::ItemId, done_at: Option<i64>) {
use crate::storage::{BacklogItemRepository, FileRepository};
let repo = FileRepository::new(dir.join(".pinto"));
let mut item = repo.load(id).await.expect("load");
item.done_at = done_at.map(|s| chrono::DateTime::from_timestamp(s, 0).expect("ts"));
repo.save(&item).await.expect("save");
}
#[tokio::test]
async fn board_done_column_orders_by_completion_desc_and_undated_last() {
let dir = init_temp().await;
let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
let d = add_item(dir.path(), "D", NewItem::default()).await.unwrap();
let e = add_item(dir.path(), "E", NewItem::default()).await.unwrap();
for it in [&a, &b, &c, &d, &e] {
move_item(dir.path(), &it.id, "done").await.unwrap();
}
set_done_at(dir.path(), &a.id, Some(100)).await;
set_done_at(dir.path(), &b.id, Some(300)).await;
set_done_at(dir.path(), &c.id, Some(200)).await;
set_done_at(dir.path(), &d.id, None).await;
set_done_at(dir.path(), &e.id, None).await;
let board = board(dir.path(), &BoardQuery::default())
.await
.expect("board succeeds");
let done = board
.columns
.iter()
.find(|c| c.status.as_str() == "done")
.expect("done column");
let ids: Vec<_> = done.items.iter().map(|i| i.id.clone()).collect();
assert_eq!(ids, [b.id, c.id, a.id, d.id, e.id]);
}
#[tokio::test]
async fn board_non_done_columns_remain_rank_ordered() {
let dir = init_temp().await;
let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
for it in [&a, &b, &c] {
move_item(dir.path(), &it.id, "in-progress").await.unwrap();
}
let board = board(dir.path(), &BoardQuery::default())
.await
.expect("board succeeds");
let wip = board
.columns
.iter()
.find(|c| c.status.as_str() == "in-progress")
.expect("in-progress column");
let ids: Vec<_> = wip.items.iter().map(|i| i.id.clone()).collect();
assert_eq!(
ids,
[a.id, b.id, c.id],
"non-terminal columns keep rank order"
);
}
#[tokio::test]
async fn board_done_column_setting_is_position_independent() {
let dir = init_temp().await;
set_columns(dir.path(), &["todo", "done", "archived"]).await;
let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
for it in [&a, &b, &c] {
move_item(dir.path(), &it.id, "done").await.unwrap();
}
set_done_at(dir.path(), &a.id, Some(100)).await;
set_done_at(dir.path(), &b.id, Some(300)).await;
set_done_at(dir.path(), &c.id, Some(200)).await;
let board = board(dir.path(), &BoardQuery::default())
.await
.expect("board succeeds");
let done = board
.columns
.iter()
.find(|c| c.status.as_str() == "done")
.expect("done column");
let ids: Vec<_> = done.items.iter().map(|i| i.id.clone()).collect();
assert_eq!(ids, [b.id, c.id, a.id]);
}
#[tokio::test]
async fn board_sort_created_applies_to_all_columns() {
let dir = init_temp().await;
let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
set_created(dir.path(), &a.id, 300).await;
set_created(dir.path(), &b.id, 100).await;
set_created(dir.path(), &c.id, 200).await;
let asc = BoardQuery {
sort: Some(SortKey::Created),
..Default::default()
};
let asc_board = board(dir.path(), &asc).await.unwrap();
assert_eq!(
asc_board.columns[0]
.items
.iter()
.map(|i| i.id.clone())
.collect::<Vec<_>>(),
[b.id.clone(), c.id.clone(), a.id.clone()]
);
let desc = BoardQuery {
sort: Some(SortKey::Created),
reverse: true,
..Default::default()
};
let desc_board = board(dir.path(), &desc).await.unwrap();
assert_eq!(
desc_board.columns[0]
.items
.iter()
.map(|i| i.id.clone())
.collect::<Vec<_>>(),
[a.id, c.id, b.id]
);
}
#[tokio::test]
async fn board_sort_rank_overrides_default_done_ordering() {
let dir = init_temp().await;
let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
for it in [&a, &b, &c] {
move_item(dir.path(), &it.id, "done").await.unwrap();
}
set_done_at(dir.path(), &a.id, Some(100)).await;
set_done_at(dir.path(), &b.id, Some(200)).await;
set_done_at(dir.path(), &c.id, Some(300)).await;
let query = BoardQuery {
sort: Some(SortKey::Rank),
..Default::default()
};
let board = board(dir.path(), &query).await.unwrap();
let done = board
.columns
.iter()
.find(|c| c.status.as_str() == "done")
.unwrap();
assert_eq!(
done.items.iter().map(|i| i.id.clone()).collect::<Vec<_>>(),
[a.id, b.id, c.id],
"explicit --sort rank overrides the default done ordering"
);
}
async fn set_created(dir: &Path, id: &crate::backlog::ItemId, created: i64) {
use crate::storage::{BacklogItemRepository, FileRepository};
let repo = FileRepository::new(dir.join(".pinto"));
let mut item = repo.load(id).await.expect("load");
item.created = chrono::DateTime::from_timestamp(created, 0).expect("ts");
repo.save(&item).await.expect("save");
}
}