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 convert_terms(
&self,
estimates: &[Estimate],
target: &str,
) -> std::result::Result<u32, WipError> {
if estimates.is_empty() {
return Err(Estimate::malformed(
"an estimate must state at least one term".to_string(),
));
}
let mut total: u64 = 0;
for estimate in estimates {
total += u64::from(self.convert(estimate, target)?);
if total > u64::from(u32::MAX) {
return Err(Estimate::malformed(format!(
"the estimate terms sum past what this board can count in `{target}`"
)));
}
}
Ok(total 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 mut terms = Self::parse_terms(text)?;
if terms.len() != 1 {
return Err(Self::malformed(format!(
"`{text}` states more than one estimate term"
)));
}
Ok(terms.remove(0))
}
pub fn parse_terms(raw: &str) -> std::result::Result<Vec<Self>, WipError> {
let text = raw.trim();
match Self::parse_terms_strict(text) {
Ok(terms) => Ok(terms),
Err(whole_error) => {
if !text.contains(',') {
return Err(whole_error);
}
let mut terms = Vec::new();
for segment in text.split(',') {
if let Ok(mut parsed) = Self::parse_terms_strict(segment.trim()) {
terms.append(&mut parsed);
}
}
if terms.is_empty() {
return Err(whole_error);
}
if terms.len() > 1 && terms.iter().any(|term| term.unit.is_none()) {
return Err(Self::malformed(format!(
"`{text}` mixes a bare number with unit terms"
)));
}
Ok(terms)
}
}
}
fn parse_terms_strict(text: &str) -> std::result::Result<Vec<Self>, WipError> {
let mut rest = text;
let mut terms = Vec::new();
while !rest.is_empty() {
rest = rest.trim_start_matches(|c: char| c.is_whitespace() || c == ',');
if rest.is_empty() {
break;
}
let (term, tail) = Self::parse_term(text, rest)?;
terms.push(term);
rest = tail;
}
if terms.is_empty() {
return Err(Self::malformed(format!(
"`{text}` is not `<number>` or `<number><unit>`"
)));
}
if terms.len() > 1 && terms.iter().any(|term| term.unit.is_none()) {
return Err(Self::malformed(format!(
"`{text}` mixes a bare number with unit terms"
)));
}
Ok(terms)
}
fn parse_term<'a>(text: &str, rest: &'a str) -> std::result::Result<(Self, &'a str), WipError> {
let (low, mut tail) = Self::parse_decimal(rest).ok_or_else(|| {
Self::malformed(format!(
"`{text}` is not a duration such as `<number><unit>` or `<low>-<high><unit>`"
))
})?;
tail = tail.trim_start_matches([' ', '\t']);
let mut high = None;
if let Some(after_dash) = tail.strip_prefix('-') {
let after_dash = after_dash.trim_start_matches([' ', '\t']);
let (value, after_high) = Self::parse_decimal(after_dash).ok_or_else(|| {
Self::malformed(format!("`{text}` starts a range but has no upper bound"))
})?;
high = Some(value);
tail = after_high.trim_start_matches([' ', '\t']);
}
let unit: String = tail
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect();
if unit.is_empty() {
if tail.is_empty() || tail.starts_with(',') {
let value = Self::pessimistic(low, high, text)?;
return Ok((Self { value, unit: None }, tail));
}
return Err(Self::malformed(format!(
"`{text}` is not `<number>` or `<number><unit>`"
)));
}
if !is_unit_name(&unit) {
return Err(Self::malformed(format!(
"`{text}` has an invalid unit `{unit}`"
)));
}
let value = Self::pessimistic(low, high, text)?;
Ok((
Self {
value,
unit: Some(canonical_unit_word(&unit)),
},
&tail[unit.len()..],
))
}
fn parse_decimal(rest: &str) -> Option<(f64, &str)> {
let bytes = rest.as_bytes();
let mut end = 0;
while end < bytes.len() && bytes[end].is_ascii_digit() {
end += 1;
}
if end == 0 {
return None;
}
if end < bytes.len() && bytes[end] == b'.' {
let fraction_start = end + 1;
let mut fraction_end = fraction_start;
while fraction_end < bytes.len() && bytes[fraction_end].is_ascii_digit() {
fraction_end += 1;
}
if fraction_end == fraction_start {
return None;
}
end = fraction_end;
}
let value = rest.get(..end)?.parse::<f64>().ok()?;
value.is_finite().then_some((value, &rest[end..]))
}
fn pessimistic(low: f64, high: Option<f64>, text: &str) -> std::result::Result<u32, WipError> {
let value = high.map_or(low, |upper| low.max(upper)).ceil();
if value > f64::from(u32::MAX) {
return Err(Self::malformed(format!(
"`{text}` is not a number this board can count"
)));
}
Ok(value as u32)
}
fn malformed(message: String) -> WipError {
WipError::single(WipDiagnostic::new(
WipDiagnosticKind::MalformedEstimate,
message,
))
}
pub fn render(&self) -> String {
match &self.unit {
Some(unit) => format!("{}{unit}", self.value),
None => self.value.to_string(),
}
}
}
fn canonical_unit_word(unit: &str) -> String {
if unit.chars().count() == 1 {
return unit.to_string();
}
match unit.to_ascii_lowercase().as_str() {
"day" | "days" => "d",
"week" | "weeks" | "wk" | "wks" => "w",
"hour" | "hours" | "hr" | "hrs" => "h",
"minute" | "minutes" | "min" | "mins" => "m",
"second" | "seconds" | "sec" | "secs" => "s",
_ => unit,
}
.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 {
#[allow(clippy::wildcard_imports)]
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())
}
);
assert_eq!(
Estimate::parse("1 day").expect("a prose unit"),
Estimate {
value: 1,
unit: Some("d".to_string())
}
);
assert_eq!(
Estimate::parse("2 DAYS").expect("case-insensitive prose"),
Estimate {
value: 2,
unit: Some("d".to_string())
}
);
assert_eq!(
Estimate::parse("3S").expect("a one-letter unit keeps its case"),
Estimate {
value: 3,
unit: Some("S".to_string())
}
);
assert_eq!(
Estimate::parse("3.5").expect("a fraction rounds up"),
Estimate {
value: 4,
unit: None
}
);
for bad in [
"",
"twenty",
"-2",
"d3",
"3d!",
"3 2h",
"1 day 2",
"99999999999999",
] {
let error = Estimate::parse(bad).expect_err("refused: {bad}");
assert_eq!(error.first().kind, WipDiagnosticKind::MalformedEstimate);
}
}
#[test]
fn wip_config_estimate_parses_compound_durations_term_by_term() {
let terms = Estimate::parse_terms("3 weeks 2 hours").expect("two terms");
assert_eq!(
terms,
vec![
Estimate {
value: 3,
unit: Some("w".to_string())
},
Estimate {
value: 2,
unit: Some("h".to_string())
},
]
);
assert_eq!(
Estimate::parse_terms("1d, 30 min").expect("comma-separated"),
vec![
Estimate {
value: 1,
unit: Some("d".to_string())
},
Estimate {
value: 30,
unit: Some("m".to_string())
},
]
);
assert!(
Estimate::parse("3 weeks 2 hours").is_err(),
"the single-term entry point still names one term"
);
}
#[test]
fn wip_config_estimate_ranges_are_pessimistic() {
assert_eq!(
Estimate::parse("0.5-1 day").expect("a range"),
Estimate {
value: 1,
unit: Some("d".to_string())
}
);
assert_eq!(
Estimate::parse("0.5 day").expect("a fraction rounds up"),
Estimate {
value: 1,
unit: Some("d".to_string())
}
);
assert_eq!(
Estimate::parse("2.1-2.2d").expect("the upper bound, rounded up"),
Estimate {
value: 3,
unit: Some("d".to_string())
}
);
assert_eq!(
Estimate::parse("2-1 day").expect("the larger bound wins"),
Estimate {
value: 2,
unit: Some("d".to_string())
}
);
}
#[test]
fn wip_config_estimate_ignores_unreadable_comma_annotations() {
assert_eq!(
Estimate::parse_terms("~300 LOC, 0.5-1 day").expect("the estimate segment"),
vec![Estimate {
value: 1,
unit: Some("d".to_string())
}]
);
assert_eq!(
Estimate::parse_terms("3d, best guess").expect("the first segment"),
vec![Estimate {
value: 3,
unit: Some("d".to_string())
}]
);
assert!(
Estimate::parse_terms("~300 LOC").is_err(),
"no readable segment means malformed"
);
}
#[test]
fn wip_config_sums_compound_estimates_through_the_board_units() {
let config = WipConfig {
units: units(&[("h", 1), ("d", 8), ("w", 40)]),
limits: IndexMap::new(),
};
let terms = Estimate::parse_terms("3 weeks 2 hours").expect("parses");
assert_eq!(
config.convert_terms(&terms, "h").expect("exact"),
3 * 5 * 8 + 2,
"a prose estimate is board-relative, never a clock-ratio guess"
);
let terms = Estimate::parse_terms("1 day 30 fortnights").expect("parses");
let error = config
.convert_terms(&terms, "h")
.expect_err("the second unit is undeclared");
assert_eq!(error.first().kind, WipDiagnosticKind::MissingConversion);
}
#[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", 40)]),
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);
}
}