tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
//! The inbox query: scope, entries, age, and counts — one snapshot, no writes.
//!
//! Contract `triage-state.md` §Queue query: "`triage list`/`triage status`
//! return ordered entries, age, and counts derived from one issue snapshot. The
//! query is read-only, deterministic for a supplied date, and uses the same
//! status/category/completion service as other surfaces. No query writes files
//! or History." Research R3 and R11, data-model §5–§6, WP02 T007–T011.
//!
//! Three claims hold the design together, and each is a property of the
//! *signatures* rather than of a comment:
//!
//! 1. **One snapshot.** [`collect`] takes `&[Issue]`. It cannot open a file,
//!    walk a directory, or call [`crate::storage::Store::list`], so there is
//!    nowhere for a second filesystem pass to live. The caller loads the issues
//!    (normally through [`crate::search::Corpus::build`]) and hands them over
//!    once.
//! 2. **Read-only.** Nothing in this module takes `&mut` of anything outside
//!    its own return value, and no type here is `Serialize`. A query cannot
//!    write what it cannot name.
//! 3. **Shared vocabulary.** Membership is a set of column *names* resolved by
//!    [`crate::workflow::active_resolution`]; acceptance and decline are
//!    categories from that same resolution, never a literal `"done"` or
//!    `"canceled"` string (C-002).
//!
//! The age source is [`crate::triage::entry_time`] — the stamp WP01 records —
//! and never `created` or `updated` (research R4). A supplied `today` makes the
//! arithmetic deterministic: no wall-clock read happens inside a query.

use crate::config::Config;
use crate::error::{Result, TixError};
use crate::model::Issue;
use crate::relationships::RelationKind;
use crate::triage::{EntryTime, entry_time};
use crate::workflow::{self, CategoryResolution, WorkflowCategory, is_completed};

/// The inbox, resolved against one board.
///
/// Built by [`scope`] and consumed by [`collect`]. Held as resolved **column
/// names** rather than as the `[triage]` section, because everything downstream
/// compares an issue's status against a column and a section is not a column:
/// the same resolution the rest of the crate uses decides what is in the inbox,
/// so the inbox cannot drift from `tix list` or from `tix mv` (research R3,
/// "scope drift → shared #183 resolver").
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InboxScope {
    /// The board this scope was resolved against, for messages that must name it.
    pub board: String,
    /// `[triage].entry_status`: the column an issue sits in while awaiting a decision.
    pub entry_status: String,
    /// `[triage].deferred_status`, or [`Self::entry_status`] when the section
    /// names none (WP01's `effective_deferred_status`).
    pub deferred_status: String,
    /// The membership set: the two columns above, deduplicated and in **board
    /// order** so the set is a function of the board rather than of how the
    /// section happened to spell it.
    pub columns: Vec<String>,
    /// The board's category resolution, carried alongside so a caller can
    /// classify an entry without asking for the config again.
    pub resolution: CategoryResolution,
}

impl InboxScope {
    /// Whether `issue` is in the inbox.
    ///
    /// Column membership only. An issue's *age* is a separate question — a
    /// legacy issue with no `entered_at` is in the inbox and has an unknown age
    /// — and folding the two together would make "is it queued?" depend on when
    /// it was stamped.
    #[must_use]
    pub fn matches(&self, issue: &Issue) -> bool {
        self.columns.iter().any(|c| issue.status.is(c))
    }

    /// The board's columns joined for a message, in board order.
    ///
    /// Delegates rather than re-deriving, so an inbox message lists the same
    /// columns as an `unknown_column` refusal from the same board.
    #[must_use]
    pub fn column_list(&self) -> String {
        self.resolution.column_list()
    }
}

/// Resolve the inbox scope from `[triage]` and the shared category service (T007).
///
/// `Ok(None)` means this project configures no inbox at all: the section is
/// absent, which is the default and the state every legacy `tix.toml` is in.
/// Absence is not an error — it is the documented opt-out (WP01, data-model §1)
/// — and a caller that wants the inbox checks for the `None`.
///
/// A section that *is* present is validated first, through WP01's own
/// [`crate::triage::config::validate`]. That is deliberate: this function is
/// reached from a filter string a user typed (`scope:triage`), which may run
/// against a `Config` a caller built by hand rather than one that came through
/// [`Config::load_with_warning`]. Reusing the loader's validator means there is
/// one answer to "is this section valid", not one per entry point.
pub fn scope(config: &Config) -> Result<Option<InboxScope>> {
    let Some(triage) = config.triage.as_ref() else {
        return Ok(None);
    };
    crate::triage::config::validate(config)?;

    // `validate` is what makes both of these `Some`: it rejects a section that
    // names no entry status and one whose columns the board does not have,
    // before this line is reachable.
    let entry = triage
        .entry_status
        .as_deref()
        .expect("triage::config::validate requires entry_status when the section exists");
    let deferred = triage
        .effective_deferred_status()
        .expect("the fallback is entry_status, which validate just required");

    let (board, resolution) = workflow::active_resolution(config);
    let mut named: Vec<String> = vec![entry.to_string()];
    if deferred != entry {
        named.push(deferred.to_string());
    }
    // Board order, not the order the section wrote them in: two configs that
    // name the same two columns must produce the same scope, and a
    // `HashMap`-backed set would not.
    let columns = resolution
        .columns()
        .filter(|column| named.iter().any(|c| c == column))
        .map(str::to_string)
        .collect();

    Ok(Some(InboxScope {
        board,
        entry_status: entry.to_string(),
        deferred_status: deferred.to_string(),
        columns,
        resolution,
    }))
}

/// Resolve a `scope:` query token to the columns it selects (WP02 T010).
///
/// The only scope is `triage`. Both failures are **errors, not empty results**,
/// for the reason [`crate::search::resolve_parent_criterion`] documents: a user
/// who typed `scope:triage` and is told "0 issues" cannot tell a typo from a
/// project that has no inbox, and those are two different answers.
///
/// Not a new [`crate::workflow::DiagnosticKind`]: this is command-layer input
/// validation like `unknown --show value`, reported as
/// [`TixError::InvalidInput`]. The seven-name diagnostic vocabulary is the
/// workflow contract's and stays at seven.
pub fn scope_columns(config: &Config, name: &str) -> Result<Vec<String>> {
    if !name.eq_ignore_ascii_case("triage") {
        return Err(TixError::InvalidInput(format!(
            "unknown scope `{name}` — this project's scopes are: triage"
        )));
    }
    match scope(config)? {
        Some(scope) => Ok(scope.columns),
        None => Err(TixError::InvalidInput(
            "`scope:triage` needs a `[triage]` section in tix.toml, and this \
             project has none"
                .to_string(),
        )),
    }
}

/// One row of the derived inbox (data-model §5).
///
/// Borrows the issue rather than copying it: the snapshot the caller already
/// loaded is the source of truth (C-001), and a queue of 10,000 entries that
/// cloned its issues would hold a second, silently divergent copy of every title
/// and body. A consumer that wants more than these fields reads `issue`.
///
/// `last_decision` from data-model §5 is **not** here, and the reason is a
/// boundary rather than an omission: the triage History kinds that would answer
/// it are introduced by WP03 T016, and today's vocabulary has none. Deriving it
/// from the last event of *any* kind would report a `relationship_added` as the
/// queue's most recent decision.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueueEntry<'a> {
    /// The canonical issue this row describes.
    pub issue: &'a Issue,
    /// The stamp queue age is derived from, verbatim from WP01's
    /// [`crate::triage::entry_time`] — `Unknown` for a legacy issue, never
    /// substituted with `created` or `updated`.
    pub entered_at: EntryTime<'a>,
    /// Days between [`Self::entered_at`] and the supplied `today`; `None` when
    /// the entry has no stamp or a stamp this parser cannot read.
    ///
    /// `None`, never `0`: a missing stamp and "entered today" are different
    /// facts, and a zero that means "unknown" is how an unknown age becomes a
    /// confident one.
    pub age_days: Option<i64>,
}

/// One age bucket, in the order [`AGE_BUCKETS`] declares them.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AgeBucket {
    /// The bucket's display label.
    pub label: &'static str,
    /// How many inbox entries fell in it.
    pub count: usize,
}

/// The age buckets, in presentation order, most recent first.
///
/// Presentation only (data-model §6: "presentation-friendly deterministic
/// data"). The boundaries are this module's choice and are not load-bearing:
/// every decision about an entry uses [`QueueEntry::age_days`], so a surface
/// that wants different buckets re-buckets the ages it already has rather than
/// asking for a second query.
pub const AGE_BUCKETS: [&str; 7] = ["today", "1-2", "3-6", "7-13", "14-29", "30+", "unknown"];

/// The bucket an age falls in, or `"unknown"` for an age nothing was recorded
/// for.
#[must_use]
pub fn age_bucket(age: Option<i64>) -> &'static str {
    match age {
        // `<= 0` rather than `== 0`: an entry stamped ahead of `today` is not
        // waiting a negative number of days, it is waiting to be looked at, and
        // splitting it into its own bucket would make the distribution depend
        // on a clock skew nobody asked about.
        Some(age) if age <= 0 => AGE_BUCKETS[0],
        Some(age) if age <= 2 => AGE_BUCKETS[1],
        Some(age) if age <= 6 => AGE_BUCKETS[2],
        Some(age) if age <= 13 => AGE_BUCKETS[3],
        Some(age) if age <= 29 => AGE_BUCKETS[4],
        Some(_) => AGE_BUCKETS[5],
        None => AGE_BUCKETS[6],
    }
}

/// The derived inbox summary (data-model §6).
///
/// Every field is a count over **one** pass of one snapshot (NFR-001): the
/// numbers agree with each other by construction because they are accumulated
/// together, not reconciled afterwards.
///
/// The counts are independent facts, not a partition. `awaiting + deferred ==
/// total` when the section names a deferred column of its own, and `2 * total`
/// when it does not — WP01 makes `deferred_status` default to `entry_status`,
/// so a project with one inbox column has one column and no status to tell the
/// two apart. Reporting `deferred == total` there is the truth; reporting `0`
/// would claim nothing is deferred when the config says everything is.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueueMetrics<'a> {
    /// Inbox entries — issues whose status is a configured inbox column.
    pub total: usize,
    /// Entries in the entry column: awaiting a first decision.
    pub awaiting: usize,
    /// Entries in the deferred column (see the struct note on equality).
    pub deferred: usize,
    /// Entries already in the `completed` category while still in an inbox
    /// column — accepted, but not gone.
    ///
    /// A **staleness** count, and 0 on a correctly configured board: an
    /// accepted issue leaves the inbox, so a non-zero figure reports an issue
    /// the columns disagree about. The value comes from [`is_completed`], the
    /// crate's single definition of completion, so it cannot disagree with
    /// `tix mv` (C-002).
    pub accepted: usize,
    /// Entries already in the `canceled` category while still in an inbox
    /// column: declined, but not gone. Same staleness reading as
    /// [`Self::accepted`], judged by [`WorkflowCategory::Canceled`] rather than
    /// by a column name (WP01 T004: decline *is* a move to `canceled`).
    pub declined: usize,
    /// Entries that originate a `duplicates` edge — the #169 relation vocabulary
    /// rather than a status, because duplicate is a relation and never a column
    /// (C-006).
    pub duplicated: usize,
    /// Entries with no readable [`QueueEntry::entered_at`].
    pub unknown_age: usize,
    /// The age histogram, one entry per [`AGE_BUCKETS`] label, in order.
    ///
    /// Sums to [`Self::total`] — every entry lands in exactly one bucket,
    /// `unknown` included.
    pub age_distribution: Vec<AgeBucket>,
    /// The entries themselves, in snapshot order.
    ///
    /// Not re-sorted: [`crate::storage::Store::list`] fixes the order (sorted
    /// by relative filename), so the inbox agrees with `tix list` without a
    /// second ordering rule that could disagree with it.
    pub entries: Vec<QueueEntry<'a>>,
}

/// Days between an entry stamp and `today`, or `None` when either is unreadable.
///
/// The only age arithmetic in the crate, so two surfaces cannot compute two
/// answers from one pair of dates. Date math reuses
/// [`crate::sync::date_to_epoch_days`] rather than growing a second calendar
/// parser beside `model::civil_from_days`.
///
/// `entered_at` is an RFC 3339 instant (`2026-09-30T09:12:44Z`) while `today`
/// is the `YYYY-MM-DD` [`crate::model::today`] returns, so the instant is read
/// through its date prefix. An instant written without a readable date is an
/// **unknown** age, not a zero and not `updated` (research R4): the fallback
/// discipline WP01 established for a missing stamp holds for a malformed one
/// too.
#[must_use]
pub fn age_days(entered_at: EntryTime<'_>, today: &str) -> Option<i64> {
    let EntryTime::Recorded(at) = entered_at else {
        return None;
    };
    let entered = crate::sync::date_to_epoch_days(at.get(..10)?)?;
    let today = crate::sync::date_to_epoch_days(today.trim())?;
    Some(today - entered)
}

/// Derive the inbox from one snapshot of issues (T008, T009, NFR-001).
///
/// One `for` loop, one membership test, one age computation, one category
/// lookup per member — no nested `issues × issues` pass and no second walk of
/// `issues`.
///
/// `today` is a parameter, never [`crate::model::today()`]: a query is
/// deterministic for a supplied date (contract §Queue query), which is what
/// lets a test pin an age and what keeps a long-running board from reporting
/// two different ages for one issue in one frame.
#[must_use]
pub fn collect<'a>(issues: &'a [Issue], scope: &InboxScope, today: &str) -> QueueMetrics<'a> {
    let mut metrics = QueueMetrics {
        total: 0,
        awaiting: 0,
        deferred: 0,
        accepted: 0,
        declined: 0,
        duplicated: 0,
        unknown_age: 0,
        age_distribution: Vec::new(),
        entries: Vec::new(),
    };
    for issue in issues {
        if !scope.matches(issue) {
            continue;
        }
        let entered_at = entry_time(issue);
        let age = age_days(entered_at, today);
        if age.is_none() {
            metrics.unknown_age += 1;
        }
        if issue.status.is(&scope.entry_status) {
            metrics.awaiting += 1;
        }
        if issue.status.is(&scope.deferred_status) {
            metrics.deferred += 1;
        }
        // #183's own answers, in its own vocabulary. Neither branch names a
        // column: `accepted` is "complete" as the service defines it, and
        // `declined` is the `canceled` category WP01 made decline map to. Two
        // independent `if`s rather than one `else if`, so neither count depends
        // on the other having been evaluated first.
        if is_completed(issue, &scope.resolution) {
            metrics.accepted += 1;
        }
        if workflow::category_of(issue, &scope.resolution) == Some(WorkflowCategory::Canceled) {
            metrics.declined += 1;
        }
        if issue
            .relationships
            .iter()
            .any(|r| r.kind == RelationKind::Duplicates && r.from == issue.number)
        {
            metrics.duplicated += 1;
        }
        metrics.entries.push(QueueEntry {
            issue,
            entered_at,
            age_days: age,
        });
        metrics.total += 1;
    }

    // A local first: assigning through `metrics.age_distribution = ...` while
    // the same expression reads `metrics.entries` would hold both a mutable and
    // an immutable borrow of `metrics` at once.
    let distribution = AGE_BUCKETS
        .iter()
        .map(|label| AgeBucket {
            label,
            count: metrics
                .entries
                .iter()
                .filter(|entry| age_bucket(entry.age_days) == *label)
                .count(),
        })
        .collect();
    metrics.age_distribution = distribution;
    metrics
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;
    use crate::triage::record_entry;

    /// A board that declares every category, so `[triage]` can name a
    /// `canceled` column (WP01 T004's requirement) and a `completed` one.
    const BOARD: &str = r#"issues_dir = "docs/issues"

[kanban.default]
columns = ["triage", "todo", "in progress", "in review", "done", "wontfix"]
done_column = "done"
category_map = { triage = "backlog", todo = "unstarted", "in progress" = "active", "in review" = "active", done = "completed", wontfix = "canceled" }
"#;

    const TRIAGE: &str = "[triage]\nentry_status = \"triage\"\ndeclined_status = \"wontfix\"\n";

    fn config_with(section: &str) -> Config {
        toml::from_str(&format!("{BOARD}{section}")).expect("fixture is a well-formed tix.toml")
    }

    fn issue(number: u32, status: &str) -> Issue {
        let mut issue = Issue::parse(&format!(
            "---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: {status}\nlabels: []\n\
             priority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody."
        ))
        .expect("the fixture parses");
        issue.filename = format!("2026-01-01-issue-{number}.md");
        issue
    }

    fn scope_of(config: &Config) -> InboxScope {
        scope(config)
            .expect("the fixture validates")
            .expect("the fixture configures a `[triage]` section")
    }

    // -- T007: resolve inbox scope ----------------------------------------

    #[test]
    fn triage_queue_scope_is_absent_without_a_triage_section() {
        // The default state of every legacy `tix.toml`: no inbox, and asking for
        // one is a named error rather than an empty board.
        let config = config_with("");
        assert_eq!(config.triage, None, "the fixture has no `[triage]`");
        assert_eq!(scope(&config).unwrap(), None);
        assert!(
            scope_columns(&config, "triage").is_err(),
            "a scope this project does not configure must be an error, not 0 rows"
        );
    }

    #[test]
    fn triage_queue_scope_resolves_entry_and_deferred_through_the_shared_resolver() {
        let config = config_with(TRIAGE);
        let scope = scope_of(&config);

        assert_eq!(scope.board, "default");
        assert_eq!(scope.entry_status, "triage");
        // WP01's default: no `deferred_status` key, so the entry column answers.
        assert_eq!(scope.deferred_status, "triage");
        assert_eq!(scope.columns, vec!["triage".to_string()]);
        assert_eq!(
            scope.resolution.resolve("triage"),
            Some(WorkflowCategory::Backlog),
            "the scope carries #183's answer, not this module's"
        );
    }

    #[test]
    fn triage_queue_scope_membership_is_column_membership() {
        let config = config_with(&format!("{TRIAGE}deferred_status = \"todo\"\n"));
        let scope = scope_of(&config);
        assert_eq!(
            scope.columns,
            vec!["triage".to_string(), "todo".to_string()],
            "both inbox columns, deduplicated, in board order"
        );

        assert!(scope.matches(&issue(1, "triage")), "entry column");
        assert!(scope.matches(&issue(2, "todo")), "deferred column");
        assert!(
            !scope.matches(&issue(3, "in review")),
            "not an inbox column"
        );
        assert!(!scope.matches(&issue(4, "wontfix")), "declined has left");
        assert!(!scope.matches(&issue(5, "done")), "accepted has left");
    }

    #[test]
    fn triage_queue_scope_unknown_name_is_refused_by_name() {
        let config = config_with(TRIAGE);
        let err = scope_columns(&config, "backlog").unwrap_err().to_string();
        assert!(err.contains("unknown scope `backlog`"), "{err}");
        assert!(
            err.contains("triage"),
            "the error names what would have worked: {err}"
        );
    }

    #[test]
    fn triage_queue_scope_of_a_hand_built_config_is_still_validated() {
        // `scope:` runs against whatever `Config` the caller holds, not only
        // against one that came through `Config::load_with_warning` — so a
        // section naming a column the board does not have is refused here too,
        // with WP01's own message rather than a second one.
        let config =
            config_with("[triage]\nentry_status = \"nope\"\ndeclined_status = \"wontfix\"\n");
        let err = scope(&config).unwrap_err().to_string();
        assert!(err.contains("unknown_column"), "{err}");
        assert!(err.contains("`[triage].entry_status`"), "{err}");
        assert!(
            err.contains("triage, todo"),
            "and the board's column list: {err}"
        );
    }

    // -- T008: one-pass entries and age ------------------------------------

    #[test]
    fn triage_queue_age_is_entry_stamp_against_the_supplied_today() {
        let mut entry = issue(1, "triage");
        record_entry(&mut entry, "2026-09-20T09:12:44Z", None);
        let scope = scope_of(&config_with(TRIAGE));

        let metrics = collect(std::slice::from_ref(&entry), &scope, "2026-09-30");
        assert_eq!(metrics.total, 1);
        assert_eq!(metrics.entries[0].age_days, Some(10), "ten days, not nine");
        assert_eq!(age_bucket(Some(10)), "7-13");
        assert_eq!(metrics.unknown_age, 0);
    }

    #[test]
    fn triage_queue_age_is_deterministic_for_the_date_it_is_given() {
        let mut entry = issue(1, "triage");
        record_entry(&mut entry, "2026-09-20T09:12:44Z", None);
        let scope = scope_of(&config_with(TRIAGE));
        let one = collect(std::slice::from_ref(&entry), &scope, "2026-09-30");
        let two = collect(std::slice::from_ref(&entry), &scope, "2026-09-30");
        let later = collect(std::slice::from_ref(&entry), &scope, "2026-10-02");

        assert_eq!(one, two, "same date, same answer — no wall-clock read");
        assert_eq!(
            later.entries[0].age_days,
            Some(12),
            "a later date, more days"
        );
    }

    #[test]
    fn triage_queue_legacy_entry_has_an_unknown_age_never_a_substitute() {
        // The fixture carries `created` and `updated` of 2026-01-01 — nine
        // months before the `today` below. Using either would report a wait the
        // issue never served (research R4).
        let legacy = issue(1, "triage");
        let scope = scope_of(&config_with(TRIAGE));

        let metrics = collect(std::slice::from_ref(&legacy), &scope, "2026-09-30");
        assert_eq!(metrics.total, 1, "a legacy issue is still in the inbox");
        assert_eq!(
            metrics.entries[0].age_days, None,
            "unknown, and specifically not the 272 days `updated` would imply"
        );
        assert_eq!(metrics.unknown_age, 1);
        assert_eq!(age_bucket(None), "unknown");
    }

    #[test]
    fn triage_queue_an_unreadable_stamp_is_unknown_rather_than_zero() {
        let mut malformed = issue(1, "triage");
        malformed.entered_at = Some("not a date".to_string());
        let scope = scope_of(&config_with(TRIAGE));

        let metrics = collect(std::slice::from_ref(&malformed), &scope, "2026-09-30");
        assert_eq!(metrics.entries[0].age_days, None, "not Some(0)");
        assert_eq!(metrics.unknown_age, 1, "and counted as unknown");
    }

    // -- T009: counts and stable order -------------------------------------

    #[test]
    fn triage_queue_counts_split_the_inbox_by_its_two_columns() {
        let config = config_with(&format!("{TRIAGE}deferred_status = \"todo\"\n"));
        let scope = scope_of(&config);
        let issues = vec![
            issue(1, "triage"),
            issue(2, "triage"),
            issue(3, "todo"),
            issue(4, "in review"),
        ];

        let metrics = collect(&issues, &scope, "2026-09-30");
        assert_eq!(metrics.total, 3, "the `in review` row is not in the inbox");
        assert_eq!(metrics.awaiting, 2, "two in the entry column");
        assert_eq!(metrics.deferred, 1, "one in the deferred column");
        assert_eq!(
            metrics.awaiting + metrics.deferred,
            metrics.total,
            "and with a distinct deferred column the two counts partition it"
        );
        assert_eq!(metrics.accepted, 0, "nobody has been decided yet");
        assert_eq!(metrics.declined, 0);
        assert_eq!(metrics.duplicated, 0);
        assert_eq!(
            metrics.unknown_age, 3,
            "no fixture issue carries an `entered_at`, so no fixture issue has an age"
        );
    }

    #[test]
    fn triage_queue_a_single_column_inbox_reports_the_same_count_twice() {
        // WP01 defaults `deferred_status` to `entry_status`, so a project that
        // names no deferred column has one column and no status to tell the two
        // facts apart. `deferred == total` is the truth; `0` would claim
        // nothing is deferred when the config says everything is.
        let config = config_with(TRIAGE);
        let scope = scope_of(&config);
        assert_eq!(scope.entry_status, scope.deferred_status);

        let issues = vec![issue(1, "triage"), issue(2, "triage")];
        let metrics = collect(&issues, &scope, "2026-09-30");
        assert_eq!(metrics.total, 2);
        assert_eq!(metrics.awaiting, 2);
        assert_eq!(metrics.deferred, 2, "one column, two descriptions of it");
    }

    #[test]
    fn triage_queue_counts_follow_the_category_mapping_not_the_column_name() {
        // The board maps `done` → `completed` and `wontfix` → `canceled`. Put
        // an inbox column on each and the counts must follow the *mapping*: a
        // literal `"done"` here would report the `wontfix` column as accepted,
        // and a literal `"declined"` column name would not exist at all.
        let accepted_scope = scope_of(&config_with(
            "[triage]\nentry_status = \"done\"\ndeclined_status = \"wontfix\"\n",
        ));
        let in_accepted = vec![issue(1, "done")];
        let metrics = collect(&in_accepted, &accepted_scope, "2026-09-30");
        assert_eq!(metrics.total, 1);
        assert_eq!(metrics.accepted, 1, "`done` is complete, via #183");
        assert_eq!(metrics.declined, 0, "complete is not canceled");

        let declined_scope = scope_of(&config_with(
            "[triage]\nentry_status = \"wontfix\"\ndeclined_status = \"wontfix\"\n",
        ));
        let in_declined = vec![issue(2, "wontfix")];
        let metrics = collect(&in_declined, &declined_scope, "2026-09-30");
        assert_eq!(metrics.total, 1);
        assert_eq!(metrics.declined, 1, "canceled, via #183");
        assert_eq!(
            metrics.accepted, 0,
            "canceled is terminal and never complete (C-003)"
        );
    }

    #[test]
    fn triage_queue_duplicate_is_counted_from_the_relation_vocabulary() {
        let config = config_with(TRIAGE);
        let scope = scope_of(&config);

        let mut duplicate = issue(1, "triage");
        duplicate
            .relationships
            .push(crate::relationships::Relationship::new(
                1,
                99,
                RelationKind::Duplicates,
                "Canonical",
            ));
        let mut blocked = issue(2, "triage");
        blocked
            .relationships
            .push(crate::relationships::Relationship::new(
                2,
                98,
                RelationKind::Blocks,
                "Something else",
            ));
        let plain = issue(3, "triage");
        let all = [duplicate, blocked, plain];

        let metrics = collect(&all, &scope, "2026-09-30");
        assert_eq!(metrics.total, 3, "relations do not change membership");
        assert_eq!(
            metrics.duplicated, 1,
            "only the `duplicates` edge counts, and only from its origin"
        );
    }

    #[test]
    fn triage_queue_entries_keep_the_snapshot_order() {
        // `Store::list` sorts by filename; `collect` must not re-sort, or the
        // inbox and `tix list` would disagree about which row is first.
        let issues: Vec<Issue> = [3, 1, 2].iter().map(|n| issue(*n, "triage")).collect();
        let scope = scope_of(&config_with(TRIAGE));
        let metrics = collect(&issues, &scope, "2026-09-30");

        assert_eq!(
            metrics
                .entries
                .iter()
                .map(|e| e.issue.number)
                .collect::<Vec<_>>(),
            vec![3, 1, 2],
            "input order, unchanged"
        );
    }

    #[test]
    fn triage_queue_age_distribution_sums_to_the_total() {
        // Anti-vacuity: a histogram of zeros would satisfy any per-bucket
        // assertion, so the invariant checked is the one that cannot hold if an
        // entry were dropped on the way in.
        let scope = scope_of(&config_with(TRIAGE));
        let mut issues = Vec::new();
        for (n, age) in [(1u32, 0i64), (2, 1), (3, 5), (4, 12), (5, 25)] {
            let mut entry = issue(n, "triage");
            record_entry(
                &mut entry,
                &format!("2026-09-{:02}T00:00:00Z", 30 - age),
                None,
            );
            issues.push(entry);
        }
        issues.push(issue(6, "triage")); // no stamp at all

        let metrics = collect(&issues, &scope, "2026-09-30");
        assert_eq!(metrics.total, 6);
        assert_eq!(metrics.unknown_age, 1);
        let sum: usize = metrics.age_distribution.iter().map(|b| b.count).sum();
        assert_eq!(
            sum, metrics.total,
            "every entry lands in exactly one bucket"
        );
        assert_eq!(
            metrics
                .age_distribution
                .iter()
                .map(|b| b.label)
                .collect::<Vec<_>>(),
            AGE_BUCKETS.to_vec(),
            "and in the declared order"
        );
        assert_eq!(metrics.age_distribution[0].count, 1, "the `today` bucket");
        assert_eq!(metrics.age_distribution[4].count, 1, "the `14-29` bucket");
        assert_eq!(metrics.age_distribution[6].count, 1, "the `unknown` bucket");
    }
}