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};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InboxScope {
pub board: String,
pub entry_status: String,
pub deferred_status: String,
pub columns: Vec<String>,
pub resolution: CategoryResolution,
}
impl InboxScope {
#[must_use]
pub fn matches(&self, issue: &Issue) -> bool {
self.columns.iter().any(|c| issue.status.is(c))
}
#[must_use]
pub fn column_list(&self) -> String {
self.resolution.column_list()
}
}
pub fn scope(config: &Config) -> Result<Option<InboxScope>> {
let Some(triage) = config.triage.as_ref() else {
return Ok(None);
};
crate::triage::config::validate(config)?;
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());
}
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,
}))
}
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(),
)),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueueEntry<'a> {
pub issue: &'a Issue,
pub entered_at: EntryTime<'a>,
pub age_days: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AgeBucket {
pub label: &'static str,
pub count: usize,
}
pub const AGE_BUCKETS: [&str; 7] = ["today", "1-2", "3-6", "7-13", "14-29", "30+", "unknown"];
#[must_use]
pub fn age_bucket(age: Option<i64>) -> &'static str {
match age {
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],
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueueMetrics<'a> {
pub total: usize,
pub awaiting: usize,
pub deferred: usize,
pub accepted: usize,
pub declined: usize,
pub duplicated: usize,
pub unknown_age: usize,
pub age_distribution: Vec<AgeBucket>,
pub entries: Vec<QueueEntry<'a>>,
}
#[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)
}
#[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;
}
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;
}
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;
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")
}
#[test]
fn triage_queue_scope_is_absent_without_a_triage_section() {
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");
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() {
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}"
);
}
#[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() {
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");
}
#[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() {
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() {
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() {
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() {
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"));
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");
}
}