use crate::config::BoardConfiguration;
use crate::model::Issue;
use super::config::{ColumnLimit, Estimate, LimitPolicy, WipDiagnostic};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LimitState {
Within,
Over,
Unknown,
}
impl LimitState {
pub const fn as_str(self) -> &'static str {
match self {
Self::Within => "within",
Self::Over => "over",
Self::Unknown => "unknown",
}
}
pub const fn is_violation(self) -> bool {
matches!(self, Self::Over)
}
pub const fn worse(self, other: Self) -> Self {
match (self, other) {
(Self::Over, _) | (_, Self::Over) => Self::Over,
(Self::Unknown, _) | (_, Self::Unknown) => Self::Unknown,
_ => Self::Within,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColumnUsage {
pub column: String,
pub items: u32,
pub estimate: Option<u32>,
pub limit: Option<ColumnLimit>,
}
impl ColumnUsage {
pub fn max_items(&self) -> Option<u32> {
self.limit.as_ref().and_then(|limit| limit.max_items)
}
pub fn max_estimate(&self) -> Option<u32> {
self.limit.as_ref().and_then(|limit| limit.max_estimate)
}
pub fn unit(&self) -> Option<&str> {
self.limit.as_ref().and_then(|limit| limit.unit.as_deref())
}
pub fn policy(&self) -> Option<LimitPolicy> {
self.limit.as_ref().and_then(ColumnLimit::resolved_policy)
}
pub fn item_state(&self) -> LimitState {
match self.max_items() {
Some(max) => match self.items.cmp(&max) {
std::cmp::Ordering::Greater => LimitState::Over,
_ => LimitState::Within,
},
None => LimitState::Within,
}
}
pub fn estimate_state(&self) -> LimitState {
match (self.max_estimate(), self.estimate) {
(Some(_), None) => LimitState::Unknown,
(Some(max), Some(total)) => match total.cmp(&max) {
std::cmp::Ordering::Greater => LimitState::Over,
_ => LimitState::Within,
},
(None, _) => LimitState::Within,
}
}
pub fn state(&self) -> LimitState {
self.item_state().worse(self.estimate_state())
}
pub fn prospective(&self, add_items: u32, add_estimate: u32) -> LimitState {
let items = self.items.saturating_add(add_items);
let item_state = match self.max_items() {
Some(max) if items > max => LimitState::Over,
_ => LimitState::Within,
};
let estimate_state = match (self.max_estimate(), self.estimate) {
(Some(_), None) => LimitState::Unknown,
(Some(max), Some(total)) => match total.saturating_add(add_estimate).cmp(&max) {
std::cmp::Ordering::Greater => LimitState::Over,
_ => LimitState::Within,
},
(None, _) => LimitState::Within,
};
item_state.worse(estimate_state)
}
pub fn cell(&self) -> String {
let mut cell = match self.max_items() {
Some(max) => format!("{}/{max}", self.items),
None => self.items.to_string(),
};
if let Some(max) = self.max_estimate() {
let unit = self.unit().unwrap_or("");
match self.estimate {
Some(total) => cell.push_str(&format!(" {total}/{max}{unit}")),
None => cell.push_str(&format!(" ?/{max}{unit}")),
}
}
if !matches!(self.state(), LimitState::Within) {
cell.push('!');
}
cell
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WipSnapshot {
columns: Vec<ColumnUsage>,
diagnostics: Vec<WipDiagnostic>,
configured: bool,
}
impl WipSnapshot {
pub fn build(board: &BoardConfiguration, issues: &[Issue]) -> Self {
let mut columns: Vec<ColumnUsage> = board
.columns
.iter()
.map(|column| ColumnUsage {
column: column.clone(),
items: 0,
estimate: None,
limit: board.wip.effective_limit(column).cloned(),
})
.collect();
let mut totals: Vec<Option<u32>> = columns
.iter()
.map(|usage| usage.max_estimate().map(|_| 0))
.collect();
let mut diagnostics: Vec<WipDiagnostic> = Vec::new();
let index: std::collections::HashMap<&str, usize> = board
.columns
.iter()
.enumerate()
.map(|(slot, column)| (column.as_str(), slot))
.collect();
for issue in issues {
let Some(&slot) = index.get(issue.status.display()) else {
continue;
};
columns[slot].items = columns[slot].items.saturating_add(1);
let Some(total) = totals[slot].as_mut() else {
continue;
};
let Some(raw) = issue
.estimate
.as_deref()
.map(str::trim)
.filter(|raw| !raw.is_empty())
else {
continue;
};
let unit = columns[slot].unit().unwrap_or_default().to_string();
match Estimate::parse(raw).and_then(|estimate| board.wip.convert(&estimate, &unit)) {
Ok(value) => *total = total.saturating_add(value),
Err(error) => {
let diagnostic = error.first();
diagnostics.push(
WipDiagnostic::new(
diagnostic.kind,
format!(
"issue #{} in `{}` states `{raw}`, which this board cannot count \
in `{unit}`: {}",
issue.number, columns[slot].column, diagnostic.message
),
)
.because(
"the column's estimate total is unknown until this is fixed, and an \
unknown total never passes a limit (C-003)",
),
);
totals[slot] = None;
columns[slot].estimate = None;
}
}
}
for (slot, total) in totals.into_iter().enumerate() {
columns[slot].estimate = total;
}
let configured = columns.iter().any(|usage| usage.limit.is_some());
Self {
columns,
diagnostics,
configured,
}
}
pub fn columns(&self) -> &[ColumnUsage] {
&self.columns
}
pub fn column(&self, column: &str) -> Option<&ColumnUsage> {
self.columns.iter().find(|usage| usage.column == column)
}
pub fn diagnostics(&self) -> &[WipDiagnostic] {
&self.diagnostics
}
pub fn is_configured(&self) -> bool {
self.configured
}
pub fn state(&self, column: &str) -> LimitState {
self.column(column)
.map_or(LimitState::Within, ColumnUsage::state)
}
pub fn violations(&self) -> impl Iterator<Item = &ColumnUsage> {
self.columns
.iter()
.filter(|usage| usage.state().is_violation())
}
}
#[cfg(test)]
mod tests {
use super::*;
use indexmap::IndexMap;
use crate::config::BoardConfiguration;
use crate::wip::WipConfig;
use crate::wip::config::WipDiagnosticKind;
fn board(limits: &[(&str, ColumnLimit)]) -> BoardConfiguration {
BoardConfiguration {
columns: ["todo", "in progress", "in review", "done"]
.into_iter()
.map(String::from)
.collect(),
wip: WipConfig {
units: [("d", 1), ("w", 5)]
.into_iter()
.map(|(name, factor)| (name.to_string(), factor))
.collect::<IndexMap<_, _>>(),
limits: limits
.iter()
.map(|(column, limit)| ((*column).to_string(), limit.clone()))
.collect(),
},
..BoardConfiguration::default()
}
}
fn issue(number: u32, status: &str, estimate: Option<&str>) -> Issue {
let estimate = estimate
.map(|value| format!("estimate: \"{value}\"\n"))
.unwrap_or_default();
let contents = format!(
"---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"{status}\"\n\
created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n{estimate}---\n\nBody.\n"
);
let mut issue = Issue::parse(&contents).expect("the fixture parses");
issue.filename = format!("issue-{number}.md");
issue
}
#[test]
fn wip_aggregate_counts_every_column_in_one_pass() {
let board = board(&[("in progress", ColumnLimit::items(2, LimitPolicy::Warn))]);
let issues = vec![
issue(1, "todo", None),
issue(2, "in progress", None),
issue(3, "in progress", None),
issue(4, "done", None),
issue(5, "shipped", None), ];
let snapshot = WipSnapshot::build(&board, &issues);
assert_eq!(
snapshot
.columns()
.iter()
.map(|usage| (usage.column.as_str(), usage.items))
.collect::<Vec<_>>(),
vec![
("todo", 1),
("in progress", 2),
("in review", 0),
("done", 1)
],
"every configured column appears, in board order"
);
assert_eq!(
snapshot.column("in progress").expect("usage").state(),
LimitState::Within
);
assert_eq!(
snapshot.state("todo"),
LimitState::Within,
"no limit is within"
);
assert!(snapshot.is_configured());
}
#[test]
fn wip_aggregate_state_is_over_only_past_the_ceiling() {
let board = board(&[("in progress", ColumnLimit::items(2, LimitPolicy::Block))]);
let at_limit = WipSnapshot::build(
&board,
&[issue(1, "in progress", None), issue(2, "in progress", None)],
);
let usage = at_limit.column("in progress").expect("usage");
assert_eq!(
usage.state(),
LimitState::Within,
"exactly at the limit is within"
);
assert_eq!(usage.prospective(1, 0), LimitState::Over);
assert_eq!(
usage.prospective(0, 0),
LimitState::Within,
"a no-op move is within"
);
let over = WipSnapshot::build(
&board,
&[
issue(1, "in progress", None),
issue(2, "in progress", None),
issue(3, "in progress", None),
],
);
assert_eq!(
over.column("in progress").expect("usage").state(),
LimitState::Over
);
assert_eq!(over.violations().count(), 1);
assert_eq!(over.column("in progress").expect("usage").cell(), "3/2!");
}
#[test]
fn wip_aggregate_totals_estimates_in_the_limit_unit() {
let board = board(&[(
"in progress",
ColumnLimit::estimate(10, "d", LimitPolicy::Warn),
)]);
let issues = vec![
issue(1, "in progress", Some("3")),
issue(2, "in progress", Some("1w")),
issue(3, "in progress", None),
issue(4, "in progress", Some(" ")),
issue(5, "todo", Some("9w")),
];
let snapshot = WipSnapshot::build(&board, &issues);
let usage = snapshot.column("in progress").expect("usage");
assert_eq!(usage.items, 4, "an unestimated card is still work");
assert_eq!(
usage.estimate,
Some(8),
"3 + 5d; the empty ones add nothing"
);
assert_eq!(usage.state(), LimitState::Within);
assert_eq!(usage.cell(), "4 8/10d");
assert_eq!(
snapshot.column("todo").expect("usage").estimate,
None,
"no estimate ceiling means no unit, so no total"
);
assert_eq!(snapshot.diagnostics(), []);
}
#[test]
fn wip_aggregate_unknown_total_is_never_within() {
let board = board(&[(
"in progress",
ColumnLimit::estimate(10, "d", LimitPolicy::Block),
)]);
for (raw, kind) in [
("three", WipDiagnosticKind::MalformedEstimate),
("3q", WipDiagnosticKind::MissingConversion),
] {
let snapshot = WipSnapshot::build(
&board,
&[
issue(1, "in progress", Some("2")),
issue(2, "in progress", Some(raw)),
],
);
let usage = snapshot.column("in progress").expect("usage");
assert_eq!(usage.estimate, None, "`{raw}`: no total is claimed");
assert_eq!(usage.estimate_state(), LimitState::Unknown, "`{raw}`");
assert_eq!(usage.prospective(0, 0), LimitState::Unknown, "`{raw}`");
assert_eq!(
usage.cell(),
"2 ?/10d!",
"the cell says unknown with a `!`, never a number"
);
let diagnostic = &snapshot.diagnostics()[0];
assert_eq!(diagnostic.kind, kind, "`{raw}`");
assert!(
diagnostic.message.contains("issue #2") && diagnostic.message.contains(raw),
"{}",
diagnostic.message
);
}
}
#[test]
fn wip_aggregate_fractional_conversion_makes_the_total_unknown() {
let mut board = board(&[(
"in progress",
ColumnLimit::estimate(10, "w", LimitPolicy::Warn),
)]);
board.wip.units.insert("h".to_string(), 1);
board.wip.units.insert("d".to_string(), 8);
let snapshot = WipSnapshot::build(&board, &[issue(1, "in progress", Some("3d"))]);
let usage = snapshot.column("in progress").expect("usage");
assert_eq!(usage.estimate_state(), LimitState::Unknown);
assert_eq!(
snapshot.diagnostics()[0].kind,
WipDiagnosticKind::AmbiguousConversion
);
}
#[test]
fn wip_aggregate_without_limits_is_inert() {
let board = BoardConfiguration::default();
let issues = vec![issue(1, "todo", Some("3d")), issue(2, "todo", Some("nope"))];
let snapshot = WipSnapshot::build(&board, &issues);
assert!(!snapshot.is_configured(), "nothing is configured");
assert!(snapshot.violations().next().is_none());
assert_eq!(snapshot.column("todo").expect("usage").items, 2);
assert!(
snapshot.diagnostics().is_empty(),
"with no limit there is nothing to measure, so nothing is broken"
);
}
}