use crate::backlog::{BacklogItem, ItemId, Status};
use crate::error::{Error, Result};
use crate::storage::BacklogItemRepository;
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NextFilter {
pub count: usize,
pub sprint: Option<String>,
}
pub async fn next_items(project_dir: &Path, filter: &NextFilter) -> Result<Vec<BacklogItem>> {
let (board_dir, repo, config) = super::open_board(project_dir).await?;
let config_path = board_dir.join("config.toml");
let first_status = config
.columns
.first()
.map(Status::new)
.ok_or_else(|| Error::parse(&config_path, "board config has no columns"))?;
let done_status = Status::new(&config.done_column);
let mut items = repo.list().await?;
super::apply_effective_points(&mut items, config.points.aggregate_children, &done_status);
Ok(actionable_items(
&items,
&first_status,
&done_status,
filter.sprint.as_deref(),
filter.count,
))
}
fn actionable_items(
items: &[BacklogItem],
first_status: &Status,
done_status: &Status,
sprint: Option<&str>,
count: usize,
) -> Vec<BacklogItem> {
if count == 0 {
return Vec::new();
}
let status_by_id: HashMap<&ItemId, &Status> =
items.iter().map(|item| (&item.id, &item.status)).collect();
let selected = items
.iter()
.filter(|item| item.status == *first_status && item.status != *done_status)
.filter(|item| sprint.is_none_or(|wanted| item.sprint.as_deref() == Some(wanted)))
.filter(|item| {
item.depends_on.iter().all(|dependency| {
status_by_id
.get(dependency)
.is_some_and(|status| *status == done_status)
})
})
.cloned()
.collect::<Vec<_>>();
super::hierarchical(selected)
.into_iter()
.take(count)
.collect()
}
#[cfg(test)]
mod tests {
use super::actionable_items;
use crate::backlog::{BacklogItem, ItemId, Status};
use crate::rank::Rank;
use chrono::Utc;
fn item(id: &str, status: &str, rank: &str) -> BacklogItem {
BacklogItem::new(
id.parse::<ItemId>().expect("valid item ID"),
id,
Status::new(status),
Rank::parse(rank).expect("valid rank"),
Utc::now(),
)
.expect("valid item")
}
#[test]
fn selects_ranked_unstarted_items_with_completed_dependencies() {
let mut blocked = item("T-2", "todo", "b");
blocked.depends_on.push("T-3".parse().expect("valid ID"));
let mut ready = item("T-1", "todo", "a");
ready.depends_on.push("T-4".parse().expect("valid ID"));
let done = item("T-4", "done", "d");
let in_progress = item("T-5", "in-progress", "c");
let items = vec![ready.clone(), blocked, in_progress, done];
let selected =
actionable_items(&items, &Status::new("todo"), &Status::new("done"), None, 10);
assert_eq!(selected, [ready]);
}
#[test]
fn excludes_missing_dependencies_and_completed_items() {
let mut missing = item("T-1", "todo", "a");
missing.depends_on.push("T-99".parse().expect("valid ID"));
let done = item("T-2", "done", "b");
let started = item("T-3", "review", "c");
let items = vec![missing, done, started];
let selected =
actionable_items(&items, &Status::new("todo"), &Status::new("done"), None, 10);
assert!(selected.is_empty());
}
#[test]
fn filters_by_sprint_and_limits_in_priority_order() {
let mut first = item("T-1", "todo", "a");
first.sprint = Some("S-1".to_string());
let mut second = item("T-2", "todo", "b");
second.sprint = Some("S-1".to_string());
let mut other = item("T-3", "todo", "c");
other.sprint = Some("S-2".to_string());
let items = vec![first.clone(), second, other];
let selected = actionable_items(
&items,
&Status::new("todo"),
&Status::new("done"),
Some("S-1"),
1,
);
assert_eq!(selected, [first]);
}
#[test]
fn applies_hierarchical_priority_before_limiting_candidates() {
let mut child = item("T-2", "todo", "a");
child.parent = Some("T-1".parse().expect("valid ID"));
let sibling = item("T-3", "todo", "c");
let parent = item("T-1", "todo", "b");
let items = vec![child, parent.clone(), sibling];
let selected =
actionable_items(&items, &Status::new("todo"), &Status::new("done"), None, 1);
assert_eq!(selected[0].id, parent.id);
}
}