use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::error::TixError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LimitPolicy {
Warn,
Block,
}
impl LimitPolicy {
pub const fn as_str(self) -> &'static str {
match self {
Self::Warn => "warn",
Self::Block => "block",
}
}
}
const fn enabled_default() -> bool {
true
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_enabled_default(enabled: &bool) -> bool {
*enabled
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ColumnLimit {
#[serde(skip_serializing_if = "Option::is_none")]
pub max_items: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_estimate: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy: Option<LimitPolicy>,
#[serde(
default = "enabled_default",
skip_serializing_if = "is_enabled_default"
)]
pub enabled: bool,
}
impl Default for ColumnLimit {
fn default() -> Self {
Self {
max_items: None,
max_estimate: None,
unit: None,
policy: None,
enabled: true,
}
}
}
impl ColumnLimit {
pub fn items(max_items: u32, policy: LimitPolicy) -> Self {
Self {
max_items: Some(max_items),
policy: Some(policy),
..Self::default()
}
}
pub fn estimate(max_estimate: u32, unit: impl Into<String>, policy: LimitPolicy) -> Self {
Self {
max_estimate: Some(max_estimate),
unit: Some(unit.into()),
policy: Some(policy),
..Self::default()
}
}
pub fn is_empty(&self) -> bool {
self.max_items.is_none()
&& self.max_estimate.is_none()
&& self.unit.is_none()
&& self.policy.is_none()
&& self.enabled == enabled_default()
}
pub fn resolved_policy(&self) -> Option<LimitPolicy> {
self.enabled.then_some(self.policy).flatten()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct WipConfig {
#[serde(skip_serializing_if = "IndexMap::is_empty")]
pub units: IndexMap<String, u32>,
#[serde(skip_serializing_if = "IndexMap::is_empty")]
pub limits: IndexMap<String, ColumnLimit>,
}
impl WipConfig {
pub fn limits(&self) -> impl Iterator<Item = (&str, &ColumnLimit)> {
self.limits
.iter()
.map(|(column, limit)| (column.as_str(), limit))
}
pub fn is_empty(&self) -> bool {
self.units.is_empty() && self.limits.is_empty()
}
pub fn has_enabled_limits(&self) -> bool {
self.limits.values().any(|limit| limit.enabled)
}
pub fn effective_limit(&self, column: &str) -> Option<&ColumnLimit> {
self.limits
.get(column)
.filter(|limit| limit.enabled && !limit.is_empty())
}
pub fn factor(&self, unit: &str) -> Option<u32> {
self.units.get(unit).copied()
}
pub fn convert(&self, estimate: &Estimate, target: &str) -> std::result::Result<u32, WipError> {
let target_factor = self.factor(target).ok_or_else(|| {
WipError::single(WipDiagnostic::new(
WipDiagnosticKind::UnknownUnit,
format!("`{target}` is not a declared estimate unit"),
))
.because("declare the unit under `[kanban.<board>.wip.units]`")
})?;
let Some(unit) = estimate.unit.as_deref() else {
return Ok(estimate.value);
};
if unit == target {
return Ok(estimate.value);
}
let from_factor = self.factor(unit).ok_or_else(|| {
WipError::single(WipDiagnostic::new(
WipDiagnosticKind::MissingConversion,
format!(
"`{}` uses estimate unit `{unit}`, which this board does not declare",
estimate.render()
),
))
.because("a missing conversion is an error, never a zero or a guess (C-003)")
})?;
let scaled = (estimate.value as u64) * (from_factor as u64);
let target_factor = target_factor as u64;
if scaled % target_factor != 0 {
return Err(WipError::single(WipDiagnostic::new(
WipDiagnosticKind::AmbiguousConversion,
format!(
"`{}` is {scaled} base points, which is not a whole number of `{target}` \
(a `{target}` is {target_factor})",
estimate.render()
),
))
.because(
"declare a unit the arithmetic divides evenly into, or rewrite the estimate",
));
}
Ok((scaled / target_factor) as u32)
}
pub fn validate(&self, board: &str, columns: &[String]) -> std::result::Result<(), TixError> {
for (name, factor) in &self.units {
if name.trim().is_empty() {
return Err(TixError::Config(format!(
"`kanban.{board}.wip.units` must not contain a blank unit name"
)));
}
if *factor == 0 {
return Err(TixError::Config(format!(
"`kanban.{board}.wip.units.{name}` must be at least 1"
)));
}
}
for (column, limit) in &self.limits {
let key = format!("kanban.{board}.wip.limits.`{column}`");
if !columns.contains(column) {
return Err(TixError::Config(format!(
"`{key}` names a column that is not in `kanban.{board}.columns`"
)));
}
if limit.max_items == Some(0) {
return Err(TixError::Config(format!(
"`{key}.max_items` must be at least 1"
)));
}
if limit.max_estimate == Some(0) {
return Err(TixError::Config(format!(
"`{key}.max_estimate` must be at least 1"
)));
}
if let Some(unit) = &limit.unit
&& !self.units.contains_key(unit)
{
return Err(TixError::Config(format!(
"`{key}.unit` (`{unit}`) is not declared in \
`kanban.{board}.wip.units`"
)));
}
if limit.max_estimate.is_some() && limit.unit.is_none() {
return Err(TixError::Config(format!(
"`{key}.unit` is required with `max_estimate`: it says what the \
ceiling counts"
)));
}
if limit.max_estimate.is_none() && limit.unit.is_some() {
return Err(TixError::Config(format!(
"`{key}.unit` is only meaningful with `max_estimate`"
)));
}
if limit.enabled {
if limit.max_items.is_none() && limit.max_estimate.is_none() {
return Err(TixError::Config(format!(
"`{key}` is enabled but declares neither `max_items` nor \
`max_estimate`"
)));
}
if limit.policy.is_none() {
return Err(TixError::Config(format!(
"`{key}.policy` is required: say whether the limit `warn`s or \
`block`s (C-004)"
)));
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Estimate {
pub value: u32,
pub unit: Option<String>,
}
impl Estimate {
pub fn parse(raw: &str) -> std::result::Result<Self, WipError> {
let text = raw.trim();
let digits: String = text.chars().take_while(char::is_ascii_digit).collect();
let rest = &text[digits.len()..];
if digits.is_empty() || (!rest.is_empty() && !is_unit_name(rest)) {
return Err(WipError::single(WipDiagnostic::new(
WipDiagnosticKind::MalformedEstimate,
format!("`{text}` is not `<number>` or `<number><unit>`"),
)));
}
let value = digits.parse::<u32>().map_err(|_| {
WipError::single(WipDiagnostic::new(
WipDiagnosticKind::MalformedEstimate,
format!("`{text}` is not a number this board can count"),
))
})?;
Ok(Self {
value,
unit: (!rest.is_empty()).then(|| rest.to_string()),
})
}
pub fn render(&self) -> String {
match &self.unit {
Some(unit) => format!("{}{unit}", self.value),
None => self.value.to_string(),
}
}
}
fn is_unit_name(text: &str) -> bool {
let mut chars = text.chars();
let Some(first) = chars.next() else {
return false;
};
first.is_ascii_alphabetic() && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WipDiagnosticKind {
MalformedEstimate,
MissingConversion,
AmbiguousConversion,
UnknownUnit,
OverLimit,
OverrideApplied,
OverrideRequired,
UnconfirmedOverride,
MissingOverrideField,
StaleOverrideLimit,
RejectedTransition,
}
impl WipDiagnosticKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::MalformedEstimate => "malformed_estimate",
Self::MissingConversion => "missing_conversion",
Self::AmbiguousConversion => "ambiguous_conversion",
Self::UnknownUnit => "unknown_unit",
Self::OverLimit => "over_limit",
Self::OverrideApplied => "override_applied",
Self::OverrideRequired => "override_required",
Self::UnconfirmedOverride => "unconfirmed_override",
Self::MissingOverrideField => "missing_override_field",
Self::StaleOverrideLimit => "stale_override_limit",
Self::RejectedTransition => "rejected_transition",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WipDiagnostic {
pub kind: WipDiagnosticKind,
pub message: String,
pub because: Option<String>,
}
impl WipDiagnostic {
pub fn new(kind: WipDiagnosticKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
because: None,
}
}
pub fn because(mut self, because: impl Into<String>) -> Self {
self.because = Some(because.into());
self
}
pub fn render(&self) -> String {
let mut pairs = vec![(self.kind.as_str(), self.message.as_str())];
if let Some(because) = self.because.as_deref() {
pairs.push((self.kind.as_str(), because));
}
crate::error::render_diagnostics("E-tix-wip", pairs)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WipError {
diagnostics: Vec<WipDiagnostic>,
}
impl WipError {
pub fn new(diagnostics: Vec<WipDiagnostic>) -> Self {
Self { diagnostics }
}
pub fn single(diagnostic: WipDiagnostic) -> Self {
Self {
diagnostics: vec![diagnostic],
}
}
pub fn diagnostics(&self) -> &[WipDiagnostic] {
&self.diagnostics
}
pub fn first(&self) -> &WipDiagnostic {
self.diagnostics
.first()
.expect("a WipError carries at least one diagnostic")
}
pub fn because(self, because: impl Into<String>) -> Self {
let mut diagnostics = self.diagnostics;
if let Some(first) = diagnostics.first_mut() {
first.because = Some(because.into());
}
Self { diagnostics }
}
pub fn render(&self) -> String {
self.diagnostics
.iter()
.map(WipDiagnostic::render)
.collect::<Vec<_>>()
.join("\n")
}
}
impl std::fmt::Display for WipError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.render())
}
}
impl std::error::Error for WipError {}
#[cfg(test)]
mod tests {
use super::*;
fn board_columns() -> Vec<String> {
["todo", "in progress", "in review", "done"]
.into_iter()
.map(String::from)
.collect()
}
fn units(pairs: &[(&str, u32)]) -> IndexMap<String, u32> {
pairs
.iter()
.map(|(name, factor)| ((*name).to_string(), *factor))
.collect()
}
fn configured() -> WipConfig {
WipConfig {
units: units(&[("d", 1), ("w", 5)]),
limits: IndexMap::from([(
"in progress".to_string(),
ColumnLimit {
max_items: Some(3),
max_estimate: Some(10),
unit: Some("d".to_string()),
policy: Some(LimitPolicy::Block),
enabled: true,
},
)]),
}
}
#[test]
fn wip_config_absent_limits_are_absent() {
let empty = WipConfig::default();
assert!(empty.is_empty());
assert!(!empty.has_enabled_limits());
assert!(empty.effective_limit("in progress").is_none());
let toml = toml::to_string(&empty).expect("an empty section serializes");
assert_eq!(toml.trim(), "", "an empty section writes no keys: {toml:?}");
assert!(ColumnLimit::default().is_empty());
}
#[test]
fn wip_config_round_trips_through_toml() {
let config = configured();
config.validate("default", &board_columns()).expect("valid");
let text = toml::to_string(&config).expect("serializes");
let back: WipConfig = toml::from_str(&text).expect("deserializes");
assert_eq!(back, config, "round trip: {text}");
assert!(back.has_enabled_limits());
}
#[test]
fn wip_config_omits_defaults_on_save() {
let text = "[limits.\"in progress\"]\nmax_items = 3\npolicy = \"warn\"\n";
let config: WipConfig = toml::from_str(text).expect("loads");
assert!(config.limits["in progress"].enabled, "default on");
assert_eq!(toml::to_string(&config).expect("saves"), text);
}
#[test]
fn wip_config_rejects_an_unknown_column() {
let mut config = configured();
config.limits.insert(
"shipped".to_string(),
ColumnLimit::items(1, LimitPolicy::Warn),
);
let error = config
.validate("default", &board_columns())
.expect_err("an unknown column is refused")
.to_string();
assert!(
error.contains("kanban.default.wip.limits.`shipped`"),
"{error}"
);
assert!(error.contains("columns"), "{error}");
}
#[test]
fn wip_config_rejects_an_enabled_limit_that_limits_nothing() {
let mut config = configured();
config.limits.insert(
"in review".to_string(),
ColumnLimit {
policy: Some(LimitPolicy::Warn),
..ColumnLimit::default()
},
);
let error = config
.validate("default", &board_columns())
.expect_err("an enabled empty limit is refused")
.to_string();
assert!(
error.contains("neither `max_items` nor `max_estimate`"),
"{error}"
);
}
#[test]
fn wip_config_requires_a_policy_and_a_unit() {
let mut missing_policy = configured();
missing_policy.limits["in progress"].policy = None;
let error = missing_policy
.validate("default", &board_columns())
.expect_err("a policy is required")
.to_string();
assert!(error.contains("policy` is required"), "{error}");
let mut missing_unit = configured();
missing_unit.limits["in progress"].unit = None;
let error = missing_unit
.validate("default", &board_columns())
.expect_err("a unit is required with max_estimate")
.to_string();
assert!(error.contains("is required with `max_estimate`"), "{error}");
let mut unit_without_estimate = configured();
unit_without_estimate.limits["in progress"].max_estimate = None;
let error = unit_without_estimate
.validate("default", &board_columns())
.expect_err("a unit without an estimate is refused")
.to_string();
assert!(
error.contains("only meaningful with `max_estimate`"),
"{error}"
);
}
#[test]
fn wip_config_rejects_undeclared_units_and_zero_factors() {
let mut undeclared = configured();
undeclared.limits["in progress"].unit = Some("q".to_string());
let error = undeclared
.validate("default", &board_columns())
.expect_err("an undeclared unit is refused")
.to_string();
assert!(error.contains("`q`"), "{error}");
assert!(error.contains("wip.units"), "{error}");
let mut zero = configured();
zero.units.insert("z".to_string(), 0);
let error = zero
.validate("default", &board_columns())
.expect_err("a zero factor is refused")
.to_string();
assert!(error.contains("wip.units.z"), "{error}");
}
#[test]
fn wip_config_zero_ceilings_are_refused() {
for key in ["max_items", "max_estimate"] {
let mut config = configured();
let limit = config.limits.get_mut("in progress").expect("the limit");
match key {
"max_items" => limit.max_items = Some(0),
_ => limit.max_estimate = Some(0),
}
let error = config
.validate("default", &board_columns())
.expect_err("a zero ceiling is refused")
.to_string();
assert!(error.contains(key), "{error}");
assert!(error.contains("at least 1"), "{error}");
}
}
#[test]
fn wip_config_disabled_limit_is_validated_but_not_effective() {
let mut config = configured();
config.limits["in progress"].enabled = false;
config.limits["in progress"].policy = None;
config
.validate("default", &board_columns())
.expect("a disabled limit may state no policy");
assert!(config.effective_limit("in progress").is_none());
assert!(!config.has_enabled_limits());
assert!(
config.limits.contains_key("in progress"),
"the numbers stay for later"
);
}
#[test]
fn wip_config_estimate_parsing_is_strict_but_total() {
assert_eq!(
Estimate::parse(" 3d ").expect("a unit"),
Estimate {
value: 3,
unit: Some("d".to_string())
}
);
assert_eq!(
Estimate::parse("12").expect("a bare number"),
Estimate {
value: 12,
unit: None
}
);
assert_eq!(
Estimate::parse("5story-points").expect("a hyphenated unit"),
Estimate {
value: 5,
unit: Some("story-points".to_string())
}
);
for bad in [
"",
"twenty",
"3 d",
"3.5",
"-2",
"d3",
"3d!",
"99999999999999",
] {
let error = Estimate::parse(bad).expect_err("refused: {bad}");
assert_eq!(error.first().kind, WipDiagnosticKind::MalformedEstimate);
}
}
#[test]
fn wip_config_converts_between_declared_units() {
let config = configured();
let weeks = Estimate {
value: 1,
unit: Some("w".to_string()),
};
assert_eq!(config.convert(&weeks, "d").expect("1w is 5d"), 5);
assert_eq!(
config
.convert(&Estimate::parse("10d").expect("parses"), "d")
.expect("same unit"),
10
);
assert_eq!(
config
.convert(&Estimate::parse("7").expect("parses"), "w")
.expect("bare means the target unit"),
7
);
let half_weeks = Estimate {
value: 5,
unit: Some("w".to_string()),
};
assert_eq!(
config.convert(&half_weeks, "w").expect("same unit"),
5,
"5w is 5w"
);
assert_eq!(config.convert(&half_weeks, "d").expect("25 points"), 25);
}
#[test]
fn wip_config_refuses_a_missing_conversion() {
let config = configured();
let points = Estimate {
value: 3,
unit: Some("p".to_string()),
};
let error = config.convert(&points, "d").expect_err("undeclared unit");
assert_eq!(error.first().kind, WipDiagnosticKind::MissingConversion);
let rendered = error.render();
assert!(rendered.contains("missing_conversion"), "{rendered}");
assert!(rendered.contains("`3p`"), "{rendered}");
let error = config
.convert(&Estimate::parse("3").expect("parses"), "q")
.expect_err("an undeclared target unit");
assert_eq!(error.first().kind, WipDiagnosticKind::UnknownUnit);
}
#[test]
fn wip_config_refuses_a_fractional_conversion() {
let config = WipConfig {
units: units(&[("h", 1), ("d", 8), ("w", 5)]),
limits: IndexMap::new(),
};
let error = config
.convert(&Estimate::parse("3d").expect("parses"), "w")
.expect_err("24 is not a whole number of 5");
assert_eq!(error.first().kind, WipDiagnosticKind::AmbiguousConversion);
let rendered = error.render();
assert!(rendered.contains("ambiguous_conversion"), "{rendered}");
assert!(rendered.contains("24 base points"), "{rendered}");
assert_eq!(
config
.convert(&Estimate::parse("3d").expect("parses"), "h")
.expect("24h"),
24
);
}
#[test]
fn wip_config_policy_spellings_are_stable() {
assert_eq!(LimitPolicy::Warn.as_str(), "warn");
assert_eq!(LimitPolicy::Block.as_str(), "block");
let limit = configured().limits["in progress"].clone();
assert_eq!(limit.resolved_policy(), Some(LimitPolicy::Block));
let disabled = ColumnLimit {
enabled: false,
..limit
};
assert_eq!(disabled.resolved_policy(), None);
}
}