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}