use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use assert_cmd::Command;
use tempfile::TempDir;
use tix::config::Config;
use tix::model::Issue;
use tix::search::Corpus;
use tix::storage::Store;
use tix::triage;
const BOARD: &str = r#"
[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 = "\n[triage]\nentry_status = \"triage\"\ndeclined_status = \"wontfix\"\n";
const TODAY: &str = "2026-09-30";
fn repo(section: &str) -> (TempDir, PathBuf) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let text = format!("issues_dir = \"{}\"{}{}", dir.display(), BOARD, section);
fs::write(tmp.path().join("tix.toml"), text).unwrap();
(tmp, dir)
}
fn load(tmp: &TempDir) -> Config {
Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {})
.expect("the fixture tix.toml loads")
}
fn write_issue(
dir: &Path,
file: &str,
number: u32,
title: &str,
status: &str,
entered_at: Option<&str>,
) {
let mut text = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\n\
priority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n"
);
if let Some(at) = entered_at {
text.push_str(&format!("entered_at: {at}\nsource: test\n"));
}
text.push_str("---\n\nBody text.");
fs::write(dir.join(file), text).unwrap();
}
fn tix() -> Command {
let mut c = Command::cargo_bin("tix").expect("the tix binary must build");
c.env("NO_COLOR", "1").env("TERM", "dumb");
c
}
fn snapshot(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
let mut out = BTreeMap::new();
let mut stack = vec![root.to_path_buf()];
while let Some(cur) = stack.pop() {
for entry in fs::read_dir(&cur).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
} else {
out.insert(path.clone(), fs::read(&path).unwrap());
}
}
}
out
}
fn scope_of(tmp: &TempDir) -> Option<triage::inbox::InboxScope> {
triage::inbox::scope(&load(tmp)).expect("the fixture validates")
}
fn collect_inbox<'a>(
config: &Config,
corpus: &'a Corpus,
today: &str,
) -> Result<Option<triage::inbox::QueueMetrics<'a>>, tix::error::TixError> {
let Some(scope) = triage::inbox::scope(config)? else {
return Ok(None);
};
Ok(Some(triage::inbox::collect(&corpus.issues, &scope, today)))
}
#[test]
fn triage_queue_scope_is_none_for_a_project_that_configures_no_inbox() {
let (tmp, _dir) = repo("");
assert_eq!(load(&tmp).triage, None, "the fixture has no `[triage]`");
assert_eq!(scope_of(&tmp), None, "so it has no scope either");
}
#[test]
fn triage_queue_scope_names_the_configured_entry_and_deferred_columns() {
let (tmp, _dir) = repo(&format!("{TRIAGE}deferred_status = \"todo\"\n"));
let scope = scope_of(&tmp).expect("the fixture configures an inbox");
assert_eq!(scope.board, "default");
assert_eq!(scope.entry_status, "triage");
assert_eq!(scope.deferred_status, "todo");
assert_eq!(
scope.columns,
vec!["triage".to_string(), "todo".to_string()],
"both inbox columns, deduplicated, in board order"
);
assert_eq!(
scope.resolution.resolve("triage"),
Some(triage_scope_backlog()),
"#183's answer, carried rather than re-derived"
);
}
fn triage_scope_backlog() -> tix::workflow::WorkflowCategory {
tix::workflow::WorkflowCategory::from_key("backlog").expect("`backlog` is in the vocabulary")
}
#[test]
fn triage_queue_single_entry_reports_the_age_of_the_supplied_date() {
let (tmp, dir) = repo(TRIAGE);
write_issue(
&dir,
"2026-01-01-waiting.md",
1,
"Waiting",
"triage",
Some("2026-09-20T09:12:44Z"),
);
let config = load(&tmp);
let store = Store::open(&config).unwrap();
let corpus = Corpus::build(&store).unwrap();
let metrics = collect_inbox(&config, &corpus, TODAY)
.unwrap()
.expect("the fixture configures an inbox");
assert_eq!(metrics.total, 1);
assert_eq!(metrics.entries[0].age_days, Some(10), "ten days, not nine");
assert_eq!(metrics.unknown_age, 0);
assert_eq!(metrics.entries[0].issue.number, 1);
}
#[test]
fn triage_queue_legacy_entry_is_in_the_inbox_with_an_unknown_age() {
let (tmp, dir) = repo(TRIAGE);
write_issue(&dir, "2026-01-01-legacy.md", 1, "Legacy", "triage", None);
let config = load(&tmp);
let store = Store::open(&config).unwrap();
let corpus = Corpus::build(&store).unwrap();
let metrics = collect_inbox(&config, &corpus, TODAY).unwrap().unwrap();
assert_eq!(metrics.total, 1, "a legacy issue is still in the inbox");
assert_eq!(metrics.entries[0].age_days, None, "and has no honest age");
assert_eq!(metrics.unknown_age, 1);
assert_eq!(metrics.entries[0].entered_at, triage::EntryTime::Unknown);
}
#[test]
fn triage_queue_a_move_into_the_inbox_records_the_moved_origin() {
let (tmp, _dir) = repo(TRIAGE);
let config = load(&tmp);
let mut moved = Issue::parse(
"---\nnumber: 2\ntitle: \"Moved\"\nstatus: \"triage\"\nlabels: []\n\
priority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.",
)
.expect("the fixture parses");
assert!(
triage::record_move_entry(&config, &mut moved).expect("the fixture validates"),
"landing in the entry column stamps the entry"
);
assert_eq!(moved.source.as_deref(), Some("moved"));
assert!(moved.entered_at.is_some(), "and records when it entered");
let scope = triage::inbox::scope(&config)
.expect("the fixture validates")
.expect("the fixture configures an inbox");
let metrics = triage::inbox::collect(std::slice::from_ref(&moved), &scope, TODAY);
assert_eq!(metrics.unknown_age, 0, "a moved entry has an age");
assert_eq!(
metrics.entries[0].entered_at,
triage::EntryTime::Recorded(moved.entered_at.as_deref().unwrap())
);
let mut outside = Issue::parse(
"---\nnumber: 3\ntitle: \"Outside\"\nstatus: \"todo\"\nlabels: []\n\
priority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.",
)
.expect("the fixture parses");
assert!(
!triage::record_move_entry(&config, &mut outside).expect("the fixture validates"),
"an ordinary column is not an entry"
);
assert!(outside.entered_at.is_none() && outside.source.is_none());
}
#[test]
fn triage_queue_empty_repo_has_an_empty_inbox() {
let (tmp, _dir) = repo(TRIAGE);
let config = load(&tmp);
let store = Store::open(&config).unwrap();
let corpus = Corpus::build(&store).unwrap();
let metrics = collect_inbox(&config, &corpus, TODAY).unwrap().unwrap();
assert_eq!(metrics.total, 0);
assert_eq!(metrics.entries.len(), 0);
assert!(
metrics.age_distribution.iter().all(|b| b.count == 0),
"and no phantom buckets"
);
}
fn mixed_repo() -> (TempDir, PathBuf) {
let (tmp, dir) = repo(&format!("{TRIAGE}deferred_status = \"todo\"\n"));
write_issue(
&dir,
"2026-01-01-alpha.md",
1,
"Alpha waits",
"triage",
Some("2026-09-28T00:00:00Z"),
);
write_issue(
&dir,
"2026-01-01-bravo.md",
2,
"Bravo deferred",
"todo",
None,
);
write_issue(
&dir,
"2026-01-01-charlie.md",
3,
"Charlie outside",
"in review",
Some("2026-09-28T00:00:00Z"),
);
(tmp, dir)
}
#[test]
fn triage_queue_counts_split_the_mixed_board_and_drop_the_outsider() {
let (tmp, _dir) = mixed_repo();
let config = load(&tmp);
let store = Store::open(&config).unwrap();
let corpus = Corpus::build(&store).unwrap();
let metrics = collect_inbox(&config, &corpus, TODAY).unwrap().unwrap();
assert_eq!(
metrics.total, 2,
"the entry column and the deferred column; `in review` is not in the inbox"
);
assert_eq!(metrics.awaiting, 1, "Alpha awaits");
assert_eq!(metrics.deferred, 1, "Bravo is deferred");
assert_eq!(metrics.accepted, 0, "nobody has been decided");
assert_eq!(metrics.declined, 0);
assert_eq!(metrics.duplicated, 0);
assert_eq!(metrics.unknown_age, 1, "Bravo carries no entry stamp");
let sum: usize = metrics.age_distribution.iter().map(|b| b.count).sum();
assert_eq!(
sum, metrics.total,
"every entry lands in exactly one bucket"
);
}
#[test]
fn triage_queue_entries_keep_the_store_order() {
let (tmp, _dir) = mixed_repo();
let config = load(&tmp);
let store = Store::open(&config).unwrap();
let corpus = Corpus::build(&store).unwrap();
let metrics = collect_inbox(&config, &corpus, TODAY).unwrap().unwrap();
let listed: Vec<String> = store
.list()
.unwrap()
.into_iter()
.filter(|i| metrics.entries.iter().any(|e| e.issue.number == i.number))
.map(|i| i.filename)
.collect();
let in_box: Vec<String> = metrics
.entries
.iter()
.map(|e| e.issue.filename.clone())
.collect();
assert_eq!(in_box, listed, "same rows, same order, no second sort");
}
#[test]
fn triage_queue_scope_filter_selects_the_inbox_through_tix_list() {
let (tmp, _dir) = mixed_repo();
let out = tix()
.current_dir(tmp.path())
.args(["list", "--filter", "scope:triage"])
.output()
.unwrap();
let text = String::from_utf8_lossy(&out.stdout).to_string();
assert!(out.status.success(), "a valid scope is not an error");
assert!(text.contains("Alpha waits"), "an entry member:\n{text}");
assert!(
text.contains("Bravo deferred"),
"a deferred member:\n{text}"
);
assert!(
!text.contains("Charlie outside"),
"and nothing outside the inbox:\n{text}"
);
}
#[test]
fn triage_queue_scope_filter_selects_the_inbox_through_tix_search() {
let (tmp, _dir) = mixed_repo();
let out = tix()
.current_dir(tmp.path())
.args(["search", "scope:triage"])
.output()
.unwrap();
let text = String::from_utf8_lossy(&out.stdout).to_string();
assert!(out.status.success(), "{text}");
assert!(text.contains("Alpha waits"), "{text}");
assert!(text.contains("Bravo deferred"), "{text}");
assert!(!text.contains("Charlie outside"), "{text}");
}
#[test]
fn triage_queue_scope_combines_with_an_existing_criterion() {
let (tmp, _dir) = mixed_repo();
let out = tix()
.current_dir(tmp.path())
.args(["list", "--filter", "scope:triage Alpha"])
.output()
.unwrap();
let text = String::from_utf8_lossy(&out.stdout).to_string();
assert!(out.status.success(), "{text}");
assert!(text.contains("Alpha waits"), "{text}");
assert!(!text.contains("Bravo deferred"), "AND, not OR:\n{text}");
}
#[test]
fn triage_queue_default_operational_views_are_untouched() {
let (tmp, _dir) = mixed_repo();
let out = tix()
.current_dir(tmp.path())
.args(["list", "--all"])
.output()
.unwrap();
let text = String::from_utf8_lossy(&out.stdout).to_string();
assert!(out.status.success(), "{text}");
assert!(text.contains("Alpha waits"), "{text}");
assert!(text.contains("Bravo deferred"), "{text}");
assert!(
text.contains("Charlie outside"),
"the default view must keep showing what the scope filters out:\n{text}"
);
}
#[test]
fn triage_queue_an_unscoped_criterion_still_selects() {
let (tmp, _dir) = mixed_repo();
let out = tix()
.current_dir(tmp.path())
.args(["list", "--filter", "Alpha"])
.output()
.unwrap();
let text = String::from_utf8_lossy(&out.stdout).to_string();
assert!(out.status.success(), "{text}");
assert!(text.contains("Alpha waits"), "{text}");
assert!(
!text.contains("Charlie outside"),
"a filter with no scope must still be a filter:\n{text}"
);
}
#[test]
fn triage_queue_unknown_scope_is_a_named_error_not_an_empty_board() {
let (tmp, _dir) = mixed_repo();
let out = tix()
.current_dir(tmp.path())
.args(["list", "--filter", "scope:backlog"])
.output()
.unwrap();
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
!out.status.success(),
"a typo must not read as 'no matches'"
);
assert!(err.contains("unknown scope `backlog`"), "{err}");
assert!(err.contains("triage"), "and name what would work: {err}");
}
#[test]
fn triage_queue_scope_on_a_repo_without_a_triage_section_is_an_error() {
let (tmp, _dir) = repo("");
let out = tix()
.current_dir(tmp.path())
.args(["list", "--filter", "scope:triage"])
.output()
.unwrap();
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(!out.status.success(), "{err}");
assert!(err.contains("scope:triage"), "{err}");
assert!(err.contains("[triage]"), "{err}");
}
#[test]
fn triage_queue_query_writes_nothing_to_the_tree() {
let (tmp, _dir) = mixed_repo();
let config = load(&tmp);
let store = Store::open(&config).unwrap();
let before = snapshot(tmp.path());
assert!(
!before.is_empty(),
"the fixture has files, or this assertion compares nothing with nothing"
);
let corpus = Corpus::build(&store).unwrap();
let metrics = collect_inbox(&config, &corpus, TODAY).unwrap().unwrap();
assert_eq!(metrics.total, 2, "and the query actually ran");
let after = snapshot(tmp.path());
assert_eq!(
before, after,
"a queue query reads a snapshot; it must leave every byte where it found it"
);
}
#[test]
fn triage_queue_query_needs_no_second_pass_over_the_tree() {
let (tmp, dir) = repo(TRIAGE);
write_issue(
&dir,
"2026-01-01-waiting.md",
1,
"Waiting",
"triage",
Some("2026-09-20T09:12:44Z"),
);
let config = load(&tmp);
let store = Store::open(&config).unwrap();
let corpus = Corpus::build(&store).unwrap();
drop(store);
std::fs::remove_dir_all(&dir).expect("the tree goes away before the query runs");
let metrics = collect_inbox(&config, &corpus, TODAY)
.unwrap()
.expect("the fixture configures an inbox");
assert_eq!(
metrics.total, 1,
"the snapshot came from the build, not from a second trip to disk"
);
assert_eq!(metrics.entries[0].age_days, Some(10));
assert_eq!(
metrics
.age_distribution
.iter()
.map(|b| b.count)
.sum::<usize>(),
1
);
}
#[test]
fn triage_queue_nfr001_ten_thousand_entries_derive_in_one_pass_under_500ms() {
const TOTAL: u32 = 10_000;
const ENTRY: u32 = 7_000;
const DEFERRED: u32 = 2_000;
let (tmp, _dir) = repo(&format!("{TRIAGE}deferred_status = \"todo\"\n"));
let scope = scope_of(&tmp).expect("the fixture configures an inbox");
let issues: Vec<Issue> = (0..TOTAL)
.map(|i| {
let status = if i < ENTRY {
"triage"
} else if i < ENTRY + DEFERRED {
"todo"
} else {
"in review"
};
let entered_at = if i % 2 == 0 {
Some("2026-09-20T00:00:00Z")
} else {
None
};
let mut issue = Issue::parse(&format!(
"---\nnumber: {}\ntitle: \"Issue {i}\"\nstatus: \"{status}\"\nlabels: []\n\
priority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n\
{}---\n\nBody.",
i + 1,
entered_at.map_or(String::new(), |at| format!("entered_at: {at}\n")),
))
.expect("the generated fixture parses");
issue.filename = format!("issue-{i:05}.md");
issue
})
.collect();
let mut samples = Vec::new();
let mut metrics = None;
for _ in 0..5 {
let started = Instant::now();
let derived = triage::inbox::collect(&issues, &scope, TODAY);
samples.push(started.elapsed());
metrics = Some(derived);
}
samples.sort();
let median = samples[samples.len() / 2];
let metrics = metrics.expect("the loop ran at least once");
let expected_members = (ENTRY + DEFERRED) as usize;
assert_eq!(
metrics.total, expected_members,
"the 1,000 outsiders are out"
);
assert_eq!(metrics.awaiting, ENTRY as usize);
assert_eq!(metrics.deferred, DEFERRED as usize);
assert_eq!(
metrics.accepted, 0,
"nothing in this fixture has been decided"
);
assert_eq!(metrics.declined, 0);
assert_eq!(metrics.duplicated, 0);
assert_eq!(
metrics.unknown_age,
expected_members / 2,
"the odd-numbered members carry no stamp"
);
assert_eq!(metrics.entries.len(), expected_members);
let sum: usize = metrics.age_distribution.iter().map(|b| b.count).sum();
assert_eq!(
sum, expected_members,
"the histogram accounts for every row"
);
assert_eq!(
metrics.age_distribution[3].count,
expected_members / 2,
"the stamped half is ten days old, in the `7-13` bucket"
);
assert_eq!(
metrics.age_distribution[6].count,
expected_members / 2,
"and the unstamped half is unknown"
);
assert!(
median < Duration::from_millis(500),
"NFR-001: 10,000 entries must derive in under 500 ms, took {median:?}"
);
}