Skip to main content

tix/wip/
aggregate.rs

1//! One-pass WIP aggregates: what each column holds, where that stands against its
2//! limit, and why a total is unknown when it is (mission
3//! `configurable-wip-limits-01M3C6WX`, WP02).
4//!
5//! # One snapshot, derived once
6//!
7//! Every surface that wants to show or decide a limit asks *this* module, and the
8//! module asks the issue slice once (T007). Counting per column by scanning the
9//! issue set again would be `columns × issues` — and, worse, two surfaces counting
10//! separately is how `tix status` and the board start disagreeing about how full a
11//! column is. The snapshot is derived state: it is built from issues a caller
12//! already holds, it reads no file, and it writes nothing.
13//!
14//! # Never a wrong number
15//!
16//! An issue whose `estimate` cannot be counted (not `<number><unit>`,
17//! `malformed_estimate`; a unit the board does not declare, `missing_conversion`;
18//! a conversion that does not divide exactly, `ambiguous_conversion`) makes the
19//! column's estimate total **unknown**, not zero and not the sum of the others
20//! (C-003). The diagnostic names the issue, so the reader can fix it; the state
21//! stays [`LimitState::Unknown`], which is never read as "within the limit".
22
23use crate::config::BoardConfiguration;
24use crate::model::Issue;
25
26use super::config::{ColumnLimit, Estimate, LimitPolicy, WipDiagnostic};
27
28/// Where a column stands against its limit.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub enum LimitState {
31    /// Within every configured ceiling.
32    Within,
33    /// Over at least one configured ceiling.
34    Over,
35    /// At least one aggregate could not be computed, so nothing is claimed. This
36    /// is never "within": an uncountable estimate must not pass a limit silently.
37    Unknown,
38}
39
40impl LimitState {
41    /// The stable spelling every surface prints.
42    pub const fn as_str(self) -> &'static str {
43        match self {
44            Self::Within => "within",
45            Self::Over => "over",
46            Self::Unknown => "unknown",
47        }
48    }
49
50    /// Whether anything is actually wrong: `Over`, or a total nobody can trust.
51    pub const fn is_violation(self) -> bool {
52        matches!(self, Self::Over)
53    }
54
55    /// The worse of two states, for a column that has two ceilings.
56    pub const fn worse(self, other: Self) -> Self {
57        match (self, other) {
58            (Self::Over, _) | (_, Self::Over) => Self::Over,
59            (Self::Unknown, _) | (_, Self::Unknown) => Self::Unknown,
60            _ => Self::Within,
61        }
62    }
63}
64
65/// One column's usage, and the limit it is measured against.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct ColumnUsage {
68    /// The column's name, as the board declares it.
69    pub column: String,
70    /// How many issues the column holds.
71    pub items: u32,
72    /// The estimate total in the limit's unit.
73    ///
74    /// `None` when the board states no estimate ceiling for the column (there is no
75    /// unit to count in) **or** when at least one issue's estimate could not be
76    /// counted — the snapshot's diagnostics say which issue, and
77    /// [`ColumnUsage::state`] is then [`LimitState::Unknown`].
78    pub estimate: Option<u32>,
79    /// The enabled limit in force for the column, if any.
80    pub limit: Option<ColumnLimit>,
81}
82
83impl ColumnUsage {
84    /// The item ceiling, when the column has one.
85    pub fn max_items(&self) -> Option<u32> {
86        self.limit.as_ref().and_then(|limit| limit.max_items)
87    }
88
89    /// The estimate ceiling, when the column has one.
90    pub fn max_estimate(&self) -> Option<u32> {
91        self.limit.as_ref().and_then(|limit| limit.max_estimate)
92    }
93
94    /// The unit the estimate ceiling counts in, when there is one.
95    pub fn unit(&self) -> Option<&str> {
96        self.limit.as_ref().and_then(|limit| limit.unit.as_deref())
97    }
98
99    /// The policy the column enforces, when it enforces one.
100    pub fn policy(&self) -> Option<LimitPolicy> {
101        self.limit.as_ref().and_then(ColumnLimit::resolved_policy)
102    }
103
104    /// Where the column stands on its own item ceiling.
105    pub fn item_state(&self) -> LimitState {
106        match self.max_items() {
107            Some(max) => match self.items.cmp(&max) {
108                std::cmp::Ordering::Greater => LimitState::Over,
109                _ => LimitState::Within,
110            },
111            None => LimitState::Within,
112        }
113    }
114
115    /// Where the column stands on its own estimate ceiling.
116    pub fn estimate_state(&self) -> LimitState {
117        match (self.max_estimate(), self.estimate) {
118            // A ceiling nobody can measure is `Unknown`, never `Within`.
119            (Some(_), None) => LimitState::Unknown,
120            (Some(max), Some(total)) => match total.cmp(&max) {
121                std::cmp::Ordering::Greater => LimitState::Over,
122                _ => LimitState::Within,
123            },
124            (None, _) => LimitState::Within,
125        }
126    }
127
128    /// Where the column stands overall: the worse of its two ceilings.
129    pub fn state(&self) -> LimitState {
130        self.item_state().worse(self.estimate_state())
131    }
132
133    /// The state the column **would** be in after `add_items` more cards and
134    /// `add_estimate` more of its unit (T009).
135    ///
136    /// This is the before/after shape a move is judged with: the caller passes what
137    /// the move would add, and reads the answer without touching a file. A move that
138    /// adds nothing reproduces [`state`](ColumnUsage::state) exactly.
139    pub fn prospective(&self, add_items: u32, add_estimate: u32) -> LimitState {
140        let items = self.items.saturating_add(add_items);
141        let item_state = match self.max_items() {
142            Some(max) if items > max => LimitState::Over,
143            _ => LimitState::Within,
144        };
145        let estimate_state = match (self.max_estimate(), self.estimate) {
146            (Some(_), None) => LimitState::Unknown,
147            (Some(max), Some(total)) => match total.saturating_add(add_estimate).cmp(&max) {
148                std::cmp::Ordering::Greater => LimitState::Over,
149                _ => LimitState::Within,
150            },
151            (None, _) => LimitState::Within,
152        };
153        item_state.worse(estimate_state)
154    }
155
156    /// The usage as one short cell: `4/5`, or `4` when the column has no item
157    /// ceiling, with the estimate ceiling beside it in its unit (`12/20d`) when the
158    /// board states one. A trailing `!` means something is over, or unknown.
159    ///
160    /// It is deliberately the *usage*, not a verdict: the verdict is the policy
161    /// layer's, and a cell that said "block" would be a claim this module is not
162    /// entitled to make.
163    pub fn cell(&self) -> String {
164        let mut cell = match self.max_items() {
165            Some(max) => format!("{}/{max}", self.items),
166            None => self.items.to_string(),
167        };
168        if let Some(max) = self.max_estimate() {
169            let unit = self.unit().unwrap_or("");
170            match self.estimate {
171                Some(total) => cell.push_str(&format!(" {total}/{max}{unit}")),
172                // No number to print: the diagnostics name the issue that made this
173                // unmeasurable, and a `?` here is the honest cell.
174                None => cell.push_str(&format!(" ?/{max}{unit}")),
175            }
176        }
177        if !matches!(self.state(), LimitState::Within) {
178            cell.push('!');
179        }
180        cell
181    }
182}
183
184/// The board's WIP state: one derived snapshot of every configured column (T008).
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct WipSnapshot {
187    columns: Vec<ColumnUsage>,
188    diagnostics: Vec<WipDiagnostic>,
189    configured: bool,
190}
191
192impl WipSnapshot {
193    /// Derive the snapshot from `issues`, in the board's column order.
194    ///
195    /// **One pass over the issues** and no I/O: the columns are indexed once, and
196    /// every issue is examined once and added to its own column's slot. An issue
197    /// whose `status` is not a configured column is not counted anywhere — the board
198    /// does not render it, so a limit on some other column is not its business
199    /// (`tix status`'s own `mismatch` reporting is where such an issue is surfaced).
200    pub fn build(board: &BoardConfiguration, issues: &[Issue]) -> Self {
201        let mut columns: Vec<ColumnUsage> = board
202            .columns
203            .iter()
204            .map(|column| ColumnUsage {
205                column: column.clone(),
206                items: 0,
207                estimate: None,
208                limit: board.wip.effective_limit(column).cloned(),
209            })
210            .collect();
211        // A column that has an estimate ceiling starts its total at zero and grows
212        // it; a column without one keeps `None`, because there is no unit to count
213        // in and a raw sum would be a number with no meaning.
214        let mut totals: Vec<Option<u32>> = columns
215            .iter()
216            .map(|usage| usage.max_estimate().map(|_| 0))
217            .collect();
218        let mut diagnostics: Vec<WipDiagnostic> = Vec::new();
219        let index: std::collections::HashMap<&str, usize> = board
220            .columns
221            .iter()
222            .enumerate()
223            .map(|(slot, column)| (column.as_str(), slot))
224            .collect();
225
226        for issue in issues {
227            let Some(&slot) = index.get(issue.status.display()) else {
228                continue;
229            };
230            columns[slot].items = columns[slot].items.saturating_add(1);
231            let Some(total) = totals[slot].as_mut() else {
232                continue;
233            };
234            let Some(raw) = issue
235                .estimate
236                .as_deref()
237                .map(str::trim)
238                .filter(|raw| !raw.is_empty())
239            else {
240                // No estimate is zero estimate: the card is work, and it states no
241                // size. That is not an error and produces no diagnostic.
242                continue;
243            };
244            let unit = columns[slot].unit().unwrap_or_default().to_string();
245            match Estimate::parse_terms(raw)
246                .and_then(|terms| board.wip.convert_terms(&terms, &unit))
247            {
248                Ok(value) => *total = total.saturating_add(value),
249                Err(error) => {
250                    let diagnostic = error.first();
251                    diagnostics.push(
252                        WipDiagnostic::new(
253                            diagnostic.kind,
254                            format!(
255                                "issue #{} in `{}` states `{raw}`, which this board cannot count \
256                                 in `{unit}`: {}",
257                                issue.number, columns[slot].column, diagnostic.message
258                            ),
259                        )
260                        .because(
261                            "the column's estimate total is unknown until this is fixed, and an \
262                             unknown total never passes a limit (C-003)",
263                        ),
264                    );
265                    // The total is now untrustworthy: `None` is the only honest value.
266                    totals[slot] = None;
267                    columns[slot].estimate = None;
268                }
269            }
270        }
271
272        for (slot, total) in totals.into_iter().enumerate() {
273            columns[slot].estimate = total;
274        }
275        let configured = columns.iter().any(|usage| usage.limit.is_some());
276        Self {
277            columns,
278            diagnostics,
279            configured,
280        }
281    }
282
283    /// Every configured column, in board order.
284    pub fn columns(&self) -> &[ColumnUsage] {
285        &self.columns
286    }
287
288    /// One column's usage, by name.
289    pub fn column(&self, column: &str) -> Option<&ColumnUsage> {
290        self.columns.iter().find(|usage| usage.column == column)
291    }
292
293    /// The findings that made a total unknown.
294    pub fn diagnostics(&self) -> &[WipDiagnostic] {
295        &self.diagnostics
296    }
297
298    /// Whether any column enforces a limit at all.
299    ///
300    /// A surface that shows limits should ask this first: a board with no limits must
301    /// render exactly what it rendered before this mission, byte for byte.
302    pub fn is_configured(&self) -> bool {
303        self.configured
304    }
305
306    /// Where `column` stands, or [`LimitState::Within`] for a column with no limit.
307    pub fn state(&self, column: &str) -> LimitState {
308        self.column(column)
309            .map_or(LimitState::Within, ColumnUsage::state)
310    }
311
312    /// Every column that is over a ceiling, in board order.
313    pub fn violations(&self) -> impl Iterator<Item = &ColumnUsage> {
314        self.columns
315            .iter()
316            .filter(|usage| usage.state().is_violation())
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    #[allow(clippy::wildcard_imports)]
323    use super::*;
324    use indexmap::IndexMap;
325
326    use crate::config::BoardConfiguration;
327    use crate::wip::WipConfig;
328    use crate::wip::config::WipDiagnosticKind;
329
330    fn board(limits: &[(&str, ColumnLimit)]) -> BoardConfiguration {
331        BoardConfiguration {
332            columns: ["todo", "in progress", "in review", "done"]
333                .into_iter()
334                .map(String::from)
335                .collect(),
336            wip: WipConfig {
337                units: [("d", 1), ("w", 5)]
338                    .into_iter()
339                    .map(|(name, factor)| (name.to_string(), factor))
340                    .collect::<IndexMap<_, _>>(),
341                limits: limits
342                    .iter()
343                    .map(|(column, limit)| ((*column).to_string(), limit.clone()))
344                    .collect(),
345            },
346            ..BoardConfiguration::default()
347        }
348    }
349
350    /// An issue through the **real** parser: the `estimate:` key is frontmatter, so
351    /// the fixture is the file format rather than a hand-built struct.
352    fn issue(number: u32, status: &str, estimate: Option<&str>) -> Issue {
353        let estimate = estimate
354            .map(|value| format!("estimate: \"{value}\"\n"))
355            .unwrap_or_default();
356        let contents = format!(
357            "---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"{status}\"\n\
358             created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n{estimate}---\n\nBody.\n"
359        );
360        let mut issue = Issue::parse(&contents).expect("the fixture parses");
361        issue.filename = format!("issue-{number}.md");
362        issue
363    }
364
365    #[test]
366    fn wip_aggregate_counts_every_column_in_one_pass() {
367        let board = board(&[("in progress", ColumnLimit::items(2, LimitPolicy::Warn))]);
368        let issues = vec![
369            issue(1, "todo", None),
370            issue(2, "in progress", None),
371            issue(3, "in progress", None),
372            issue(4, "done", None),
373            issue(5, "shipped", None), // not a configured column
374        ];
375        let snapshot = WipSnapshot::build(&board, &issues);
376        assert_eq!(
377            snapshot
378                .columns()
379                .iter()
380                .map(|usage| (usage.column.as_str(), usage.items))
381                .collect::<Vec<_>>(),
382            vec![
383                ("todo", 1),
384                ("in progress", 2),
385                ("in review", 0),
386                ("done", 1)
387            ],
388            "every configured column appears, in board order"
389        );
390        assert_eq!(
391            snapshot.column("in progress").expect("usage").state(),
392            LimitState::Within
393        );
394        assert_eq!(
395            snapshot.state("todo"),
396            LimitState::Within,
397            "no limit is within"
398        );
399        assert!(snapshot.is_configured());
400    }
401
402    #[test]
403    fn wip_aggregate_state_is_over_only_past_the_ceiling() {
404        let board = board(&[("in progress", ColumnLimit::items(2, LimitPolicy::Block))]);
405        let at_limit = WipSnapshot::build(
406            &board,
407            &[issue(1, "in progress", None), issue(2, "in progress", None)],
408        );
409        let usage = at_limit.column("in progress").expect("usage");
410        assert_eq!(
411            usage.state(),
412            LimitState::Within,
413            "exactly at the limit is within"
414        );
415        assert_eq!(usage.prospective(1, 0), LimitState::Over);
416        assert_eq!(
417            usage.prospective(0, 0),
418            LimitState::Within,
419            "a no-op move is within"
420        );
421
422        let over = WipSnapshot::build(
423            &board,
424            &[
425                issue(1, "in progress", None),
426                issue(2, "in progress", None),
427                issue(3, "in progress", None),
428            ],
429        );
430        assert_eq!(
431            over.column("in progress").expect("usage").state(),
432            LimitState::Over
433        );
434        assert_eq!(over.violations().count(), 1);
435        assert_eq!(over.column("in progress").expect("usage").cell(), "3/2!");
436    }
437
438    #[test]
439    fn wip_aggregate_totals_estimates_in_the_limit_unit() {
440        let board = board(&[(
441            "in progress",
442            ColumnLimit::estimate(10, "d", LimitPolicy::Warn),
443        )]);
444        let issues = vec![
445            issue(1, "in progress", Some("3")),
446            issue(2, "in progress", Some("1w")),
447            issue(3, "in progress", None),
448            issue(4, "in progress", Some("  ")),
449            issue(5, "todo", Some("9w")),
450        ];
451        let snapshot = WipSnapshot::build(&board, &issues);
452        let usage = snapshot.column("in progress").expect("usage");
453        assert_eq!(usage.items, 4, "an unestimated card is still work");
454        assert_eq!(
455            usage.estimate,
456            Some(8),
457            "3 + 5d; the empty ones add nothing"
458        );
459        assert_eq!(usage.state(), LimitState::Within);
460        assert_eq!(usage.cell(), "4 8/10d");
461        assert_eq!(
462            snapshot.column("todo").expect("usage").estimate,
463            None,
464            "no estimate ceiling means no unit, so no total"
465        );
466        assert_eq!(snapshot.diagnostics(), []);
467    }
468
469    #[test]
470    fn wip_aggregate_unknown_total_is_never_within() {
471        let board = board(&[(
472            "in progress",
473            ColumnLimit::estimate(10, "d", LimitPolicy::Block),
474        )]);
475        for (raw, kind) in [
476            ("three", WipDiagnosticKind::MalformedEstimate),
477            ("3q", WipDiagnosticKind::MissingConversion),
478        ] {
479            let snapshot = WipSnapshot::build(
480                &board,
481                &[
482                    issue(1, "in progress", Some("2")),
483                    issue(2, "in progress", Some(raw)),
484                ],
485            );
486            let usage = snapshot.column("in progress").expect("usage");
487            assert_eq!(usage.estimate, None, "`{raw}`: no total is claimed");
488            assert_eq!(usage.estimate_state(), LimitState::Unknown, "`{raw}`");
489            assert_eq!(usage.prospective(0, 0), LimitState::Unknown, "`{raw}`");
490            assert_eq!(
491                usage.cell(),
492                "2 ?/10d!",
493                "the cell says unknown with a `!`, never a number"
494            );
495            let diagnostic = &snapshot.diagnostics()[0];
496            assert_eq!(diagnostic.kind, kind, "`{raw}`");
497            assert!(
498                diagnostic.message.contains("issue #2") && diagnostic.message.contains(raw),
499                "{}",
500                diagnostic.message
501            );
502        }
503    }
504
505    #[test]
506    fn wip_aggregate_fractional_conversion_makes_the_total_unknown() {
507        let mut board = board(&[(
508            "in progress",
509            ColumnLimit::estimate(10, "w", LimitPolicy::Warn),
510        )]);
511        board.wip.units.insert("h".to_string(), 1);
512        board.wip.units.insert("d".to_string(), 8);
513        let snapshot = WipSnapshot::build(&board, &[issue(1, "in progress", Some("3d"))]);
514        let usage = snapshot.column("in progress").expect("usage");
515        assert_eq!(usage.estimate_state(), LimitState::Unknown);
516        assert_eq!(
517            snapshot.diagnostics()[0].kind,
518            WipDiagnosticKind::AmbiguousConversion
519        );
520    }
521
522    #[test]
523    fn wip_aggregate_without_limits_is_inert() {
524        let board = BoardConfiguration::default();
525        let issues = vec![issue(1, "todo", Some("3d")), issue(2, "todo", Some("nope"))];
526        let snapshot = WipSnapshot::build(&board, &issues);
527        assert!(!snapshot.is_configured(), "nothing is configured");
528        assert!(snapshot.violations().next().is_none());
529        assert_eq!(snapshot.column("todo").expect("usage").items, 2);
530        assert!(
531            snapshot.diagnostics().is_empty(),
532            "with no limit there is nothing to measure, so nothing is broken"
533        );
534    }
535}