Skip to main content

tix/triage/
inbox.rs

1//! The inbox query: scope, entries, age, and counts — one snapshot, no writes.
2//!
3//! Contract `triage-state.md` §Queue query: "`triage list`/`triage status`
4//! return ordered entries, age, and counts derived from one issue snapshot. The
5//! query is read-only, deterministic for a supplied date, and uses the same
6//! status/category/completion service as other surfaces. No query writes files
7//! or History." Research R3 and R11, data-model §5–§6, WP02 T007–T011.
8//!
9//! Three claims hold the design together, and each is a property of the
10//! *signatures* rather than of a comment:
11//!
12//! 1. **One snapshot.** [`collect`] takes `&[Issue]`. It cannot open a file,
13//!    walk a directory, or call [`crate::storage::Store::list`], so there is
14//!    nowhere for a second filesystem pass to live. The caller loads the issues
15//!    (normally through [`crate::search::Corpus::build`]) and hands them over
16//!    once.
17//! 2. **Read-only.** Nothing in this module takes `&mut` of anything outside
18//!    its own return value, and no type here is `Serialize`. A query cannot
19//!    write what it cannot name.
20//! 3. **Shared vocabulary.** Membership is a set of column *names* resolved by
21//!    [`crate::workflow::active_resolution`]; acceptance and decline are
22//!    categories from that same resolution, never a literal `"done"` or
23//!    `"canceled"` string (C-002).
24//!
25//! The age source is [`crate::triage::entry_time`] — the stamp WP01 records —
26//! and never `created` or `updated` (research R4). A supplied `today` makes the
27//! arithmetic deterministic: no wall-clock read happens inside a query.
28
29use crate::config::Config;
30use crate::error::{Result, TixError};
31use crate::model::Issue;
32use crate::relationships::RelationKind;
33use crate::triage::{EntryTime, entry_time};
34use crate::workflow::{self, CategoryResolution, WorkflowCategory, is_completed};
35
36/// The inbox, resolved against one board.
37///
38/// Built by [`scope`] and consumed by [`collect`]. Held as resolved **column
39/// names** rather than as the `[triage]` section, because everything downstream
40/// compares an issue's status against a column and a section is not a column:
41/// the same resolution the rest of the crate uses decides what is in the inbox,
42/// so the inbox cannot drift from `tix list` or from `tix mv` (research R3,
43/// "scope drift → shared #183 resolver").
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct InboxScope {
46    /// The board this scope was resolved against, for messages that must name it.
47    pub board: String,
48    /// `[triage].entry_status`: the column an issue sits in while awaiting a decision.
49    pub entry_status: String,
50    /// `[triage].deferred_status`, or [`Self::entry_status`] when the section
51    /// names none (WP01's `effective_deferred_status`).
52    pub deferred_status: String,
53    /// The membership set: the two columns above, deduplicated and in **board
54    /// order** so the set is a function of the board rather than of how the
55    /// section happened to spell it.
56    pub columns: Vec<String>,
57    /// The board's category resolution, carried alongside so a caller can
58    /// classify an entry without asking for the config again.
59    pub resolution: CategoryResolution,
60}
61
62impl InboxScope {
63    /// Whether `issue` is in the inbox.
64    ///
65    /// Column membership only. An issue's *age* is a separate question — a
66    /// legacy issue with no `entered_at` is in the inbox and has an unknown age
67    /// — and folding the two together would make "is it queued?" depend on when
68    /// it was stamped.
69    #[must_use]
70    pub fn matches(&self, issue: &Issue) -> bool {
71        self.columns.iter().any(|c| issue.status.is(c))
72    }
73
74    /// The board's columns joined for a message, in board order.
75    ///
76    /// Delegates rather than re-deriving, so an inbox message lists the same
77    /// columns as an `unknown_column` refusal from the same board.
78    #[must_use]
79    pub fn column_list(&self) -> String {
80        self.resolution.column_list()
81    }
82}
83
84/// Resolve the inbox scope from `[triage]` and the shared category service (T007).
85///
86/// `Ok(None)` means this project configures no inbox at all: the section is
87/// absent, which is the default and the state every legacy `tix.toml` is in.
88/// Absence is not an error — it is the documented opt-out (WP01, data-model §1)
89/// — and a caller that wants the inbox checks for the `None`.
90///
91/// A section that *is* present is validated first, through WP01's own
92/// [`crate::triage::config::validate`]. That is deliberate: this function is
93/// reached from a filter string a user typed (`scope:triage`), which may run
94/// against a `Config` a caller built by hand rather than one that came through
95/// [`Config::load_with_warning`]. Reusing the loader's validator means there is
96/// one answer to "is this section valid", not one per entry point.
97pub fn scope(config: &Config) -> Result<Option<InboxScope>> {
98    let Some(triage) = config.triage.as_ref() else {
99        return Ok(None);
100    };
101    crate::triage::config::validate(config)?;
102
103    // `validate` is what makes both of these `Some`: it rejects a section that
104    // names no entry status and one whose columns the board does not have,
105    // before this line is reachable.
106    let entry = triage
107        .entry_status
108        .as_deref()
109        .expect("triage::config::validate requires entry_status when the section exists");
110    let deferred = triage
111        .effective_deferred_status()
112        .expect("the fallback is entry_status, which validate just required");
113
114    let (board, resolution) = workflow::active_resolution(config);
115    let mut named: Vec<String> = vec![entry.to_string()];
116    if deferred != entry {
117        named.push(deferred.to_string());
118    }
119    // Board order, not the order the section wrote them in: two configs that
120    // name the same two columns must produce the same scope, and a
121    // `HashMap`-backed set would not.
122    let columns = resolution
123        .columns()
124        .filter(|column| named.iter().any(|c| c == column))
125        .map(str::to_string)
126        .collect();
127
128    Ok(Some(InboxScope {
129        board,
130        entry_status: entry.to_string(),
131        deferred_status: deferred.to_string(),
132        columns,
133        resolution,
134    }))
135}
136
137/// Resolve a `scope:` query token to the columns it selects (WP02 T010).
138///
139/// The only scope is `triage`. Both failures are **errors, not empty results**,
140/// for the reason [`crate::search::resolve_parent_criterion`] documents: a user
141/// who typed `scope:triage` and is told "0 issues" cannot tell a typo from a
142/// project that has no inbox, and those are two different answers.
143///
144/// Not a new [`crate::workflow::DiagnosticKind`]: this is command-layer input
145/// validation like `unknown --show value`, reported as
146/// [`TixError::InvalidInput`]. The seven-name diagnostic vocabulary is the
147/// workflow contract's and stays at seven.
148pub fn scope_columns(config: &Config, name: &str) -> Result<Vec<String>> {
149    if !name.eq_ignore_ascii_case("triage") {
150        return Err(TixError::InvalidInput(format!(
151            "unknown scope `{name}` — this project's scopes are: triage"
152        )));
153    }
154    match scope(config)? {
155        Some(scope) => Ok(scope.columns),
156        None => Err(TixError::InvalidInput(
157            "`scope:triage` needs a `[triage]` section in tix.toml, and this \
158             project has none"
159                .to_string(),
160        )),
161    }
162}
163
164/// One row of the derived inbox (data-model §5).
165///
166/// Borrows the issue rather than copying it: the snapshot the caller already
167/// loaded is the source of truth (C-001), and a queue of 10,000 entries that
168/// cloned its issues would hold a second, silently divergent copy of every title
169/// and body. A consumer that wants more than these fields reads `issue`.
170///
171/// `last_decision` from data-model §5 is **not** here, and the reason is a
172/// boundary rather than an omission: the triage History kinds that would answer
173/// it are introduced by WP03 T016, and today's vocabulary has none. Deriving it
174/// from the last event of *any* kind would report a `relationship_added` as the
175/// queue's most recent decision.
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct QueueEntry<'a> {
178    /// The canonical issue this row describes.
179    pub issue: &'a Issue,
180    /// The stamp queue age is derived from, verbatim from WP01's
181    /// [`crate::triage::entry_time`] — `Unknown` for a legacy issue, never
182    /// substituted with `created` or `updated`.
183    pub entered_at: EntryTime<'a>,
184    /// Days between [`Self::entered_at`] and the supplied `today`; `None` when
185    /// the entry has no stamp or a stamp this parser cannot read.
186    ///
187    /// `None`, never `0`: a missing stamp and "entered today" are different
188    /// facts, and a zero that means "unknown" is how an unknown age becomes a
189    /// confident one.
190    pub age_days: Option<i64>,
191}
192
193/// One age bucket, in the order [`AGE_BUCKETS`] declares them.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub struct AgeBucket {
196    /// The bucket's display label.
197    pub label: &'static str,
198    /// How many inbox entries fell in it.
199    pub count: usize,
200}
201
202/// The age buckets, in presentation order, most recent first.
203///
204/// Presentation only (data-model §6: "presentation-friendly deterministic
205/// data"). The boundaries are this module's choice and are not load-bearing:
206/// every decision about an entry uses [`QueueEntry::age_days`], so a surface
207/// that wants different buckets re-buckets the ages it already has rather than
208/// asking for a second query.
209pub const AGE_BUCKETS: [&str; 7] = ["today", "1-2", "3-6", "7-13", "14-29", "30+", "unknown"];
210
211/// The bucket an age falls in, or `"unknown"` for an age nothing was recorded
212/// for.
213#[must_use]
214pub fn age_bucket(age: Option<i64>) -> &'static str {
215    match age {
216        // `<= 0` rather than `== 0`: an entry stamped ahead of `today` is not
217        // waiting a negative number of days, it is waiting to be looked at, and
218        // splitting it into its own bucket would make the distribution depend
219        // on a clock skew nobody asked about.
220        Some(age) if age <= 0 => AGE_BUCKETS[0],
221        Some(age) if age <= 2 => AGE_BUCKETS[1],
222        Some(age) if age <= 6 => AGE_BUCKETS[2],
223        Some(age) if age <= 13 => AGE_BUCKETS[3],
224        Some(age) if age <= 29 => AGE_BUCKETS[4],
225        Some(_) => AGE_BUCKETS[5],
226        None => AGE_BUCKETS[6],
227    }
228}
229
230/// The derived inbox summary (data-model §6).
231///
232/// Every field is a count over **one** pass of one snapshot (NFR-001): the
233/// numbers agree with each other by construction because they are accumulated
234/// together, not reconciled afterwards.
235///
236/// The counts are independent facts, not a partition. `awaiting + deferred ==
237/// total` when the section names a deferred column of its own, and `2 * total`
238/// when it does not — WP01 makes `deferred_status` default to `entry_status`,
239/// so a project with one inbox column has one column and no status to tell the
240/// two apart. Reporting `deferred == total` there is the truth; reporting `0`
241/// would claim nothing is deferred when the config says everything is.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct QueueMetrics<'a> {
244    /// Inbox entries — issues whose status is a configured inbox column.
245    pub total: usize,
246    /// Entries in the entry column: awaiting a first decision.
247    pub awaiting: usize,
248    /// Entries in the deferred column (see the struct note on equality).
249    pub deferred: usize,
250    /// Entries already in the `completed` category while still in an inbox
251    /// column — accepted, but not gone.
252    ///
253    /// A **staleness** count, and 0 on a correctly configured board: an
254    /// accepted issue leaves the inbox, so a non-zero figure reports an issue
255    /// the columns disagree about. The value comes from [`is_completed`], the
256    /// crate's single definition of completion, so it cannot disagree with
257    /// `tix mv` (C-002).
258    pub accepted: usize,
259    /// Entries already in the `canceled` category while still in an inbox
260    /// column: declined, but not gone. Same staleness reading as
261    /// [`Self::accepted`], judged by [`WorkflowCategory::Canceled`] rather than
262    /// by a column name (WP01 T004: decline *is* a move to `canceled`).
263    pub declined: usize,
264    /// Entries that originate a `duplicates` edge — the #169 relation vocabulary
265    /// rather than a status, because duplicate is a relation and never a column
266    /// (C-006).
267    pub duplicated: usize,
268    /// Entries with no readable [`QueueEntry::entered_at`].
269    pub unknown_age: usize,
270    /// The age histogram, one entry per [`AGE_BUCKETS`] label, in order.
271    ///
272    /// Sums to [`Self::total`] — every entry lands in exactly one bucket,
273    /// `unknown` included.
274    pub age_distribution: Vec<AgeBucket>,
275    /// The entries themselves, in snapshot order.
276    ///
277    /// Not re-sorted: [`crate::storage::Store::list`] fixes the order (sorted
278    /// by relative filename), so the inbox agrees with `tix list` without a
279    /// second ordering rule that could disagree with it.
280    pub entries: Vec<QueueEntry<'a>>,
281}
282
283/// Days between an entry stamp and `today`, or `None` when either is unreadable.
284///
285/// The only age arithmetic in the crate, so two surfaces cannot compute two
286/// answers from one pair of dates. Date math reuses
287/// [`crate::sync::date_to_epoch_days`] rather than growing a second calendar
288/// parser beside `model::civil_from_days`.
289///
290/// `entered_at` is an RFC 3339 instant (`2026-09-30T09:12:44Z`) while `today`
291/// is the `YYYY-MM-DD` [`crate::model::today`] returns, so the instant is read
292/// through its date prefix. An instant written without a readable date is an
293/// **unknown** age, not a zero and not `updated` (research R4): the fallback
294/// discipline WP01 established for a missing stamp holds for a malformed one
295/// too.
296#[must_use]
297pub fn age_days(entered_at: EntryTime<'_>, today: &str) -> Option<i64> {
298    let EntryTime::Recorded(at) = entered_at else {
299        return None;
300    };
301    let entered = crate::sync::date_to_epoch_days(at.get(..10)?)?;
302    let today = crate::sync::date_to_epoch_days(today.trim())?;
303    Some(today - entered)
304}
305
306/// Derive the inbox from one snapshot of issues (T008, T009, NFR-001).
307///
308/// One `for` loop, one membership test, one age computation, one category
309/// lookup per member — no nested `issues × issues` pass and no second walk of
310/// `issues`.
311///
312/// `today` is a parameter, never [`crate::model::today()`]: a query is
313/// deterministic for a supplied date (contract §Queue query), which is what
314/// lets a test pin an age and what keeps a long-running board from reporting
315/// two different ages for one issue in one frame.
316#[must_use]
317pub fn collect<'a>(issues: &'a [Issue], scope: &InboxScope, today: &str) -> QueueMetrics<'a> {
318    let mut metrics = QueueMetrics {
319        total: 0,
320        awaiting: 0,
321        deferred: 0,
322        accepted: 0,
323        declined: 0,
324        duplicated: 0,
325        unknown_age: 0,
326        age_distribution: Vec::new(),
327        entries: Vec::new(),
328    };
329    for issue in issues {
330        if !scope.matches(issue) {
331            continue;
332        }
333        let entered_at = entry_time(issue);
334        let age = age_days(entered_at, today);
335        if age.is_none() {
336            metrics.unknown_age += 1;
337        }
338        if issue.status.is(&scope.entry_status) {
339            metrics.awaiting += 1;
340        }
341        if issue.status.is(&scope.deferred_status) {
342            metrics.deferred += 1;
343        }
344        // #183's own answers, in its own vocabulary. Neither branch names a
345        // column: `accepted` is "complete" as the service defines it, and
346        // `declined` is the `canceled` category WP01 made decline map to. Two
347        // independent `if`s rather than one `else if`, so neither count depends
348        // on the other having been evaluated first.
349        if is_completed(issue, &scope.resolution) {
350            metrics.accepted += 1;
351        }
352        if workflow::category_of(issue, &scope.resolution) == Some(WorkflowCategory::Canceled) {
353            metrics.declined += 1;
354        }
355        if issue
356            .relationships
357            .iter()
358            .any(|r| r.kind == RelationKind::Duplicates && r.from == issue.number)
359        {
360            metrics.duplicated += 1;
361        }
362        metrics.entries.push(QueueEntry {
363            issue,
364            entered_at,
365            age_days: age,
366        });
367        metrics.total += 1;
368    }
369
370    // A local first: assigning through `metrics.age_distribution = ...` while
371    // the same expression reads `metrics.entries` would hold both a mutable and
372    // an immutable borrow of `metrics` at once.
373    let distribution = AGE_BUCKETS
374        .iter()
375        .map(|label| AgeBucket {
376            label,
377            count: metrics
378                .entries
379                .iter()
380                .filter(|entry| age_bucket(entry.age_days) == *label)
381                .count(),
382        })
383        .collect();
384    metrics.age_distribution = distribution;
385    metrics
386}
387
388#[cfg(test)]
389mod tests {
390    #[allow(clippy::wildcard_imports)]
391    use super::*;
392    use crate::triage::record_entry;
393
394    /// A board that declares every category, so `[triage]` can name a
395    /// `canceled` column (WP01 T004's requirement) and a `completed` one.
396    const BOARD: &str = r#"issues_dir = "docs/issues"
397
398[kanban.default]
399columns = ["triage", "todo", "in progress", "in review", "done", "wontfix"]
400done_column = "done"
401category_map = { triage = "backlog", todo = "unstarted", "in progress" = "active", "in review" = "active", done = "completed", wontfix = "canceled" }
402"#;
403
404    const TRIAGE: &str = "[triage]\nentry_status = \"triage\"\ndeclined_status = \"wontfix\"\n";
405
406    fn config_with(section: &str) -> Config {
407        toml::from_str(&format!("{BOARD}{section}")).expect("fixture is a well-formed tix.toml")
408    }
409
410    fn issue(number: u32, status: &str) -> Issue {
411        let mut issue = Issue::parse(&format!(
412            "---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: {status}\nlabels: []\n\
413             priority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody."
414        ))
415        .expect("the fixture parses");
416        issue.filename = format!("2026-01-01-issue-{number}.md");
417        issue
418    }
419
420    fn scope_of(config: &Config) -> InboxScope {
421        scope(config)
422            .expect("the fixture validates")
423            .expect("the fixture configures a `[triage]` section")
424    }
425
426    // -- T007: resolve inbox scope ----------------------------------------
427
428    #[test]
429    fn triage_queue_scope_is_absent_without_a_triage_section() {
430        // The default state of every legacy `tix.toml`: no inbox, and asking for
431        // one is a named error rather than an empty board.
432        let config = config_with("");
433        assert_eq!(config.triage, None, "the fixture has no `[triage]`");
434        assert_eq!(scope(&config).unwrap(), None);
435        assert!(
436            scope_columns(&config, "triage").is_err(),
437            "a scope this project does not configure must be an error, not 0 rows"
438        );
439    }
440
441    #[test]
442    fn triage_queue_scope_resolves_entry_and_deferred_through_the_shared_resolver() {
443        let config = config_with(TRIAGE);
444        let scope = scope_of(&config);
445
446        assert_eq!(scope.board, "default");
447        assert_eq!(scope.entry_status, "triage");
448        // WP01's default: no `deferred_status` key, so the entry column answers.
449        assert_eq!(scope.deferred_status, "triage");
450        assert_eq!(scope.columns, vec!["triage".to_string()]);
451        assert_eq!(
452            scope.resolution.resolve("triage"),
453            Some(WorkflowCategory::Backlog),
454            "the scope carries #183's answer, not this module's"
455        );
456    }
457
458    #[test]
459    fn triage_queue_scope_membership_is_column_membership() {
460        let config = config_with(&format!("{TRIAGE}deferred_status = \"todo\"\n"));
461        let scope = scope_of(&config);
462        assert_eq!(
463            scope.columns,
464            vec!["triage".to_string(), "todo".to_string()],
465            "both inbox columns, deduplicated, in board order"
466        );
467
468        assert!(scope.matches(&issue(1, "triage")), "entry column");
469        assert!(scope.matches(&issue(2, "todo")), "deferred column");
470        assert!(
471            !scope.matches(&issue(3, "in review")),
472            "not an inbox column"
473        );
474        assert!(!scope.matches(&issue(4, "wontfix")), "declined has left");
475        assert!(!scope.matches(&issue(5, "done")), "accepted has left");
476    }
477
478    #[test]
479    fn triage_queue_scope_unknown_name_is_refused_by_name() {
480        let config = config_with(TRIAGE);
481        let err = scope_columns(&config, "backlog").unwrap_err().to_string();
482        assert!(err.contains("unknown scope `backlog`"), "{err}");
483        assert!(
484            err.contains("triage"),
485            "the error names what would have worked: {err}"
486        );
487    }
488
489    #[test]
490    fn triage_queue_scope_of_a_hand_built_config_is_still_validated() {
491        // `scope:` runs against whatever `Config` the caller holds, not only
492        // against one that came through `Config::load_with_warning` — so a
493        // section naming a column the board does not have is refused here too,
494        // with WP01's own message rather than a second one.
495        let config =
496            config_with("[triage]\nentry_status = \"nope\"\ndeclined_status = \"wontfix\"\n");
497        let err = scope(&config).unwrap_err().to_string();
498        assert!(err.contains("unknown_column"), "{err}");
499        assert!(err.contains("`[triage].entry_status`"), "{err}");
500        assert!(
501            err.contains("triage, todo"),
502            "and the board's column list: {err}"
503        );
504    }
505
506    // -- T008: one-pass entries and age ------------------------------------
507
508    #[test]
509    fn triage_queue_age_is_entry_stamp_against_the_supplied_today() {
510        let mut entry = issue(1, "triage");
511        record_entry(&mut entry, "2026-09-20T09:12:44Z", None);
512        let scope = scope_of(&config_with(TRIAGE));
513
514        let metrics = collect(std::slice::from_ref(&entry), &scope, "2026-09-30");
515        assert_eq!(metrics.total, 1);
516        assert_eq!(metrics.entries[0].age_days, Some(10), "ten days, not nine");
517        assert_eq!(age_bucket(Some(10)), "7-13");
518        assert_eq!(metrics.unknown_age, 0);
519    }
520
521    #[test]
522    fn triage_queue_age_is_deterministic_for_the_date_it_is_given() {
523        let mut entry = issue(1, "triage");
524        record_entry(&mut entry, "2026-09-20T09:12:44Z", None);
525        let scope = scope_of(&config_with(TRIAGE));
526        let one = collect(std::slice::from_ref(&entry), &scope, "2026-09-30");
527        let two = collect(std::slice::from_ref(&entry), &scope, "2026-09-30");
528        let later = collect(std::slice::from_ref(&entry), &scope, "2026-10-02");
529
530        assert_eq!(one, two, "same date, same answer — no wall-clock read");
531        assert_eq!(
532            later.entries[0].age_days,
533            Some(12),
534            "a later date, more days"
535        );
536    }
537
538    #[test]
539    fn triage_queue_legacy_entry_has_an_unknown_age_never_a_substitute() {
540        // The fixture carries `created` and `updated` of 2026-01-01 — nine
541        // months before the `today` below. Using either would report a wait the
542        // issue never served (research R4).
543        let legacy = issue(1, "triage");
544        let scope = scope_of(&config_with(TRIAGE));
545
546        let metrics = collect(std::slice::from_ref(&legacy), &scope, "2026-09-30");
547        assert_eq!(metrics.total, 1, "a legacy issue is still in the inbox");
548        assert_eq!(
549            metrics.entries[0].age_days, None,
550            "unknown, and specifically not the 272 days `updated` would imply"
551        );
552        assert_eq!(metrics.unknown_age, 1);
553        assert_eq!(age_bucket(None), "unknown");
554    }
555
556    #[test]
557    fn triage_queue_an_unreadable_stamp_is_unknown_rather_than_zero() {
558        let mut malformed = issue(1, "triage");
559        malformed.entered_at = Some("not a date".to_string());
560        let scope = scope_of(&config_with(TRIAGE));
561
562        let metrics = collect(std::slice::from_ref(&malformed), &scope, "2026-09-30");
563        assert_eq!(metrics.entries[0].age_days, None, "not Some(0)");
564        assert_eq!(metrics.unknown_age, 1, "and counted as unknown");
565    }
566
567    // -- T009: counts and stable order -------------------------------------
568
569    #[test]
570    fn triage_queue_counts_split_the_inbox_by_its_two_columns() {
571        let config = config_with(&format!("{TRIAGE}deferred_status = \"todo\"\n"));
572        let scope = scope_of(&config);
573        let issues = vec![
574            issue(1, "triage"),
575            issue(2, "triage"),
576            issue(3, "todo"),
577            issue(4, "in review"),
578        ];
579
580        let metrics = collect(&issues, &scope, "2026-09-30");
581        assert_eq!(metrics.total, 3, "the `in review` row is not in the inbox");
582        assert_eq!(metrics.awaiting, 2, "two in the entry column");
583        assert_eq!(metrics.deferred, 1, "one in the deferred column");
584        assert_eq!(
585            metrics.awaiting + metrics.deferred,
586            metrics.total,
587            "and with a distinct deferred column the two counts partition it"
588        );
589        assert_eq!(metrics.accepted, 0, "nobody has been decided yet");
590        assert_eq!(metrics.declined, 0);
591        assert_eq!(metrics.duplicated, 0);
592        assert_eq!(
593            metrics.unknown_age, 3,
594            "no fixture issue carries an `entered_at`, so no fixture issue has an age"
595        );
596    }
597
598    #[test]
599    fn triage_queue_a_single_column_inbox_reports_the_same_count_twice() {
600        // WP01 defaults `deferred_status` to `entry_status`, so a project that
601        // names no deferred column has one column and no status to tell the two
602        // facts apart. `deferred == total` is the truth; `0` would claim
603        // nothing is deferred when the config says everything is.
604        let config = config_with(TRIAGE);
605        let scope = scope_of(&config);
606        assert_eq!(scope.entry_status, scope.deferred_status);
607
608        let issues = vec![issue(1, "triage"), issue(2, "triage")];
609        let metrics = collect(&issues, &scope, "2026-09-30");
610        assert_eq!(metrics.total, 2);
611        assert_eq!(metrics.awaiting, 2);
612        assert_eq!(metrics.deferred, 2, "one column, two descriptions of it");
613    }
614
615    #[test]
616    fn triage_queue_counts_follow_the_category_mapping_not_the_column_name() {
617        // The board maps `done` → `completed` and `wontfix` → `canceled`. Put
618        // an inbox column on each and the counts must follow the *mapping*: a
619        // literal `"done"` here would report the `wontfix` column as accepted,
620        // and a literal `"declined"` column name would not exist at all.
621        let accepted_scope = scope_of(&config_with(
622            "[triage]\nentry_status = \"done\"\ndeclined_status = \"wontfix\"\n",
623        ));
624        let in_accepted = vec![issue(1, "done")];
625        let metrics = collect(&in_accepted, &accepted_scope, "2026-09-30");
626        assert_eq!(metrics.total, 1);
627        assert_eq!(metrics.accepted, 1, "`done` is complete, via #183");
628        assert_eq!(metrics.declined, 0, "complete is not canceled");
629
630        let declined_scope = scope_of(&config_with(
631            "[triage]\nentry_status = \"wontfix\"\ndeclined_status = \"wontfix\"\n",
632        ));
633        let in_declined = vec![issue(2, "wontfix")];
634        let metrics = collect(&in_declined, &declined_scope, "2026-09-30");
635        assert_eq!(metrics.total, 1);
636        assert_eq!(metrics.declined, 1, "canceled, via #183");
637        assert_eq!(
638            metrics.accepted, 0,
639            "canceled is terminal and never complete (C-003)"
640        );
641    }
642
643    #[test]
644    fn triage_queue_duplicate_is_counted_from_the_relation_vocabulary() {
645        let config = config_with(TRIAGE);
646        let scope = scope_of(&config);
647
648        let mut duplicate = issue(1, "triage");
649        duplicate
650            .relationships
651            .push(crate::relationships::Relationship::new(
652                1,
653                99,
654                RelationKind::Duplicates,
655                "Canonical",
656            ));
657        let mut blocked = issue(2, "triage");
658        blocked
659            .relationships
660            .push(crate::relationships::Relationship::new(
661                2,
662                98,
663                RelationKind::Blocks,
664                "Something else",
665            ));
666        let plain = issue(3, "triage");
667        let all = [duplicate, blocked, plain];
668
669        let metrics = collect(&all, &scope, "2026-09-30");
670        assert_eq!(metrics.total, 3, "relations do not change membership");
671        assert_eq!(
672            metrics.duplicated, 1,
673            "only the `duplicates` edge counts, and only from its origin"
674        );
675    }
676
677    #[test]
678    fn triage_queue_entries_keep_the_snapshot_order() {
679        // `Store::list` sorts by filename; `collect` must not re-sort, or the
680        // inbox and `tix list` would disagree about which row is first.
681        let issues: Vec<Issue> = [3, 1, 2].iter().map(|n| issue(*n, "triage")).collect();
682        let scope = scope_of(&config_with(TRIAGE));
683        let metrics = collect(&issues, &scope, "2026-09-30");
684
685        assert_eq!(
686            metrics
687                .entries
688                .iter()
689                .map(|e| e.issue.number)
690                .collect::<Vec<_>>(),
691            vec![3, 1, 2],
692            "input order, unchanged"
693        );
694    }
695
696    #[test]
697    fn triage_queue_age_distribution_sums_to_the_total() {
698        // Anti-vacuity: a histogram of zeros would satisfy any per-bucket
699        // assertion, so the invariant checked is the one that cannot hold if an
700        // entry were dropped on the way in.
701        let scope = scope_of(&config_with(TRIAGE));
702        let mut issues = Vec::new();
703        for (n, age) in [(1u32, 0i64), (2, 1), (3, 5), (4, 12), (5, 25)] {
704            let mut entry = issue(n, "triage");
705            record_entry(
706                &mut entry,
707                &format!("2026-09-{:02}T00:00:00Z", 30 - age),
708                None,
709            );
710            issues.push(entry);
711        }
712        issues.push(issue(6, "triage")); // no stamp at all
713
714        let metrics = collect(&issues, &scope, "2026-09-30");
715        assert_eq!(metrics.total, 6);
716        assert_eq!(metrics.unknown_age, 1);
717        let sum: usize = metrics.age_distribution.iter().map(|b| b.count).sum();
718        assert_eq!(
719            sum, metrics.total,
720            "every entry lands in exactly one bucket"
721        );
722        assert_eq!(
723            metrics
724                .age_distribution
725                .iter()
726                .map(|b| b.label)
727                .collect::<Vec<_>>(),
728            AGE_BUCKETS.to_vec(),
729            "and in the declared order"
730        );
731        assert_eq!(metrics.age_distribution[0].count, 1, "the `today` bucket");
732        assert_eq!(metrics.age_distribution[4].count, 1, "the `14-29` bucket");
733        assert_eq!(metrics.age_distribution[6].count, 1, "the `unknown` bucket");
734    }
735}