1use crate::config::BoardConfiguration;
24use crate::model::Issue;
25
26use super::config::{ColumnLimit, Estimate, LimitPolicy, WipDiagnostic};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub enum LimitState {
31 Within,
33 Over,
35 Unknown,
38}
39
40impl LimitState {
41 pub const fn as_str(self) -> &'static str {
43 match self {
44 Self::Within => "within",
45 Self::Over => "over",
46 Self::Unknown => "unknown",
47 }
48 }
49
50 pub const fn is_violation(self) -> bool {
52 matches!(self, Self::Over)
53 }
54
55 pub const fn worse(self, other: Self) -> Self {
57 match (self, other) {
58 (Self::Over, _) | (_, Self::Over) => Self::Over,
59 (Self::Unknown, _) | (_, Self::Unknown) => Self::Unknown,
60 _ => Self::Within,
61 }
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct ColumnUsage {
68 pub column: String,
70 pub items: u32,
72 pub estimate: Option<u32>,
79 pub limit: Option<ColumnLimit>,
81}
82
83impl ColumnUsage {
84 pub fn max_items(&self) -> Option<u32> {
86 self.limit.as_ref().and_then(|limit| limit.max_items)
87 }
88
89 pub fn max_estimate(&self) -> Option<u32> {
91 self.limit.as_ref().and_then(|limit| limit.max_estimate)
92 }
93
94 pub fn unit(&self) -> Option<&str> {
96 self.limit.as_ref().and_then(|limit| limit.unit.as_deref())
97 }
98
99 pub fn policy(&self) -> Option<LimitPolicy> {
101 self.limit.as_ref().and_then(ColumnLimit::resolved_policy)
102 }
103
104 pub fn item_state(&self) -> LimitState {
106 match self.max_items() {
107 Some(max) => match self.items.cmp(&max) {
108 std::cmp::Ordering::Greater => LimitState::Over,
109 _ => LimitState::Within,
110 },
111 None => LimitState::Within,
112 }
113 }
114
115 pub fn estimate_state(&self) -> LimitState {
117 match (self.max_estimate(), self.estimate) {
118 (Some(_), None) => LimitState::Unknown,
120 (Some(max), Some(total)) => match total.cmp(&max) {
121 std::cmp::Ordering::Greater => LimitState::Over,
122 _ => LimitState::Within,
123 },
124 (None, _) => LimitState::Within,
125 }
126 }
127
128 pub fn state(&self) -> LimitState {
130 self.item_state().worse(self.estimate_state())
131 }
132
133 pub fn prospective(&self, add_items: u32, add_estimate: u32) -> LimitState {
140 let items = self.items.saturating_add(add_items);
141 let item_state = match self.max_items() {
142 Some(max) if items > max => LimitState::Over,
143 _ => LimitState::Within,
144 };
145 let estimate_state = match (self.max_estimate(), self.estimate) {
146 (Some(_), None) => LimitState::Unknown,
147 (Some(max), Some(total)) => match total.saturating_add(add_estimate).cmp(&max) {
148 std::cmp::Ordering::Greater => LimitState::Over,
149 _ => LimitState::Within,
150 },
151 (None, _) => LimitState::Within,
152 };
153 item_state.worse(estimate_state)
154 }
155
156 pub fn cell(&self) -> String {
164 let mut cell = match self.max_items() {
165 Some(max) => format!("{}/{max}", self.items),
166 None => self.items.to_string(),
167 };
168 if let Some(max) = self.max_estimate() {
169 let unit = self.unit().unwrap_or("");
170 match self.estimate {
171 Some(total) => cell.push_str(&format!(" {total}/{max}{unit}")),
172 None => cell.push_str(&format!(" ?/{max}{unit}")),
175 }
176 }
177 if !matches!(self.state(), LimitState::Within) {
178 cell.push('!');
179 }
180 cell
181 }
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct WipSnapshot {
187 columns: Vec<ColumnUsage>,
188 diagnostics: Vec<WipDiagnostic>,
189 configured: bool,
190}
191
192impl WipSnapshot {
193 pub fn build(board: &BoardConfiguration, issues: &[Issue]) -> Self {
201 let mut columns: Vec<ColumnUsage> = board
202 .columns
203 .iter()
204 .map(|column| ColumnUsage {
205 column: column.clone(),
206 items: 0,
207 estimate: None,
208 limit: board.wip.effective_limit(column).cloned(),
209 })
210 .collect();
211 let mut totals: Vec<Option<u32>> = columns
215 .iter()
216 .map(|usage| usage.max_estimate().map(|_| 0))
217 .collect();
218 let mut diagnostics: Vec<WipDiagnostic> = Vec::new();
219 let index: std::collections::HashMap<&str, usize> = board
220 .columns
221 .iter()
222 .enumerate()
223 .map(|(slot, column)| (column.as_str(), slot))
224 .collect();
225
226 for issue in issues {
227 let Some(&slot) = index.get(issue.status.display()) else {
228 continue;
229 };
230 columns[slot].items = columns[slot].items.saturating_add(1);
231 let Some(total) = totals[slot].as_mut() else {
232 continue;
233 };
234 let Some(raw) = issue
235 .estimate
236 .as_deref()
237 .map(str::trim)
238 .filter(|raw| !raw.is_empty())
239 else {
240 continue;
243 };
244 let unit = columns[slot].unit().unwrap_or_default().to_string();
245 match Estimate::parse_terms(raw)
246 .and_then(|terms| board.wip.convert_terms(&terms, &unit))
247 {
248 Ok(value) => *total = total.saturating_add(value),
249 Err(error) => {
250 let diagnostic = error.first();
251 diagnostics.push(
252 WipDiagnostic::new(
253 diagnostic.kind,
254 format!(
255 "issue #{} in `{}` states `{raw}`, which this board cannot count \
256 in `{unit}`: {}",
257 issue.number, columns[slot].column, diagnostic.message
258 ),
259 )
260 .because(
261 "the column's estimate total is unknown until this is fixed, and an \
262 unknown total never passes a limit (C-003)",
263 ),
264 );
265 totals[slot] = None;
267 columns[slot].estimate = None;
268 }
269 }
270 }
271
272 for (slot, total) in totals.into_iter().enumerate() {
273 columns[slot].estimate = total;
274 }
275 let configured = columns.iter().any(|usage| usage.limit.is_some());
276 Self {
277 columns,
278 diagnostics,
279 configured,
280 }
281 }
282
283 pub fn columns(&self) -> &[ColumnUsage] {
285 &self.columns
286 }
287
288 pub fn column(&self, column: &str) -> Option<&ColumnUsage> {
290 self.columns.iter().find(|usage| usage.column == column)
291 }
292
293 pub fn diagnostics(&self) -> &[WipDiagnostic] {
295 &self.diagnostics
296 }
297
298 pub fn is_configured(&self) -> bool {
303 self.configured
304 }
305
306 pub fn state(&self, column: &str) -> LimitState {
308 self.column(column)
309 .map_or(LimitState::Within, ColumnUsage::state)
310 }
311
312 pub fn violations(&self) -> impl Iterator<Item = &ColumnUsage> {
314 self.columns
315 .iter()
316 .filter(|usage| usage.state().is_violation())
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 #[allow(clippy::wildcard_imports)]
323 use super::*;
324 use indexmap::IndexMap;
325
326 use crate::config::BoardConfiguration;
327 use crate::wip::WipConfig;
328 use crate::wip::config::WipDiagnosticKind;
329
330 fn board(limits: &[(&str, ColumnLimit)]) -> BoardConfiguration {
331 BoardConfiguration {
332 columns: ["todo", "in progress", "in review", "done"]
333 .into_iter()
334 .map(String::from)
335 .collect(),
336 wip: WipConfig {
337 units: [("d", 1), ("w", 5)]
338 .into_iter()
339 .map(|(name, factor)| (name.to_string(), factor))
340 .collect::<IndexMap<_, _>>(),
341 limits: limits
342 .iter()
343 .map(|(column, limit)| ((*column).to_string(), limit.clone()))
344 .collect(),
345 },
346 ..BoardConfiguration::default()
347 }
348 }
349
350 fn issue(number: u32, status: &str, estimate: Option<&str>) -> Issue {
353 let estimate = estimate
354 .map(|value| format!("estimate: \"{value}\"\n"))
355 .unwrap_or_default();
356 let contents = format!(
357 "---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"{status}\"\n\
358 created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n{estimate}---\n\nBody.\n"
359 );
360 let mut issue = Issue::parse(&contents).expect("the fixture parses");
361 issue.filename = format!("issue-{number}.md");
362 issue
363 }
364
365 #[test]
366 fn wip_aggregate_counts_every_column_in_one_pass() {
367 let board = board(&[("in progress", ColumnLimit::items(2, LimitPolicy::Warn))]);
368 let issues = vec![
369 issue(1, "todo", None),
370 issue(2, "in progress", None),
371 issue(3, "in progress", None),
372 issue(4, "done", None),
373 issue(5, "shipped", None), ];
375 let snapshot = WipSnapshot::build(&board, &issues);
376 assert_eq!(
377 snapshot
378 .columns()
379 .iter()
380 .map(|usage| (usage.column.as_str(), usage.items))
381 .collect::<Vec<_>>(),
382 vec![
383 ("todo", 1),
384 ("in progress", 2),
385 ("in review", 0),
386 ("done", 1)
387 ],
388 "every configured column appears, in board order"
389 );
390 assert_eq!(
391 snapshot.column("in progress").expect("usage").state(),
392 LimitState::Within
393 );
394 assert_eq!(
395 snapshot.state("todo"),
396 LimitState::Within,
397 "no limit is within"
398 );
399 assert!(snapshot.is_configured());
400 }
401
402 #[test]
403 fn wip_aggregate_state_is_over_only_past_the_ceiling() {
404 let board = board(&[("in progress", ColumnLimit::items(2, LimitPolicy::Block))]);
405 let at_limit = WipSnapshot::build(
406 &board,
407 &[issue(1, "in progress", None), issue(2, "in progress", None)],
408 );
409 let usage = at_limit.column("in progress").expect("usage");
410 assert_eq!(
411 usage.state(),
412 LimitState::Within,
413 "exactly at the limit is within"
414 );
415 assert_eq!(usage.prospective(1, 0), LimitState::Over);
416 assert_eq!(
417 usage.prospective(0, 0),
418 LimitState::Within,
419 "a no-op move is within"
420 );
421
422 let over = WipSnapshot::build(
423 &board,
424 &[
425 issue(1, "in progress", None),
426 issue(2, "in progress", None),
427 issue(3, "in progress", None),
428 ],
429 );
430 assert_eq!(
431 over.column("in progress").expect("usage").state(),
432 LimitState::Over
433 );
434 assert_eq!(over.violations().count(), 1);
435 assert_eq!(over.column("in progress").expect("usage").cell(), "3/2!");
436 }
437
438 #[test]
439 fn wip_aggregate_totals_estimates_in_the_limit_unit() {
440 let board = board(&[(
441 "in progress",
442 ColumnLimit::estimate(10, "d", LimitPolicy::Warn),
443 )]);
444 let issues = vec![
445 issue(1, "in progress", Some("3")),
446 issue(2, "in progress", Some("1w")),
447 issue(3, "in progress", None),
448 issue(4, "in progress", Some(" ")),
449 issue(5, "todo", Some("9w")),
450 ];
451 let snapshot = WipSnapshot::build(&board, &issues);
452 let usage = snapshot.column("in progress").expect("usage");
453 assert_eq!(usage.items, 4, "an unestimated card is still work");
454 assert_eq!(
455 usage.estimate,
456 Some(8),
457 "3 + 5d; the empty ones add nothing"
458 );
459 assert_eq!(usage.state(), LimitState::Within);
460 assert_eq!(usage.cell(), "4 8/10d");
461 assert_eq!(
462 snapshot.column("todo").expect("usage").estimate,
463 None,
464 "no estimate ceiling means no unit, so no total"
465 );
466 assert_eq!(snapshot.diagnostics(), []);
467 }
468
469 #[test]
470 fn wip_aggregate_unknown_total_is_never_within() {
471 let board = board(&[(
472 "in progress",
473 ColumnLimit::estimate(10, "d", LimitPolicy::Block),
474 )]);
475 for (raw, kind) in [
476 ("three", WipDiagnosticKind::MalformedEstimate),
477 ("3q", WipDiagnosticKind::MissingConversion),
478 ] {
479 let snapshot = WipSnapshot::build(
480 &board,
481 &[
482 issue(1, "in progress", Some("2")),
483 issue(2, "in progress", Some(raw)),
484 ],
485 );
486 let usage = snapshot.column("in progress").expect("usage");
487 assert_eq!(usage.estimate, None, "`{raw}`: no total is claimed");
488 assert_eq!(usage.estimate_state(), LimitState::Unknown, "`{raw}`");
489 assert_eq!(usage.prospective(0, 0), LimitState::Unknown, "`{raw}`");
490 assert_eq!(
491 usage.cell(),
492 "2 ?/10d!",
493 "the cell says unknown with a `!`, never a number"
494 );
495 let diagnostic = &snapshot.diagnostics()[0];
496 assert_eq!(diagnostic.kind, kind, "`{raw}`");
497 assert!(
498 diagnostic.message.contains("issue #2") && diagnostic.message.contains(raw),
499 "{}",
500 diagnostic.message
501 );
502 }
503 }
504
505 #[test]
506 fn wip_aggregate_fractional_conversion_makes_the_total_unknown() {
507 let mut board = board(&[(
508 "in progress",
509 ColumnLimit::estimate(10, "w", LimitPolicy::Warn),
510 )]);
511 board.wip.units.insert("h".to_string(), 1);
512 board.wip.units.insert("d".to_string(), 8);
513 let snapshot = WipSnapshot::build(&board, &[issue(1, "in progress", Some("3d"))]);
514 let usage = snapshot.column("in progress").expect("usage");
515 assert_eq!(usage.estimate_state(), LimitState::Unknown);
516 assert_eq!(
517 snapshot.diagnostics()[0].kind,
518 WipDiagnosticKind::AmbiguousConversion
519 );
520 }
521
522 #[test]
523 fn wip_aggregate_without_limits_is_inert() {
524 let board = BoardConfiguration::default();
525 let issues = vec![issue(1, "todo", Some("3d")), issue(2, "todo", Some("nope"))];
526 let snapshot = WipSnapshot::build(&board, &issues);
527 assert!(!snapshot.is_configured(), "nothing is configured");
528 assert!(snapshot.violations().next().is_none());
529 assert_eq!(snapshot.column("todo").expect("usage").items, 2);
530 assert!(
531 snapshot.diagnostics().is_empty(),
532 "with no limit there is nothing to measure, so nothing is broken"
533 );
534 }
535}