Skip to main content

pinto/service/
wip.rs

1//! Detect columns that exceed their WIP (work in progress) limits.
2//!
3//! Match the per-column limits in [`crate::config::WipConfig`] against item counts and report
4//! violations for `move` and `board` warnings.
5
6use super::open_board;
7use crate::backlog::{BacklogItem, Status};
8use crate::config::WipConfig;
9use crate::error::Result;
10use std::path::Path;
11
12/// A column whose item count exceeds its WIP limit.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct WipViolation {
15    /// Column (workflow status) name.
16    pub column: String,
17    /// Maximum number of items allowed in the column.
18    pub limit: u32,
19    /// Current number of items in the column (`> limit`).
20    pub count: usize,
21}
22
23/// Compare each column limit in `config` with the corresponding item count and return the columns
24/// that exceed their limits.
25///
26/// - Always empty (check disabled) when `config.enabled` is `false`.
27/// - Columns not in `config.limits` are treated as unlimited.
28/// - If the number is exactly at the upper limit, it is not exceeded (only `count > limit` is violated).
29///
30/// Results are ordered by column name because `limits` is a `BTreeMap`.
31#[must_use]
32pub fn wip_violations(config: &WipConfig, items: &[BacklogItem]) -> Vec<WipViolation> {
33    if !config.enabled {
34        return Vec::new();
35    }
36    config
37        .limits
38        .iter()
39        .filter_map(|(column, &limit)| {
40            let status = Status::new(column);
41            let count = items.iter().filter(|it| it.status == status).count();
42            (count as u64 > u64::from(limit)).then(|| WipViolation {
43                column: column.clone(),
44                limit,
45                count,
46            })
47        })
48        .collect()
49}
50
51/// Load the board in `project_dir` and return the columns that exceed the WIP limit.
52///
53/// Read `[wip]` configuration and all PBIs, then delegate to [`wip_violations`]. Return
54/// [`crate::error::Error::NotInitialized`] for an uninitialized board.
55pub async fn check_wip(project_dir: &Path) -> Result<Vec<WipViolation>> {
56    let (_board_dir, repo, config) = open_board(project_dir).await?;
57    let items = crate::storage::BacklogItemRepository::list(&repo).await?;
58    Ok(wip_violations(&config.wip, &items))
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use crate::config::Config;
65    use crate::service::test_support::init_temp;
66    use crate::service::{NewItem, add_item, move_item};
67
68    /// Create `n` dummy PBIs with the specified status for violation tests.
69    fn items_in(status: &str, n: usize) -> Vec<BacklogItem> {
70        (0..n)
71            .map(|i| {
72                BacklogItem::new(
73                    crate::backlog::ItemId::new("T", i as u32 + 1),
74                    format!("item {i}"),
75                    Status::new(status),
76                    crate::rank::Rank::after(None),
77                    chrono::Utc::now(),
78                )
79                .expect("valid item")
80            })
81            .collect()
82    }
83
84    fn cfg(enabled: bool, limits: &[(&str, u32)]) -> WipConfig {
85        WipConfig {
86            enabled,
87            limits: limits.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
88        }
89    }
90
91    #[test]
92    fn reports_violation_when_count_exceeds_limit() {
93        let items = items_in("in-progress", 4);
94        let v = wip_violations(&cfg(true, &[("in-progress", 3)]), &items);
95        assert_eq!(v.len(), 1);
96        assert_eq!(v[0].column, "in-progress");
97        assert_eq!(v[0].limit, 3);
98        assert_eq!(v[0].count, 4);
99    }
100
101    #[test]
102    fn no_violation_when_count_at_or_below_limit() {
103        let items = items_in("in-progress", 3);
104        assert!(wip_violations(&cfg(true, &[("in-progress", 3)]), &items).is_empty());
105    }
106
107    #[test]
108    fn columns_without_a_configured_limit_are_unlimited() {
109        // Reviews have no configured upper limit, so any number of reviews is valid.
110        let items = items_in("review", 100);
111        assert!(wip_violations(&cfg(true, &[("in-progress", 1)]), &items).is_empty());
112    }
113
114    #[test]
115    fn disabled_config_yields_no_violations() {
116        let items = items_in("in-progress", 10);
117        assert!(wip_violations(&cfg(false, &[("in-progress", 1)]), &items).is_empty());
118    }
119
120    #[test]
121    fn multiple_violations_are_sorted_by_column_name() {
122        let mut items = items_in("in-progress", 2);
123        items.extend(items_in("review", 2));
124        let v = wip_violations(&cfg(true, &[("review", 1), ("in-progress", 1)]), &items);
125        let cols: Vec<_> = v.iter().map(|x| x.column.as_str()).collect();
126        assert_eq!(cols, ["in-progress", "review"], "BTreeMap 由来で昇順");
127    }
128
129    #[tokio::test]
130    async fn check_wip_reads_board_and_detects_overflow() {
131        let dir = init_temp().await;
132        // Set the in-progress upper limit of 1 to the default config.
133        let path = dir.path().join(".pinto").join("config.toml");
134        let mut config = Config::load(&path).await.unwrap();
135        config.wip.limits.insert("in-progress".to_string(), 1);
136        config.save(&path).await.unwrap();
137
138        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
139        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
140        move_item(dir.path(), &a.id, "in-progress").await.unwrap();
141        move_item(dir.path(), &b.id, "in-progress").await.unwrap();
142
143        let v = check_wip(dir.path()).await.unwrap();
144        assert_eq!(v.len(), 1);
145        assert_eq!(v[0].column, "in-progress");
146        assert_eq!(v[0].count, 2);
147        assert_eq!(v[0].limit, 1);
148    }
149}