Skip to main content

pinto/service/
next.rs

1//! Read-only selection of backlog items that are ready to start.
2
3use crate::backlog::{BacklogItem, ItemId, Status};
4use crate::error::{Error, Result};
5use crate::storage::BacklogItemRepository;
6use std::collections::HashMap;
7use std::path::Path;
8
9/// Filters for [`next_items`].
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct NextFilter {
12    /// Maximum number of actionable PBIs to return.
13    pub count: usize,
14    /// Optional exact Sprint ID filter.
15    pub sprint: Option<String>,
16}
17
18/// Return the highest-ranked PBIs that are ready to start without modifying the board.
19pub 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
39/// Select actionable items from a canonical backlog snapshot.
40fn 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    // Apply the same parent/child priority used by list and board before limiting the result.
69    // Otherwise a child with a lower raw rank could consume the count before its parent is seen.
70    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        // Repository order is raw rank order; canonical order must put the parent before the
152        // sibling and its child before applying a count limit.
153        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}