1use super::open_board;
7use crate::backlog::{BacklogItem, Status};
8use crate::config::WipConfig;
9use crate::error::Result;
10use std::path::Path;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct WipViolation {
15 pub column: String,
17 pub limit: u32,
19 pub count: usize,
21}
22
23#[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
51pub 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 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 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 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}