Skip to main content

tix/wip/
config.rs

1//! The `[kanban.<name>.wip]` schema: per-column limits, estimate units, and the
2//! arithmetic between them (mission `configurable-wip-limits-01M3C6WX`, WP01).
3//!
4//! # The shape, and why it looks like this
5//!
6//! ```toml
7//! [kanban.default.wip.units]
8//! d = 1      # a `d` is one base point
9//! w = 5      # a `w` is five
10//!
11//! [kanban.default.wip.limits."in progress"]
12//! max_items = 3
13//! max_estimate = 10
14//! unit = "d"       # what `max_estimate` counts in
15//! policy = "block" # `warn` or `block` — required, never assumed
16//! enabled = true   # default; `false` keeps the numbers but enforces nothing
17//! ```
18//!
19//! Three decisions are worth stating, because each one is a door that stays shut:
20//!
21//! 1. **The column is the key.** A limit cannot name a column that does not exist
22//!    and cannot be declared twice; validation then checks the key against the
23//!    board's own [`columns`](crate::config::BoardConfiguration::columns).
24//! 2. **`policy` is required, not defaulted.** "Warning or blocking behaviour by
25//!    configuration" (C-004) is only true if the choice is written down; a limit
26//!    that silently warned would be a policy nobody configured. The field is
27//!    `Option<LimitPolicy>` so "absent" is distinguishable from "warn" and can be
28//!    refused by name.
29//! 3. **Units are declarative and the arithmetic is exact.** A board declares named
30//!    units and their factor to its base unit; an issue's free-form `estimate`
31//!    (`3d`, or a bare `3` which is already in the limit's unit) is converted by
32//!    `value × factor(from) ÷ factor(to)`. A unit the board never declared is a
33//!    `missing_conversion`, and a conversion that does not divide exactly is an
34//!    `ambiguous_conversion` — never a rounded or defaulted count (C-003).
35//!
36//!    The text may also be a **human duration**: `1 day`, `2 days`,
37//!    `3 weeks 2 hours`, `2w`, `1d 30 min` (tix #1724). Each term is converted
38//!    through the board's own units and the converted terms are summed, so a
39//!    prose estimate never invents a clock ratio the board did not declare:
40//!    `3 weeks 2 hours` on a board with `w = 5` and `h = 1` is `17`, not
41//!    `506` (C-003 still holds).
42//!
43//! # What this module does not own
44//!
45//! The status categories and the completion semantics stay
46//! [`crate::workflow`]'s (C-002): nothing here reads a category, decides what
47//! "done" means, or interprets `done_column`. Counting issues, deciding a move, and
48//! writing an override are the later work packages; this one owns what a limit *is*.
49
50use indexmap::IndexMap;
51use serde::{Deserialize, Serialize};
52
53use crate::error::TixError;
54
55/// Whether a limit warns or blocks (C-004).
56///
57/// There is no third behaviour and no "silent": a limit that is not enforced is
58/// [`enabled = false`](ColumnLimit::enabled), which is a different statement from
59/// "warn" and reads as one.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum LimitPolicy {
63    /// The move proceeds and the violation is reported.
64    Warn,
65    /// The move is refused unless an authorized override carries it.
66    Block,
67}
68
69impl LimitPolicy {
70    /// The stable spelling every surface prints and `tix.toml` stores.
71    pub const fn as_str(self) -> &'static str {
72        match self {
73            Self::Warn => "warn",
74            Self::Block => "block",
75        }
76    }
77}
78
79/// The `enabled` default: declaring a limit means applying it.
80const fn enabled_default() -> bool {
81    true
82}
83
84/// `serde`'s `skip_serializing_if` takes a `&T` predicate, so the reference is the
85/// signature the attribute needs rather than a choice.
86#[allow(clippy::trivially_copy_pass_by_ref)]
87fn is_enabled_default(enabled: &bool) -> bool {
88    *enabled
89}
90
91/// One column's limit.
92///
93/// Both ceilings are optional and independent: a column may cap the *number* of
94/// cards, the *estimate* they add up to, or both. An enabled limit with neither is
95/// refused, because a limit that limits nothing is a configuration mistake that
96/// would otherwise read as an enforced rule.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(default, deny_unknown_fields)]
99pub struct ColumnLimit {
100    /// The most cards the column may hold.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub max_items: Option<u32>,
103    /// The most estimate the column's cards may add up to, counted in [`unit`].
104    ///
105    /// [`unit`]: ColumnLimit::unit
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub max_estimate: Option<u32>,
108    /// The unit `max_estimate` counts in. Required with `max_estimate`, refused
109    /// without it: a unit on an item-count limit would describe nothing.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub unit: Option<String>,
112    /// `warn` or `block`. Required for an enabled limit (see the module docs).
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub policy: Option<LimitPolicy>,
115    /// Whether the limit is enforced. `false` keeps the numbers for later without
116    /// changing any behaviour today.
117    #[serde(
118        default = "enabled_default",
119        skip_serializing_if = "is_enabled_default"
120    )]
121    pub enabled: bool,
122}
123
124impl Default for ColumnLimit {
125    fn default() -> Self {
126        Self {
127            max_items: None,
128            max_estimate: None,
129            unit: None,
130            policy: None,
131            enabled: true,
132        }
133    }
134}
135
136impl ColumnLimit {
137    /// A limit capping the column at `max_items` cards, with `policy`.
138    pub fn items(max_items: u32, policy: LimitPolicy) -> Self {
139        Self {
140            max_items: Some(max_items),
141            policy: Some(policy),
142            ..Self::default()
143        }
144    }
145
146    /// A limit capping the column at `max_estimate` `unit`s, with `policy`.
147    pub fn estimate(max_estimate: u32, unit: impl Into<String>, policy: LimitPolicy) -> Self {
148        Self {
149            max_estimate: Some(max_estimate),
150            unit: Some(unit.into()),
151            policy: Some(policy),
152            ..Self::default()
153        }
154    }
155
156    /// Whether nothing at all is declared: the state the schema omits on save.
157    pub fn is_empty(&self) -> bool {
158        self.max_items.is_none()
159            && self.max_estimate.is_none()
160            && self.unit.is_none()
161            && self.policy.is_none()
162            && self.enabled == enabled_default()
163    }
164
165    /// The policy this limit enforces, when it is enforced at all.
166    ///
167    /// `None` for a disabled limit *and* for an enabled one that forgot to state a
168    /// policy — validation refuses the latter, so a caller that has validated a
169    /// config and asked an **enabled** limit for its policy always gets one.
170    pub fn resolved_policy(&self) -> Option<LimitPolicy> {
171        self.enabled.then_some(self.policy).flatten()
172    }
173}
174
175/// A board's work-in-progress configuration (`[kanban.<name>.wip]`).
176///
177/// Omitted **whole** when nothing is declared (`limits` and `units` both empty), so
178/// a board that configures no limits gains no key on save — the same rule every
179/// other board field follows (#1415).
180#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(default, deny_unknown_fields)]
182pub struct WipConfig {
183    /// Named estimate units, `name -> factor` to the board's base unit, in
184    /// declaration order.
185    #[serde(skip_serializing_if = "IndexMap::is_empty")]
186    pub units: IndexMap<String, u32>,
187    /// `<column> -> limit`, in declaration order.
188    #[serde(skip_serializing_if = "IndexMap::is_empty")]
189    pub limits: IndexMap<String, ColumnLimit>,
190}
191
192impl WipConfig {
193    /// The limits, in declaration order.
194    pub fn limits(&self) -> impl Iterator<Item = (&str, &ColumnLimit)> {
195        self.limits
196            .iter()
197            .map(|(column, limit)| (column.as_str(), limit))
198    }
199
200    /// Whether no limit and no unit is declared: the state that writes nothing.
201    pub fn is_empty(&self) -> bool {
202        self.units.is_empty() && self.limits.is_empty()
203    }
204
205    /// Whether any limit is actually enforced.
206    pub fn has_enabled_limits(&self) -> bool {
207        self.limits.values().any(|limit| limit.enabled)
208    }
209
210    /// The limit **in force** for `column`, if any (T004).
211    ///
212    /// This is the resolver every surface asks — not the raw map — so "declared but
213    /// disabled" cannot be mistaken for "enforced" by one caller and not another.
214    /// It decides nothing else: what a violation *means* is the policy layer's, and
215    /// what a column *is* stays [`crate::workflow`]'s (C-002).
216    pub fn effective_limit(&self, column: &str) -> Option<&ColumnLimit> {
217        self.limits
218            .get(column)
219            .filter(|limit| limit.enabled && !limit.is_empty())
220    }
221
222    /// The conversion factor of `unit`, if the board declares it.
223    pub fn factor(&self, unit: &str) -> Option<u32> {
224        self.units.get(unit).copied()
225    }
226
227    /// Convert `estimate` into `target`, using the declared factors (T002).
228    ///
229    /// A bare estimate is **already** in `target` — the limit names the unit, so
230    /// there is nothing to infer. A named one must be declared by the board, and the
231    /// arithmetic must divide exactly: a fractional result is refused rather than
232    /// rounded, because a count that depends on rounding is a count nobody can
233    /// predict.
234    pub fn convert(&self, estimate: &Estimate, target: &str) -> std::result::Result<u32, WipError> {
235        let target_factor = self.factor(target).ok_or_else(|| {
236            WipError::single(WipDiagnostic::new(
237                WipDiagnosticKind::UnknownUnit,
238                format!("`{target}` is not a declared estimate unit"),
239            ))
240            .because("declare the unit under `[kanban.<board>.wip.units]`")
241        })?;
242        let Some(unit) = estimate.unit.as_deref() else {
243            // The same unit: nothing to convert, and nothing assumed.
244            return Ok(estimate.value);
245        };
246        if unit == target {
247            return Ok(estimate.value);
248        }
249        let from_factor = self.factor(unit).ok_or_else(|| {
250            WipError::single(WipDiagnostic::new(
251                WipDiagnosticKind::MissingConversion,
252                format!(
253                    "`{}` uses estimate unit `{unit}`, which this board does not declare",
254                    estimate.render()
255                ),
256            ))
257            .because("a missing conversion is an error, never a zero or a guess (C-003)")
258        })?;
259        let scaled = (estimate.value as u64) * (from_factor as u64);
260        let target_factor = target_factor as u64;
261        if scaled % target_factor != 0 {
262            return Err(WipError::single(WipDiagnostic::new(
263                WipDiagnosticKind::AmbiguousConversion,
264                format!(
265                    "`{}` is {scaled} base points, which is not a whole number of `{target}` \
266                     (a `{target}` is {target_factor})",
267                    estimate.render()
268                ),
269            ))
270            .because(
271                "declare a unit the arithmetic divides evenly into, or rewrite the estimate",
272            ));
273        }
274        Ok((scaled / target_factor) as u32)
275    }
276
277    /// Convert every term of a parsed estimate and sum them (tix #1724).
278    ///
279    /// This is the only way a compound duration becomes one number. Each term keeps
280    /// its own declared unit, so `3 weeks 2 hours` is `3 × w + 2 × h` in the target
281    /// — never a clock-ratio guess. One unconvertible term refuses the whole sum and
282    /// names itself, because a partial total would silently undercount.
283    pub fn convert_terms(
284        &self,
285        estimates: &[Estimate],
286        target: &str,
287    ) -> std::result::Result<u32, WipError> {
288        if estimates.is_empty() {
289            return Err(Estimate::malformed(
290                "an estimate must state at least one term".to_string(),
291            ));
292        }
293        let mut total: u64 = 0;
294        for estimate in estimates {
295            total += u64::from(self.convert(estimate, target)?);
296            if total > u64::from(u32::MAX) {
297                return Err(Estimate::malformed(format!(
298                    "the estimate terms sum past what this board can count in `{target}`"
299                )));
300            }
301        }
302        Ok(total as u32)
303    }
304
305    /// Validate the schema against the board it belongs to (T001, T003).
306    ///
307    /// Every finding is a `tix.toml` error naming the exact key, in the same shape
308    /// the neighbouring board checks use. A **disabled** limit is still validated:
309    /// it is configuration either way, and a limit aimed at a column that does not
310    /// exist is a mistake whether or not it is switched on.
311    pub fn validate(&self, board: &str, columns: &[String]) -> std::result::Result<(), TixError> {
312        for (name, factor) in &self.units {
313            if name.trim().is_empty() {
314                return Err(TixError::Config(format!(
315                    "`kanban.{board}.wip.units` must not contain a blank unit name"
316                )));
317            }
318            if *factor == 0 {
319                return Err(TixError::Config(format!(
320                    "`kanban.{board}.wip.units.{name}` must be at least 1"
321                )));
322            }
323        }
324        for (column, limit) in &self.limits {
325            let key = format!("kanban.{board}.wip.limits.`{column}`");
326            if !columns.contains(column) {
327                return Err(TixError::Config(format!(
328                    "`{key}` names a column that is not in `kanban.{board}.columns`"
329                )));
330            }
331            if limit.max_items == Some(0) {
332                return Err(TixError::Config(format!(
333                    "`{key}.max_items` must be at least 1"
334                )));
335            }
336            if limit.max_estimate == Some(0) {
337                return Err(TixError::Config(format!(
338                    "`{key}.max_estimate` must be at least 1"
339                )));
340            }
341            if let Some(unit) = &limit.unit
342                && !self.units.contains_key(unit)
343            {
344                return Err(TixError::Config(format!(
345                    "`{key}.unit` (`{unit}`) is not declared in \
346                     `kanban.{board}.wip.units`"
347                )));
348            }
349            if limit.max_estimate.is_some() && limit.unit.is_none() {
350                return Err(TixError::Config(format!(
351                    "`{key}.unit` is required with `max_estimate`: it says what the \
352                     ceiling counts"
353                )));
354            }
355            if limit.max_estimate.is_none() && limit.unit.is_some() {
356                return Err(TixError::Config(format!(
357                    "`{key}.unit` is only meaningful with `max_estimate`"
358                )));
359            }
360            if limit.enabled {
361                if limit.max_items.is_none() && limit.max_estimate.is_none() {
362                    return Err(TixError::Config(format!(
363                        "`{key}` is enabled but declares neither `max_items` nor \
364                         `max_estimate`"
365                    )));
366                }
367                if limit.policy.is_none() {
368                    return Err(TixError::Config(format!(
369                        "`{key}.policy` is required: say whether the limit `warn`s or \
370                         `block`s (C-004)"
371                    )));
372                }
373            }
374        }
375        Ok(())
376    }
377}
378
379/// An estimate as an issue writes it: a number, and optionally the unit it is in.
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct Estimate {
382    /// The number the issue states.
383    pub value: u32,
384    /// The unit it is stated in, or `None` when it is already in the limit's unit.
385    pub unit: Option<String>,
386}
387
388impl Estimate {
389    /// Parse one `<number>[<unit>]` term of the free-form `estimate:` frontmatter.
390    ///
391    /// A unit starts with an ASCII letter and continues with letters, digits, `_` or
392    /// `-`; whitespace between the number and the unit is accepted (`1 day`). Human
393    /// duration words are folded to the board's conventional symbols (`day`/`days`
394    /// → `d`, `week`/`weeks` → `w`, `hour`/`hours` → `h`, `minute`/`minutes` → `m`,
395    /// `second`/`seconds` → `s`); a single-letter unit is left exactly as written, so
396    /// a board's own `S` or `p` keeps its meaning. Anything else is refused with the
397    /// value quoted back, because an estimate nobody can count is worse than none.
398    ///
399    /// Use [`Estimate::parse_terms`] for a compound duration (`3 weeks 2 hours`).
400    pub fn parse(raw: &str) -> std::result::Result<Self, WipError> {
401        let text = raw.trim();
402        let mut terms = Self::parse_terms(text)?;
403        if terms.len() != 1 {
404            return Err(Self::malformed(format!(
405                "`{text}` states more than one estimate term"
406            )));
407        }
408        Ok(terms.remove(0))
409    }
410
411    /// Parse a free-form estimate into its terms, in the order written (tix #1724).
412    ///
413    /// Accepted: `<number>` (bare, already in the target unit), `<number><unit>`,
414    /// decimal numbers and ranges (`0.5 day`, `0.5-1 day`), and one or more
415    /// whitespace/comma-separated terms such as `3 weeks 2 hours`. Each term is read
416    /// on its own; nothing is summed here, because the sum is only meaningful after
417    /// every term has been converted through the board's declared units
418    /// ([`WipConfig::convert_terms`]).
419    ///
420    /// # Pessimistic ranges and fractions
421    ///
422    /// A fraction and a range are rounded **up** to a whole number in their own
423    /// unit, and a range takes its **upper** bound: `0.5 day` is `1d`, and
424    /// `0.5-1 day` is `1d`. That is the pessimistic reading the tracker's limits
425    /// and capacity calculations count, and it keeps every value a `u32`.
426    ///
427    /// # Text around the estimate
428    ///
429    /// A field that holds several comma-separated annotations keeps the segments
430    /// that parse and ignores the ones that do not, so `~300 LOC, 0.5-1 day` reads
431    /// as `1d`. If no segment parses, the whole value is malformed. A bare number in
432    /// a compound statement is still refused (`3 2h` has no unit for the `3`), as
433    /// are `-2`, `twenty`, `3d!` and a number that does not fit a `u32`.
434    ///
435    /// No duration crate is used here by design: `humantime` and `duration-str`
436    /// return a single `Duration`, discarding the per-term units that must each go
437    /// through the board's own conversion table. Folding the text to seconds would
438    /// also invent the clock ratios C-003 forbids.
439    pub fn parse_terms(raw: &str) -> std::result::Result<Vec<Self>, WipError> {
440        let text = raw.trim();
441        match Self::parse_terms_strict(text) {
442            Ok(terms) => Ok(terms),
443            Err(whole_error) => {
444                // A comma is the one separator that may introduce a non-estimate
445                // annotation (`~300 LOC, 0.5-1 day`). Keep every segment that parses
446                // on its own and drop the rest; if none parses, the value is
447                // malformed as a whole.
448                if !text.contains(',') {
449                    return Err(whole_error);
450                }
451                let mut terms = Vec::new();
452                for segment in text.split(',') {
453                    if let Ok(mut parsed) = Self::parse_terms_strict(segment.trim()) {
454                        terms.append(&mut parsed);
455                    }
456                }
457                if terms.is_empty() {
458                    return Err(whole_error);
459                }
460                if terms.len() > 1 && terms.iter().any(|term| term.unit.is_none()) {
461                    return Err(Self::malformed(format!(
462                        "`{text}` mixes a bare number with unit terms"
463                    )));
464                }
465                Ok(terms)
466            }
467        }
468    }
469
470    /// Parse every term in one annotation. Comma-separated terms are part of the
471    /// annotation (`1d, 30 min` is two terms); a comma that introduces words is what
472    /// makes the whole annotation fail, and [`parse_terms`](Self::parse_terms) then
473    /// falls back to per-segment reading.
474    fn parse_terms_strict(text: &str) -> std::result::Result<Vec<Self>, WipError> {
475        let mut rest = text;
476        let mut terms = Vec::new();
477        while !rest.is_empty() {
478            rest = rest.trim_start_matches(|c: char| c.is_whitespace() || c == ',');
479            if rest.is_empty() {
480                break;
481            }
482            let (term, tail) = Self::parse_term(text, rest)?;
483            terms.push(term);
484            rest = tail;
485        }
486        if terms.is_empty() {
487            return Err(Self::malformed(format!(
488                "`{text}` is not `<number>` or `<number><unit>`"
489            )));
490        }
491        if terms.len() > 1 && terms.iter().any(|term| term.unit.is_none()) {
492            return Err(Self::malformed(format!(
493                "`{text}` mixes a bare number with unit terms"
494            )));
495        }
496        Ok(terms)
497    }
498
499    /// Read one `<number>[-<number>][<unit>]` term and return the untouched tail.
500    fn parse_term<'a>(text: &str, rest: &'a str) -> std::result::Result<(Self, &'a str), WipError> {
501        let (low, mut tail) = Self::parse_decimal(rest).ok_or_else(|| {
502            Self::malformed(format!(
503                "`{text}` is not a duration such as `<number><unit>` or `<low>-<high><unit>`"
504            ))
505        })?;
506        tail = tail.trim_start_matches([' ', '\t']);
507        let mut high = None;
508        if let Some(after_dash) = tail.strip_prefix('-') {
509            let after_dash = after_dash.trim_start_matches([' ', '\t']);
510            let (value, after_high) = Self::parse_decimal(after_dash).ok_or_else(|| {
511                Self::malformed(format!("`{text}` starts a range but has no upper bound"))
512            })?;
513            high = Some(value);
514            tail = after_high.trim_start_matches([' ', '\t']);
515        }
516        let unit: String = tail
517            .chars()
518            .take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
519            .collect();
520        if unit.is_empty() {
521            // A bare term is only meaningful when it ends the annotation; a
522            // trailing `.`, `!` or another term is refused by the next iteration
523            // (or by the compound check).
524            if tail.is_empty() || tail.starts_with(',') {
525                let value = Self::pessimistic(low, high, text)?;
526                return Ok((Self { value, unit: None }, tail));
527            }
528            return Err(Self::malformed(format!(
529                "`{text}` is not `<number>` or `<number><unit>`"
530            )));
531        }
532        if !is_unit_name(&unit) {
533            return Err(Self::malformed(format!(
534                "`{text}` has an invalid unit `{unit}`"
535            )));
536        }
537        let value = Self::pessimistic(low, high, text)?;
538        Ok((
539            Self {
540                value,
541                unit: Some(canonical_unit_word(&unit)),
542            },
543            &tail[unit.len()..],
544        ))
545    }
546
547    /// Read a decimal number and return it with the rest of the string.
548    fn parse_decimal(rest: &str) -> Option<(f64, &str)> {
549        let bytes = rest.as_bytes();
550        let mut end = 0;
551        while end < bytes.len() && bytes[end].is_ascii_digit() {
552            end += 1;
553        }
554        if end == 0 {
555            return None;
556        }
557        if end < bytes.len() && bytes[end] == b'.' {
558            let fraction_start = end + 1;
559            let mut fraction_end = fraction_start;
560            while fraction_end < bytes.len() && bytes[fraction_end].is_ascii_digit() {
561                fraction_end += 1;
562            }
563            if fraction_end == fraction_start {
564                return None;
565            }
566            end = fraction_end;
567        }
568        let value = rest.get(..end)?.parse::<f64>().ok()?;
569        value.is_finite().then_some((value, &rest[end..]))
570    }
571
572    /// The pessimistic whole number for a value or range: the upper bound, rounded
573    /// up. Refuses a number no board can count.
574    fn pessimistic(low: f64, high: Option<f64>, text: &str) -> std::result::Result<u32, WipError> {
575        let value = high.map_or(low, |upper| low.max(upper)).ceil();
576        if value > f64::from(u32::MAX) {
577            return Err(Self::malformed(format!(
578                "`{text}` is not a number this board can count"
579            )));
580        }
581        Ok(value as u32)
582    }
583
584    fn malformed(message: String) -> WipError {
585        WipError::single(WipDiagnostic::new(
586            WipDiagnosticKind::MalformedEstimate,
587            message,
588        ))
589    }
590
591    /// The estimate as it was written, for a message.
592    pub fn render(&self) -> String {
593        match &self.unit {
594            Some(unit) => format!("{}{unit}", self.value),
595            None => self.value.to_string(),
596        }
597    }
598}
599
600/// Fold a human duration word to the symbol a board's unit table uses.
601///
602/// Only **words** are folded; a one-letter unit is returned verbatim, so `S`, `M`
603/// and `L` keep whatever meaning the board gave them.
604fn canonical_unit_word(unit: &str) -> String {
605    if unit.chars().count() == 1 {
606        return unit.to_string();
607    }
608    match unit.to_ascii_lowercase().as_str() {
609        "day" | "days" => "d",
610        "week" | "weeks" | "wk" | "wks" => "w",
611        "hour" | "hours" | "hr" | "hrs" => "h",
612        "minute" | "minutes" | "min" | "mins" => "m",
613        "second" | "seconds" | "sec" | "secs" => "s",
614        _ => unit,
615    }
616    .to_string()
617}
618
619/// Whether `text` names a unit the schema accepts.
620fn is_unit_name(text: &str) -> bool {
621    let mut chars = text.chars();
622    let Some(first) = chars.next() else {
623        return false;
624    };
625    first.is_ascii_alphabetic() && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
626}
627
628/// What a WIP finding is about (C-003, C-007).
629///
630/// The kind is the machine-readable half, so `tix lint` and a surface can branch on
631/// the condition while the sentence beside it stays a sentence.
632#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
633pub enum WipDiagnosticKind {
634    /// An estimate is not `<number>` or `<number><unit>`.
635    MalformedEstimate,
636    /// An estimate names a unit the board never declared.
637    MissingConversion,
638    /// A conversion that is not a whole number of the target unit.
639    AmbiguousConversion,
640    /// A unit reference the board does not declare.
641    UnknownUnit,
642    /// A declared limit is over its ceiling.
643    OverLimit,
644    /// A limit violation that an override carried through.
645    OverrideApplied,
646    /// The blocking limit needs an authorized override to be passed.
647    OverrideRequired,
648    /// The override was not confirmed explicitly.
649    UnconfirmedOverride,
650    /// The override is missing the actor, the timestamp or the reason.
651    MissingOverrideField,
652    /// The override was authorized against a limit that has since changed.
653    StaleOverrideLimit,
654    /// #183's transition policy refused the move; WIP was never reached.
655    RejectedTransition,
656}
657
658impl WipDiagnosticKind {
659    /// The stable spelling every surface prints.
660    pub const fn as_str(self) -> &'static str {
661        match self {
662            Self::MalformedEstimate => "malformed_estimate",
663            Self::MissingConversion => "missing_conversion",
664            Self::AmbiguousConversion => "ambiguous_conversion",
665            Self::UnknownUnit => "unknown_unit",
666            Self::OverLimit => "over_limit",
667            Self::OverrideApplied => "override_applied",
668            Self::OverrideRequired => "override_required",
669            Self::UnconfirmedOverride => "unconfirmed_override",
670            Self::MissingOverrideField => "missing_override_field",
671            Self::StaleOverrideLimit => "stale_override_limit",
672            Self::RejectedTransition => "rejected_transition",
673        }
674    }
675}
676
677/// One WIP finding: what it is, where it is, and what to do about it.
678#[derive(Debug, Clone, PartialEq, Eq)]
679pub struct WipDiagnostic {
680    /// What is wrong.
681    pub kind: WipDiagnosticKind,
682    /// One sentence saying what to do about it.
683    pub message: String,
684    /// A second sentence, when the first is not enough.
685    pub because: Option<String>,
686}
687
688impl WipDiagnostic {
689    /// A finding with its message.
690    pub fn new(kind: WipDiagnosticKind, message: impl Into<String>) -> Self {
691        Self {
692            kind,
693            message: message.into(),
694            because: None,
695        }
696    }
697
698    /// Add the sentence that says what to do about it.
699    pub fn because(mut self, because: impl Into<String>) -> Self {
700        self.because = Some(because.into());
701        self
702    }
703
704    /// Render through the crate's one diagnostic renderer.
705    pub fn render(&self) -> String {
706        let mut pairs = vec![(self.kind.as_str(), self.message.as_str())];
707        if let Some(because) = self.because.as_deref() {
708            pairs.push((self.kind.as_str(), because));
709        }
710        crate::error::render_diagnostics("E-tix-wip", pairs)
711    }
712}
713
714/// A set of WIP findings, reported together.
715#[derive(Debug, Clone, PartialEq, Eq)]
716pub struct WipError {
717    diagnostics: Vec<WipDiagnostic>,
718}
719
720impl WipError {
721    /// Wrap the findings.
722    pub fn new(diagnostics: Vec<WipDiagnostic>) -> Self {
723        Self { diagnostics }
724    }
725
726    /// A single finding.
727    pub fn single(diagnostic: WipDiagnostic) -> Self {
728        Self {
729            diagnostics: vec![diagnostic],
730        }
731    }
732
733    /// The findings, in the order they were found.
734    pub fn diagnostics(&self) -> &[WipDiagnostic] {
735        &self.diagnostics
736    }
737
738    /// The first finding.
739    pub fn first(&self) -> &WipDiagnostic {
740        self.diagnostics
741            .first()
742            .expect("a WipError carries at least one diagnostic")
743    }
744
745    /// Add a sentence to this error's **first** finding, when it is alone.
746    pub fn because(self, because: impl Into<String>) -> Self {
747        let mut diagnostics = self.diagnostics;
748        if let Some(first) = diagnostics.first_mut() {
749            first.because = Some(because.into());
750        }
751        Self { diagnostics }
752    }
753
754    /// Every finding as one rendered block.
755    pub fn render(&self) -> String {
756        self.diagnostics
757            .iter()
758            .map(WipDiagnostic::render)
759            .collect::<Vec<_>>()
760            .join("\n")
761    }
762}
763
764impl std::fmt::Display for WipError {
765    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
766        f.write_str(&self.render())
767    }
768}
769
770impl std::error::Error for WipError {}
771
772#[cfg(test)]
773mod tests {
774    #[allow(clippy::wildcard_imports)]
775    use super::*;
776
777    fn board_columns() -> Vec<String> {
778        ["todo", "in progress", "in review", "done"]
779            .into_iter()
780            .map(String::from)
781            .collect()
782    }
783
784    fn units(pairs: &[(&str, u32)]) -> IndexMap<String, u32> {
785        pairs
786            .iter()
787            .map(|(name, factor)| ((*name).to_string(), *factor))
788            .collect()
789    }
790
791    /// A board with `d`/`w` declared and one blocked limit on `in progress`.
792    fn configured() -> WipConfig {
793        WipConfig {
794            units: units(&[("d", 1), ("w", 5)]),
795            limits: IndexMap::from([(
796                "in progress".to_string(),
797                ColumnLimit {
798                    max_items: Some(3),
799                    max_estimate: Some(10),
800                    unit: Some("d".to_string()),
801                    policy: Some(LimitPolicy::Block),
802                    enabled: true,
803                },
804            )]),
805        }
806    }
807
808    #[test]
809    fn wip_config_absent_limits_are_absent() {
810        let empty = WipConfig::default();
811        assert!(empty.is_empty());
812        assert!(!empty.has_enabled_limits());
813        assert!(empty.effective_limit("in progress").is_none());
814        let toml = toml::to_string(&empty).expect("an empty section serializes");
815        assert_eq!(toml.trim(), "", "an empty section writes no keys: {toml:?}");
816        assert!(ColumnLimit::default().is_empty());
817    }
818
819    #[test]
820    fn wip_config_round_trips_through_toml() {
821        let config = configured();
822        config.validate("default", &board_columns()).expect("valid");
823        let text = toml::to_string(&config).expect("serializes");
824        let back: WipConfig = toml::from_str(&text).expect("deserializes");
825        assert_eq!(back, config, "round trip: {text}");
826        assert!(back.has_enabled_limits());
827    }
828
829    #[test]
830    fn wip_config_omits_defaults_on_save() {
831        // A hand-written limit with neither `enabled` nor a unit it does not need
832        // must come back byte-identical: no default key materializes.
833        let text = "[limits.\"in progress\"]\nmax_items = 3\npolicy = \"warn\"\n";
834        let config: WipConfig = toml::from_str(text).expect("loads");
835        assert!(config.limits["in progress"].enabled, "default on");
836        assert_eq!(toml::to_string(&config).expect("saves"), text);
837    }
838
839    #[test]
840    fn wip_config_rejects_an_unknown_column() {
841        let mut config = configured();
842        config.limits.insert(
843            "shipped".to_string(),
844            ColumnLimit::items(1, LimitPolicy::Warn),
845        );
846        let error = config
847            .validate("default", &board_columns())
848            .expect_err("an unknown column is refused")
849            .to_string();
850        assert!(
851            error.contains("kanban.default.wip.limits.`shipped`"),
852            "{error}"
853        );
854        assert!(error.contains("columns"), "{error}");
855    }
856
857    #[test]
858    fn wip_config_rejects_an_enabled_limit_that_limits_nothing() {
859        let mut config = configured();
860        config.limits.insert(
861            "in review".to_string(),
862            ColumnLimit {
863                policy: Some(LimitPolicy::Warn),
864                ..ColumnLimit::default()
865            },
866        );
867        let error = config
868            .validate("default", &board_columns())
869            .expect_err("an enabled empty limit is refused")
870            .to_string();
871        assert!(
872            error.contains("neither `max_items` nor `max_estimate`"),
873            "{error}"
874        );
875    }
876
877    #[test]
878    fn wip_config_requires_a_policy_and_a_unit() {
879        let mut missing_policy = configured();
880        missing_policy.limits["in progress"].policy = None;
881        let error = missing_policy
882            .validate("default", &board_columns())
883            .expect_err("a policy is required")
884            .to_string();
885        assert!(error.contains("policy` is required"), "{error}");
886
887        let mut missing_unit = configured();
888        missing_unit.limits["in progress"].unit = None;
889        let error = missing_unit
890            .validate("default", &board_columns())
891            .expect_err("a unit is required with max_estimate")
892            .to_string();
893        assert!(error.contains("is required with `max_estimate`"), "{error}");
894
895        let mut unit_without_estimate = configured();
896        unit_without_estimate.limits["in progress"].max_estimate = None;
897        let error = unit_without_estimate
898            .validate("default", &board_columns())
899            .expect_err("a unit without an estimate is refused")
900            .to_string();
901        assert!(
902            error.contains("only meaningful with `max_estimate`"),
903            "{error}"
904        );
905    }
906
907    #[test]
908    fn wip_config_rejects_undeclared_units_and_zero_factors() {
909        let mut undeclared = configured();
910        undeclared.limits["in progress"].unit = Some("q".to_string());
911        let error = undeclared
912            .validate("default", &board_columns())
913            .expect_err("an undeclared unit is refused")
914            .to_string();
915        assert!(error.contains("`q`"), "{error}");
916        assert!(error.contains("wip.units"), "{error}");
917
918        let mut zero = configured();
919        zero.units.insert("z".to_string(), 0);
920        let error = zero
921            .validate("default", &board_columns())
922            .expect_err("a zero factor is refused")
923            .to_string();
924        assert!(error.contains("wip.units.z"), "{error}");
925    }
926
927    #[test]
928    fn wip_config_zero_ceilings_are_refused() {
929        for key in ["max_items", "max_estimate"] {
930            let mut config = configured();
931            let limit = config.limits.get_mut("in progress").expect("the limit");
932            match key {
933                "max_items" => limit.max_items = Some(0),
934                _ => limit.max_estimate = Some(0),
935            }
936            let error = config
937                .validate("default", &board_columns())
938                .expect_err("a zero ceiling is refused")
939                .to_string();
940            assert!(error.contains(key), "{error}");
941            assert!(error.contains("at least 1"), "{error}");
942        }
943    }
944
945    #[test]
946    fn wip_config_disabled_limit_is_validated_but_not_effective() {
947        let mut config = configured();
948        config.limits["in progress"].enabled = false;
949        config.limits["in progress"].policy = None;
950        config
951            .validate("default", &board_columns())
952            .expect("a disabled limit may state no policy");
953        assert!(config.effective_limit("in progress").is_none());
954        assert!(!config.has_enabled_limits());
955        assert!(
956            config.limits.contains_key("in progress"),
957            "the numbers stay for later"
958        );
959    }
960
961    #[test]
962    fn wip_config_estimate_parsing_is_strict_but_total() {
963        assert_eq!(
964            Estimate::parse(" 3d ").expect("a unit"),
965            Estimate {
966                value: 3,
967                unit: Some("d".to_string())
968            }
969        );
970        assert_eq!(
971            Estimate::parse("12").expect("a bare number"),
972            Estimate {
973                value: 12,
974                unit: None
975            }
976        );
977        assert_eq!(
978            Estimate::parse("5story-points").expect("a hyphenated unit"),
979            Estimate {
980                value: 5,
981                unit: Some("story-points".to_string())
982            }
983        );
984        assert_eq!(
985            Estimate::parse("1 day").expect("a prose unit"),
986            Estimate {
987                value: 1,
988                unit: Some("d".to_string())
989            }
990        );
991        assert_eq!(
992            Estimate::parse("2 DAYS").expect("case-insensitive prose"),
993            Estimate {
994                value: 2,
995                unit: Some("d".to_string())
996            }
997        );
998        assert_eq!(
999            Estimate::parse("3S").expect("a one-letter unit keeps its case"),
1000            Estimate {
1001                value: 3,
1002                unit: Some("S".to_string())
1003            }
1004        );
1005        assert_eq!(
1006            Estimate::parse("3.5").expect("a fraction rounds up"),
1007            Estimate {
1008                value: 4,
1009                unit: None
1010            }
1011        );
1012        for bad in [
1013            "",
1014            "twenty",
1015            "-2",
1016            "d3",
1017            "3d!",
1018            "3 2h",
1019            "1 day 2",
1020            "99999999999999",
1021        ] {
1022            let error = Estimate::parse(bad).expect_err("refused: {bad}");
1023            assert_eq!(error.first().kind, WipDiagnosticKind::MalformedEstimate);
1024        }
1025    }
1026
1027    #[test]
1028    fn wip_config_estimate_parses_compound_durations_term_by_term() {
1029        let terms = Estimate::parse_terms("3 weeks 2 hours").expect("two terms");
1030        assert_eq!(
1031            terms,
1032            vec![
1033                Estimate {
1034                    value: 3,
1035                    unit: Some("w".to_string())
1036                },
1037                Estimate {
1038                    value: 2,
1039                    unit: Some("h".to_string())
1040                },
1041            ]
1042        );
1043        assert_eq!(
1044            Estimate::parse_terms("1d, 30 min").expect("comma-separated"),
1045            vec![
1046                Estimate {
1047                    value: 1,
1048                    unit: Some("d".to_string())
1049                },
1050                Estimate {
1051                    value: 30,
1052                    unit: Some("m".to_string())
1053                },
1054            ]
1055        );
1056        assert!(
1057            Estimate::parse("3 weeks 2 hours").is_err(),
1058            "the single-term entry point still names one term"
1059        );
1060    }
1061
1062    #[test]
1063    fn wip_config_estimate_ranges_are_pessimistic() {
1064        assert_eq!(
1065            Estimate::parse("0.5-1 day").expect("a range"),
1066            Estimate {
1067                value: 1,
1068                unit: Some("d".to_string())
1069            }
1070        );
1071        assert_eq!(
1072            Estimate::parse("0.5 day").expect("a fraction rounds up"),
1073            Estimate {
1074                value: 1,
1075                unit: Some("d".to_string())
1076            }
1077        );
1078        assert_eq!(
1079            Estimate::parse("2.1-2.2d").expect("the upper bound, rounded up"),
1080            Estimate {
1081                value: 3,
1082                unit: Some("d".to_string())
1083            }
1084        );
1085        assert_eq!(
1086            Estimate::parse("2-1 day").expect("the larger bound wins"),
1087            Estimate {
1088                value: 2,
1089                unit: Some("d".to_string())
1090            }
1091        );
1092    }
1093
1094    #[test]
1095    fn wip_config_estimate_ignores_unreadable_comma_annotations() {
1096        assert_eq!(
1097            Estimate::parse_terms("~300 LOC, 0.5-1 day").expect("the estimate segment"),
1098            vec![Estimate {
1099                value: 1,
1100                unit: Some("d".to_string())
1101            }]
1102        );
1103        assert_eq!(
1104            Estimate::parse_terms("3d, best guess").expect("the first segment"),
1105            vec![Estimate {
1106                value: 3,
1107                unit: Some("d".to_string())
1108            }]
1109        );
1110        assert!(
1111            Estimate::parse_terms("~300 LOC").is_err(),
1112            "no readable segment means malformed"
1113        );
1114    }
1115
1116    #[test]
1117    fn wip_config_sums_compound_estimates_through_the_board_units() {
1118        let config = WipConfig {
1119            units: units(&[("h", 1), ("d", 8), ("w", 40)]),
1120            limits: IndexMap::new(),
1121        };
1122        let terms = Estimate::parse_terms("3 weeks 2 hours").expect("parses");
1123        assert_eq!(
1124            config.convert_terms(&terms, "h").expect("exact"),
1125            3 * 5 * 8 + 2,
1126            "a prose estimate is board-relative, never a clock-ratio guess"
1127        );
1128        // One unconvertible term refuses the whole sum; nothing is dropped.
1129        let terms = Estimate::parse_terms("1 day 30 fortnights").expect("parses");
1130        let error = config
1131            .convert_terms(&terms, "h")
1132            .expect_err("the second unit is undeclared");
1133        assert_eq!(error.first().kind, WipDiagnosticKind::MissingConversion);
1134    }
1135
1136    #[test]
1137    fn wip_config_converts_between_declared_units() {
1138        let config = configured();
1139        let weeks = Estimate {
1140            value: 1,
1141            unit: Some("w".to_string()),
1142        };
1143        assert_eq!(config.convert(&weeks, "d").expect("1w is 5d"), 5);
1144        assert_eq!(
1145            config
1146                .convert(&Estimate::parse("10d").expect("parses"), "d")
1147                .expect("same unit"),
1148            10
1149        );
1150        assert_eq!(
1151            config
1152                .convert(&Estimate::parse("7").expect("parses"), "w")
1153                .expect("bare means the target unit"),
1154            7
1155        );
1156        let half_weeks = Estimate {
1157            value: 5,
1158            unit: Some("w".to_string()),
1159        };
1160        assert_eq!(
1161            config.convert(&half_weeks, "w").expect("same unit"),
1162            5,
1163            "5w is 5w"
1164        );
1165        assert_eq!(config.convert(&half_weeks, "d").expect("25 points"), 25);
1166    }
1167
1168    #[test]
1169    fn wip_config_refuses_a_missing_conversion() {
1170        let config = configured();
1171        let points = Estimate {
1172            value: 3,
1173            unit: Some("p".to_string()),
1174        };
1175        let error = config.convert(&points, "d").expect_err("undeclared unit");
1176        assert_eq!(error.first().kind, WipDiagnosticKind::MissingConversion);
1177        let rendered = error.render();
1178        assert!(rendered.contains("missing_conversion"), "{rendered}");
1179        assert!(rendered.contains("`3p`"), "{rendered}");
1180
1181        let error = config
1182            .convert(&Estimate::parse("3").expect("parses"), "q")
1183            .expect_err("an undeclared target unit");
1184        assert_eq!(error.first().kind, WipDiagnosticKind::UnknownUnit);
1185    }
1186
1187    #[test]
1188    fn wip_config_refuses_a_fractional_conversion() {
1189        // A board whose base is `h`: `d` is 8, so 3d is 24h; counting that in `w`
1190        // (a 5-hour unit) is not a whole number and must not be rounded.
1191        let config = WipConfig {
1192            units: units(&[("h", 1), ("d", 8), ("w", 40)]),
1193            limits: IndexMap::new(),
1194        };
1195        let error = config
1196            .convert(&Estimate::parse("3d").expect("parses"), "w")
1197            .expect_err("24 is not a whole number of 5");
1198        assert_eq!(error.first().kind, WipDiagnosticKind::AmbiguousConversion);
1199        let rendered = error.render();
1200        assert!(rendered.contains("ambiguous_conversion"), "{rendered}");
1201        assert!(rendered.contains("24 base points"), "{rendered}");
1202        // …and the same estimate in `h` is exact.
1203        assert_eq!(
1204            config
1205                .convert(&Estimate::parse("3d").expect("parses"), "h")
1206                .expect("24h"),
1207            24
1208        );
1209    }
1210
1211    #[test]
1212    fn wip_config_policy_spellings_are_stable() {
1213        assert_eq!(LimitPolicy::Warn.as_str(), "warn");
1214        assert_eq!(LimitPolicy::Block.as_str(), "block");
1215        let limit = configured().limits["in progress"].clone();
1216        assert_eq!(limit.resolved_policy(), Some(LimitPolicy::Block));
1217        let disabled = ColumnLimit {
1218            enabled: false,
1219            ..limit
1220        };
1221        assert_eq!(disabled.resolved_policy(), None);
1222    }
1223}