1use crate::backlog::{BacklogItem, ItemId, Status};
4use crate::error::{Error, Result};
5use crate::storage::BacklogItemRepository;
6use std::collections::HashMap;
7use std::path::Path;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct NextFilter {
12 pub count: usize,
14 pub sprint: Option<String>,
16}
17
18pub async fn next_items(project_dir: &Path, filter: &NextFilter) -> Result<Vec<BacklogItem>> {
20 let (board_dir, repo, config) = super::open_board(project_dir).await?;
21 let config_path = board_dir.join("config.toml");
22 let first_status = config
23 .columns
24 .first()
25 .map(Status::new)
26 .ok_or_else(|| Error::parse(&config_path, "board config has no columns"))?;
27 let done_status = Status::new(&config.done_column);
28 let mut items = repo.list().await?;
29 super::apply_effective_points(&mut items, config.points.aggregate_children, &done_status);
30 Ok(actionable_items(
31 &items,
32 &first_status,
33 &done_status,
34 filter.sprint.as_deref(),
35 filter.count,
36 ))
37}
38
39fn actionable_items(
41 items: &[BacklogItem],
42 first_status: &Status,
43 done_status: &Status,
44 sprint: Option<&str>,
45 count: usize,
46) -> Vec<BacklogItem> {
47 if count == 0 {
48 return Vec::new();
49 }
50
51 let status_by_id: HashMap<&ItemId, &Status> =
52 items.iter().map(|item| (&item.id, &item.status)).collect();
53
54 let selected = items
55 .iter()
56 .filter(|item| item.status == *first_status && item.status != *done_status)
57 .filter(|item| sprint.is_none_or(|wanted| item.sprint.as_deref() == Some(wanted)))
58 .filter(|item| {
59 item.depends_on.iter().all(|dependency| {
60 status_by_id
61 .get(dependency)
62 .is_some_and(|status| *status == done_status)
63 })
64 })
65 .cloned()
66 .collect::<Vec<_>>();
67
68 super::hierarchical(selected)
71 .into_iter()
72 .take(count)
73 .collect()
74}
75
76#[cfg(test)]
77mod tests {
78 use super::actionable_items;
79 use crate::backlog::{BacklogItem, ItemId, Status};
80 use crate::rank::Rank;
81 use chrono::Utc;
82
83 fn item(id: &str, status: &str, rank: &str) -> BacklogItem {
84 BacklogItem::new(
85 id.parse::<ItemId>().expect("valid item ID"),
86 id,
87 Status::new(status),
88 Rank::parse(rank).expect("valid rank"),
89 Utc::now(),
90 )
91 .expect("valid item")
92 }
93
94 #[test]
95 fn selects_ranked_unstarted_items_with_completed_dependencies() {
96 let mut blocked = item("T-2", "todo", "b");
97 blocked.depends_on.push("T-3".parse().expect("valid ID"));
98 let mut ready = item("T-1", "todo", "a");
99 ready.depends_on.push("T-4".parse().expect("valid ID"));
100 let done = item("T-4", "done", "d");
101 let in_progress = item("T-5", "in-progress", "c");
102 let items = vec![ready.clone(), blocked, in_progress, done];
103
104 let selected =
105 actionable_items(&items, &Status::new("todo"), &Status::new("done"), None, 10);
106
107 assert_eq!(selected, [ready]);
108 }
109
110 #[test]
111 fn excludes_missing_dependencies_and_completed_items() {
112 let mut missing = item("T-1", "todo", "a");
113 missing.depends_on.push("T-99".parse().expect("valid ID"));
114 let done = item("T-2", "done", "b");
115 let started = item("T-3", "review", "c");
116 let items = vec![missing, done, started];
117
118 let selected =
119 actionable_items(&items, &Status::new("todo"), &Status::new("done"), None, 10);
120
121 assert!(selected.is_empty());
122 }
123
124 #[test]
125 fn filters_by_sprint_and_limits_in_priority_order() {
126 let mut first = item("T-1", "todo", "a");
127 first.sprint = Some("S-1".to_string());
128 let mut second = item("T-2", "todo", "b");
129 second.sprint = Some("S-1".to_string());
130 let mut other = item("T-3", "todo", "c");
131 other.sprint = Some("S-2".to_string());
132 let items = vec![first.clone(), second, other];
133
134 let selected = actionable_items(
135 &items,
136 &Status::new("todo"),
137 &Status::new("done"),
138 Some("S-1"),
139 1,
140 );
141
142 assert_eq!(selected, [first]);
143 }
144
145 #[test]
146 fn applies_hierarchical_priority_before_limiting_candidates() {
147 let mut child = item("T-2", "todo", "a");
148 child.parent = Some("T-1".parse().expect("valid ID"));
149 let sibling = item("T-3", "todo", "c");
150 let parent = item("T-1", "todo", "b");
151 let items = vec![child, parent.clone(), sibling];
154
155 let selected =
156 actionable_items(&items, &Status::new("todo"), &Status::new("done"), None, 1);
157
158 assert_eq!(selected[0].id, parent.id);
159 }
160}