use std::fs;
use std::path::Path;
use tempfile::TempDir;
use tix::bulk::{Selection, SelectionScope, SelectionSource};
use tix::config::Config;
use tix::error::TixError;
use tix::model::HistoryEventKind;
use tix::model::Issue;
use tix::relationships::RelationKind;
use tix::storage::{IssueSelector, Store};
use tix::triage::batch::{self, TriagePlan};
use tix::triage::{DecisionKind, TriageDecision};
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 = "\n[triage]\nentry_status = \"triage\"\ndeclined_status = \"wontfix\"\n";
const AT: &str = "2026-09-30T12:00:00Z";
const ACTOR: &str = "alice";
struct Fixture {
_tmp: TempDir,
store: Store,
}
impl Fixture {
fn dir(&self) -> &Path {
self.store.issues_dir.as_path()
}
fn snapshot(&self) -> Vec<(String, Vec<u8>)> {
snapshot(self.dir())
}
fn select(&self, numbers: &[u32]) -> Selection {
let raw: Vec<String> = numbers.iter().map(ToString::to_string).collect();
Selection::resolve(
&self.store,
&raw,
None,
SelectionSource::Explicit,
SelectionScope::Active,
)
.expect("the fixture selection resolves")
}
fn plan(&self, numbers: &[u32], kind: DecisionKind) -> TriagePlan {
let mut request = TriageDecision::new(kind, AT, ACTOR, "confirmed scope");
if kind == DecisionKind::Accept {
request.target_status = Some("todo".to_string());
}
batch::build_plan(&self.store, self.select(numbers), request, None)
.expect("the fixture plan builds")
}
fn plan_of(&self, numbers: &[u32], of: Option<&str>) -> TriagePlan {
let request = TriageDecision::new(DecisionKind::Duplicate, AT, ACTOR, "confirmed scope");
batch::build_plan(&self.store, self.select(numbers), request, of)
.expect("the fixture plan builds")
}
fn read(&self, number: u32) -> Issue {
self.store
.get(IssueSelector::Number(number))
.expect("the issue is still there")
}
}
fn fixture(section: &str) -> Fixture {
let tmp = TempDir::new().expect("a fixture directory");
let dir = tmp.path().join("docs/issues");
fs::create_dir_all(&dir).expect("the issues directory");
let config = toml::from_str::<Config>(&format!(
"{BOARD}{section}\n[layout]\nmove_on_change = false\n"
))
.expect("fixture config parses");
Fixture {
_tmp: tmp,
store: Store {
issues_dir: dir,
config,
},
}
}
fn seed(store: &Store, number: u32, title: &str, status: &str) {
seed_in(store, None, number, title, status);
}
fn seed_in(store: &Store, segment: Option<&str>, number: u32, title: &str, status: &str) {
let mut issue = parse(number, title, status);
let basename = format!("2026-09-01-issue-{number}.md");
issue.filename = match segment {
Some(segment) => format!("{segment}/{basename}"),
None => basename,
};
let path = store.issues_dir.join(&issue.filename);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("the segment directory");
}
fs::write(&path, issue.to_file_contents()).expect("the fixture file is written");
}
fn parse(number: u32, title: &str, status: &str) -> Issue {
Issue::parse(&format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\n\
labels: []\npriority: medium\nfiles: []\ncreated: 2026-09-01\n\
updated: 2026-09-01\nentered_at: 2026-09-20T00:00:00Z\nsource: new\n\
---\n\nBody text."
))
.expect("the fixture issue parses")
}
fn snapshot(dir: &Path) -> Vec<(String, Vec<u8>)> {
let mut found = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
for entry in fs::read_dir(¤t).expect("the fixture directory is readable") {
let entry = entry.expect("a directory entry");
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let name = path
.strip_prefix(dir)
.expect("the entry is under the snapshot root")
.to_string_lossy()
.into_owned();
found.push((name, fs::read(&path).expect("the fixture file is readable")));
}
}
found.sort();
found
}
fn kinds(issue: &Issue) -> Vec<&'static str> {
issue
.history
.events()
.iter()
.map(|event| event.kind.as_str())
.collect()
}
#[test]
fn triage_batch_plan_resolves_the_selection_once_and_leaves_the_tree_byte_identical() {
let f = fixture(TRIAGE);
for number in [1, 2, 3] {
seed(&f.store, number, &format!("Issue {number}"), "triage");
}
let before = f.snapshot();
let plan = f.plan(&[3, 1, 2], DecisionKind::Decline);
assert_eq!(
plan.items
.iter()
.map(|item| item.number)
.collect::<Vec<_>>(),
vec![3, 1, 2],
"one item per selected issue, in selection order"
);
assert_eq!(plan.confirmation, None, "a fresh plan is unconfirmed");
assert_eq!(
f.snapshot(),
before,
"building a plan must not write a byte"
);
}
#[test]
fn triage_batch_plan_proposes_the_status_and_entry_changes_a_decision_would_make() {
let f = fixture(TRIAGE);
seed(&f.store, 1, "Issue 1", "triage");
let plan = f.plan(&[1], DecisionKind::Accept);
let item = &plan.items[0];
let Some(tix::bulk::FieldChange::Set { from, to }) = item.changes.get("status") else {
panic!("an accept that moves the column proposes a status change");
};
assert_eq!(from.as_str(), "triage");
assert_eq!(to.as_str(), "todo");
assert!(
matches!(
item.changes.get("entered_at"),
Some(tix::bulk::FieldChange::Clear { .. })
),
"it left the queue, so the stamp goes: {:?}",
item.changes
);
assert!(
matches!(
item.changes.get("source"),
Some(tix::bulk::FieldChange::Clear { .. })
),
"and its origin goes with it: {:?}",
item.changes
);
assert_eq!(item.history.len(), 1, "exactly one event is predicted");
assert_eq!(
item.history[0].kind,
HistoryEventKind::TriageAccepted.as_str()
);
assert!(item.history[0].detail.contains("triage -> todo"));
assert_eq!(item.status(), "applied");
}
#[test]
fn triage_batch_a_duplicate_changes_no_field_and_still_asks_for_one_event() {
let f = fixture(TRIAGE);
seed(&f.store, 1, "Issue 1", "triage");
seed(&f.store, 9, "Target", "todo");
let plan = f.plan_of(&[1], Some("9"));
let item = &plan.items[0];
assert!(
item.changes.is_empty(),
"a duplicate touches no field: {:?}",
item.changes
);
assert_eq!(item.history.len(), 1);
assert_eq!(
item.history[0].kind,
HistoryEventKind::TriageDuplicated.as_str()
);
assert!(
item.history[0].detail.contains("duplicates #9"),
"the preview names the edge it would create: {}",
item.history[0].detail
);
assert!(item.is_actionable(), "an event alone is still a write");
assert_eq!(item.status(), "applied");
}
#[test]
fn triage_batch_plan_refuses_one_item_and_keeps_the_rest_previewable() {
let f = fixture(TRIAGE);
seed(&f.store, 1, "Issue 1", "triage");
seed(&f.store, 2, "Issue 2", "triage");
seed(&f.store, 5, "Issue 5", "todo");
let plan = f.plan(&[1, 2, 5], DecisionKind::Decline);
assert_eq!(plan.actionable().count(), 2);
assert_eq!(plan.blocked().count(), 1);
let refused = plan.blocked().next().expect("the one refused item");
assert_eq!(refused.number, 5);
let diagnostic = refused
.diagnostic
.as_ref()
.expect("a blocked item has a diagnostic");
assert!(
diagnostic.message.starts_with("not_in_triage"),
"the contract's kind, not prose: {}",
diagnostic.message
);
assert_eq!(
diagnostic.field, "decision",
"the refusal is about the decision"
);
assert_eq!(refused.status(), "rejected");
}
#[test]
fn triage_batch_of_is_refused_for_a_decision_that_cannot_have_one() {
let f = fixture(TRIAGE);
seed(&f.store, 1, "Issue 1", "triage");
seed(&f.store, 9, "Target", "todo");
let request = TriageDecision::new(DecisionKind::Decline, AT, ACTOR, "confirmed scope");
let error = batch::build_plan(&f.store, f.select(&[1]), request, Some("9"))
.expect_err("a non-duplicate batch with `--of` is refused");
assert!(
matches!(&error, TixError::InvalidInput(message)
if message.contains("a duplicate target was named")),
"the refusal names the argument by meaning: {error}"
);
}
#[test]
fn triage_batch_an_ambiguous_of_propagates_the_selector_diagnostic() {
let f = fixture(TRIAGE);
for number in [1, 2, 3] {
seed(&f.store, number, "Shared target", "triage");
}
let request = TriageDecision::new(DecisionKind::Duplicate, AT, ACTOR, "confirmed scope");
let error = batch::build_plan(&f.store, f.select(&[1]), request, Some("Shared"))
.map(|_| ())
.expect_err("`--of` matching three issues cannot be resolved");
assert!(
matches!(&error, TixError::InvalidInput(message) if message.starts_with("ambiguous selector")),
"the existing ambiguity message: {error}"
);
}
#[test]
fn triage_batch_a_missing_of_is_a_per_item_refusal_and_not_a_plan_error() {
let f = fixture(TRIAGE);
for number in [1, 2, 3] {
seed(&f.store, number, &format!("Issue {number}"), "triage");
}
let before = f.snapshot();
let plan = f.plan_of(&[1, 2, 3], None);
assert_eq!(
plan.items.len(),
3,
"a missing target is per-item, not fatal"
);
for item in &plan.items {
let diagnostic = item.diagnostic.as_ref().expect("every item is refused");
assert!(
diagnostic.message.starts_with("invalid_duplicate_target"),
"WP03's kind, not a batch-local one: {}",
diagnostic.message
);
assert_eq!(diagnostic.issue, item.number, "the refusal names its issue");
assert_eq!(item.status(), "rejected");
}
assert_eq!(f.snapshot(), before, "refusing writes nothing");
}
#[test]
fn triage_batch_plan_a_duplicate_the_relationship_service_refuses() {
let f = fixture(TRIAGE);
let mut first = parse(1, "Issue 1", "triage");
first.filename = "2026-09-01-issue-1.md".to_string();
first
.relationships
.push(tix::relationships::Relationship::new(
1,
9,
RelationKind::Blocks,
"Target",
));
fs::write(
f.store.issues_dir.join(&first.filename),
first.to_file_contents(),
)
.expect("the fixture file is written");
seed(&f.store, 2, "Issue 2", "triage");
seed(&f.store, 9, "Target", "todo");
let plan = f.plan_of(&[1, 2], Some("9"));
let refused = plan.blocked().next().expect("issue 1 is refused");
assert_eq!(refused.number, 1);
let diagnostic = refused
.diagnostic
.as_ref()
.expect("a blocked item has a diagnostic");
assert_eq!(diagnostic.field, "relationship");
assert!(
diagnostic.message.starts_with("conflicting_edge"),
"#169's kind, not a batch-local one: {}",
diagnostic.message
);
assert_eq!(
plan.actionable().count(),
1,
"the other item is still previewable"
);
assert_eq!(f.snapshot().len(), 3, "a refused proposal writes nothing");
}
#[test]
fn triage_batch_plan_a_project_without_an_inbox_is_refused() {
let f = fixture("");
seed(&f.store, 1, "Issue 1", "triage");
let request = TriageDecision::new(DecisionKind::Decline, AT, ACTOR, "confirmed scope");
let error = batch::build_plan(&f.store, f.select(&[1]), request, None)
.map(|_| ())
.expect_err("a project with no inbox cannot plan a triage batch");
assert!(
matches!(&error, TixError::InvalidInput(_)),
"the opt-out is an invalid input, not a panic: {error}"
);
}
#[test]
fn triage_batch_apply_is_refused_without_a_confirmation_and_writes_nothing() {
let f = fixture(TRIAGE);
seed(&f.store, 1, "Issue 1", "triage");
let before = f.snapshot();
let plan = f.plan(&[1], DecisionKind::Decline);
let error = batch::apply(&f.store, &plan).expect_err("an unconfirmed plan cannot be applied");
assert!(
matches!(&error, TixError::InvalidInput(message) if message.contains("confirmation")),
"the gate says what is missing: {error}"
);
assert_eq!(f.snapshot(), before, "the gate fired before any write");
}
#[test]
fn triage_batch_a_confirmation_from_another_selection_is_refused() {
let f = fixture(TRIAGE);
for number in [1, 2, 3] {
seed(&f.store, number, &format!("Issue {number}"), "triage");
}
let before = f.snapshot();
let mut stale = f.plan(&[1, 2], DecisionKind::Decline);
stale.confirm();
let mut plan = f.plan(&[1, 2, 3], DecisionKind::Decline);
plan.confirmation = stale.confirmation;
assert!(!plan.confirmation_is_valid());
assert!(matches!(
batch::apply(&f.store, &plan),
Err(TixError::InvalidInput(_))
));
assert_eq!(f.snapshot(), before, "a stale confirmation writes nothing");
}
#[test]
fn triage_batch_every_selected_issue_receives_exactly_one_outcome_in_order() {
let f = fixture(TRIAGE);
seed(&f.store, 1, "Issue 1", "triage");
seed(&f.store, 2, "Issue 2", "triage");
seed(&f.store, 5, "Issue 5", "todo");
let mut plan = f.plan(&[1, 2, 5], DecisionKind::Decline);
plan.confirm();
let report = batch::apply(&f.store, &plan).expect("a confirmed plan applies");
let numbers: Vec<u32> = report.outcomes.iter().map(|(number, _)| *number).collect();
assert_eq!(
numbers,
vec![1, 2, 5],
"one outcome per selected issue, in order"
);
assert_eq!(report.outcomes.len(), 3);
assert_eq!(report.applied(), 2);
assert_eq!(report.rejected(), 1);
assert_eq!(report.failed(), 0);
}
#[test]
fn triage_batch_an_empty_selection_proposes_and_applies_nothing() {
let f = fixture(TRIAGE);
let mut plan = f.plan(&[], DecisionKind::Decline);
assert_eq!(plan.summary(), "0 of 0 would change");
plan.confirm();
let report = batch::apply(&f.store, &plan).expect("an empty plan applies");
assert!(report.outcomes.is_empty());
assert_eq!(
report.summary(),
"0 of 0 applied; 0 already in that state; 0 refused"
);
}
#[test]
fn triage_batch_only_applied_outcomes_append_a_decision_event() {
let f = fixture(TRIAGE);
seed(&f.store, 1, "Issue 1", "triage");
seed(&f.store, 2, "Issue 2", "triage");
seed(&f.store, 5, "Issue 5", "todo");
let refused_before = fs::read_to_string(f.store.issues_dir.join(f.read(5).filename))
.expect("the untouched issue is readable");
let mut first = f.plan(&[1, 2, 5], DecisionKind::Decline);
first.confirm();
let first = batch::apply(&f.store, &first).expect("the first batch applies");
assert_eq!(first.applied(), 2);
assert_eq!(first.rejected(), 1);
let mut second = f.plan(&[1, 2, 5], DecisionKind::Decline);
second.confirm();
let second = batch::apply(&f.store, &second).expect("the second batch applies");
assert_eq!(
second.noop(),
2,
"the two that declined report the no-op explicitly"
);
assert_eq!(second.rejected(), 1);
assert_eq!(
kinds(&f.read(1)),
vec![HistoryEventKind::TriageDeclined.as_str()]
);
assert_eq!(
kinds(&f.read(2)),
vec![HistoryEventKind::TriageDeclined.as_str()]
);
assert!(
kinds(&f.read(5)).is_empty(),
"a rejected item appends nothing"
);
assert_eq!(
fs::read_to_string(f.store.issues_dir.join(f.read(5).filename)).expect("readable"),
refused_before,
"a rejected item's file is untouched"
);
}
#[test]
fn triage_batch_a_duplicate_writes_one_event_and_one_typed_relation_and_nothing_else() {
let f = fixture(TRIAGE);
seed(&f.store, 1, "Issue 1", "triage");
seed(&f.store, 2, "Issue 2", "triage");
seed(&f.store, 9, "Target", "todo");
let mut plan = f.plan_of(&[1, 2], Some("9"));
plan.confirm();
let report = batch::apply(&f.store, &plan).expect("a confirmed duplicate applies");
assert_eq!(report.applied(), 2);
for number in [1, 2] {
let issue = f.read(number);
assert_eq!(
kinds(&issue),
vec![HistoryEventKind::TriageDuplicated.as_str()],
"#{number} carries the decision and nothing else"
);
assert_eq!(issue.relationships.len(), 1, "#{number} gained one edge");
assert_eq!(issue.relationships[0].kind, RelationKind::Duplicates);
assert_eq!(issue.relationships[0].to, 9);
assert_eq!(
issue.status.display(),
"triage",
"a duplicate moves no column"
);
}
assert!(
kinds(&f.read(9)).is_empty(),
"the target's own History is untouched"
);
}
#[test]
fn triage_batch_a_mixed_selection_keeps_every_item_that_succeeded() {
let f = fixture(TRIAGE);
seed(&f.store, 1, "Issue 1", "triage");
seed(&f.store, 3, "Issue 3", "triage");
seed(&f.store, 5, "Issue 5", "todo");
let refused_before =
fs::read_to_string(f.store.issues_dir.join(f.read(5).filename)).expect("readable");
let mut plan = f.plan(&[1, 5, 3], DecisionKind::Decline);
plan.confirm();
let report = batch::apply(&f.store, &plan).expect("the batch applies");
assert_eq!(report.applied(), 2);
assert_eq!(report.rejected(), 1);
assert_eq!(report.failed(), 0, "nothing failed; one was refused");
assert_eq!(report.outcomes.len(), 3, "no item was dropped");
assert_eq!(f.read(1).status.display(), "wontfix");
assert_eq!(f.read(3).status.display(), "wontfix");
assert_eq!(
fs::read_to_string(f.store.issues_dir.join(f.read(5).filename)).expect("readable"),
refused_before,
"the refused item did not undo, and was not undone by, its neighbours"
);
assert_eq!(
report.summary(),
"2 of 3 applied; 0 already in that state; 1 refused"
);
}
#[test]
fn triage_batch_a_mixed_batch_judges_each_item_against_the_fresh_board() {
let f = fixture(TRIAGE);
seed(&f.store, 1, "Issue 1", "triage");
seed(&f.store, 2, "Issue 2", "triage");
let mut plan = f.plan(&[1, 2], DecisionKind::Decline);
plan.confirm();
let mut moved = f.read(2);
moved.status = tix::model::Status::new("done");
fs::write(
f.store.issues_dir.join(&moved.filename),
moved.to_file_contents(),
)
.expect("the fixture is updated");
let report = batch::apply(&f.store, &plan).expect("a refusal is not a batch failure");
assert_eq!(
report.outcomes.len(),
2,
"the report still has two outcomes"
);
assert_eq!(report.applied(), 1);
assert_eq!(report.rejected(), 1);
let tix::bulk::ItemOutcome::Rejected(reason) = &report.outcomes[1].1 else {
panic!("the moved item is refused: {:?}", report.outcomes[1].1);
};
assert!(
reason.starts_with("not_in_triage"),
"the refusal the service would give: {reason}"
);
assert!(kinds(&f.read(1)).len() == 1, "the neighbour kept its write");
assert!(
kinds(&f.read(2)).is_empty(),
"the refused item appended nothing"
);
}
#[test]
fn triage_batch_a_mixed_batch_reports_an_item_whose_file_disappeared() {
let f = fixture(TRIAGE);
seed(&f.store, 1, "Issue 1", "triage");
seed(&f.store, 2, "Issue 2", "triage");
let gone = f.read(2).filename.clone();
let mut plan = f.plan(&[1, 2], DecisionKind::Decline);
plan.confirm();
fs::remove_file(f.store.issues_dir.join(&gone)).expect("the fixture is removed");
let report = batch::apply(&f.store, &plan).expect("a per-item failure is not a batch failure");
assert_eq!(
report.outcomes.len(),
2,
"the report still has two outcomes"
);
assert_eq!(report.applied(), 1);
assert_eq!(report.failed(), 1);
let tix::bulk::ItemOutcome::Failed { item, error } = &report.outcomes[1].1 else {
panic!(
"the missing item reports a failure: {:?}",
report.outcomes[1].1
);
};
assert_eq!(item.issue, 2);
assert_eq!(item.field, "read");
assert!(
!error.starts_with("write_failed"),
"nothing was written, so nothing claims to have failed to write: {error}"
);
assert!(kinds(&f.read(1)).len() == 1, "the neighbour kept its write");
}
#[test]
fn triage_batch_cancelling_changes_nothing() {
let f = fixture(TRIAGE);
for number in [1, 2, 3] {
seed(&f.store, number, &format!("Issue {number}"), "triage");
}
let before = f.snapshot();
let plan = f.plan(&[1, 2, 3], DecisionKind::Accept);
assert!(batch::apply(&f.store, &plan).is_err());
let mut plan = plan;
plan.confirm();
assert!(plan.confirmation_is_valid());
let mut changed = f.plan(&[1, 2], DecisionKind::Accept);
changed.confirmation = plan.confirmation.clone();
assert!(batch::apply(&f.store, &changed).is_err());
assert_eq!(f.snapshot(), before, "neither path wrote a byte");
}
#[test]
#[cfg(unix)]
fn triage_batch_a_write_failure_is_named_and_leaves_the_other_items_applied() {
use std::os::unix::fs::PermissionsExt;
let f = fixture(TRIAGE);
seed(&f.store, 1, "Issue 1", "triage");
seed_in(&f.store, Some("carol"), 3, "Issue 3", "triage");
let segment = f.store.issues_dir.join("carol");
let blocked_before = fs::read(f.store.issues_dir.join("carol/2026-09-01-issue-3.md"))
.expect("the blocked issue is readable");
let mut plan = f.plan(&[1, 3], DecisionKind::Decline);
plan.confirm();
let mut perms = fs::metadata(&segment)
.expect("the segment exists")
.permissions();
perms.set_mode(0o555);
fs::set_permissions(&segment, perms).expect("the segment is made unwritable");
let report = batch::apply(&f.store, &plan);
let mut perms = fs::metadata(&segment)
.expect("the segment exists")
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&segment, perms).expect("the fixture is restored");
let report = report.expect("a per-item failure is not a batch failure");
assert_eq!(report.applied(), 1);
assert_eq!(report.failed(), 1);
assert_eq!(report.rejected(), 0);
let (number, outcome) = &report.outcomes[1];
assert_eq!(*number, 3, "the outcome is where the selection put it");
let tix::bulk::ItemOutcome::Failed { item, error } = outcome else {
panic!("the unwritable item reports a failure, not something quieter: {outcome:?}");
};
assert_eq!(item.issue, 3);
assert_eq!(item.field, "write");
assert!(
error.starts_with("write_failed"),
"the contract's kind is on the line a surface prints: {error}"
);
assert_eq!(report.write_failures.len(), 1);
assert_eq!(report.write_failures[0].issue, 3);
assert_eq!(
f.read(1).status.display(),
"wontfix",
"the item that could be written kept its write"
);
assert_eq!(
fs::read(f.store.issues_dir.join("carol/2026-09-01-issue-3.md")).expect("readable"),
blocked_before,
"the item that could not be written is untouched"
);
assert!(
kinds(&f.read(3)).is_empty(),
"a failed write appends no event"
);
}
#[test]
fn triage_batch_accept_decline_duplicate_and_defer_each_run_as_a_batch() {
let f = fixture(TRIAGE);
for number in 1..=8 {
seed(&f.store, number, &format!("Issue {number}"), "triage");
}
seed(&f.store, 9, "Target", "todo");
let mut accepted = f.plan(&[1, 2], DecisionKind::Accept);
accepted.confirm();
let accepted = batch::apply(&f.store, &accepted).expect("accept applies");
assert_eq!(accepted.applied(), 2);
let mut declined = f.plan(&[3, 4], DecisionKind::Decline);
declined.confirm();
let declined = batch::apply(&f.store, &declined).expect("decline applies");
assert_eq!(declined.applied(), 2);
let mut duplicated = f.plan_of(&[5, 6], Some("9"));
duplicated.confirm();
let duplicated = batch::apply(&f.store, &duplicated).expect("duplicate applies");
assert_eq!(duplicated.applied(), 2);
let mut deferred = f.plan(&[7, 8], DecisionKind::Defer);
deferred.confirm();
let deferred = batch::apply(&f.store, &deferred).expect("defer applies");
assert_eq!(deferred.applied(), 2);
assert_eq!(f.read(1).status.display(), "todo");
assert_eq!(f.read(2).status.display(), "todo");
assert_eq!(f.read(1).entered_at, None, "it left the queue");
assert_eq!(f.read(3).status.display(), "wontfix");
assert_eq!(f.read(4).status.display(), "wontfix");
assert_eq!(f.read(5).relationships.len(), 1);
assert_eq!(f.read(5).relationships[0].kind, RelationKind::Duplicates);
assert_eq!(f.read(6).relationships.len(), 1);
assert_eq!(
f.read(5).status.display(),
"triage",
"a duplicate stays queued"
);
assert_eq!(f.read(7).status.display(), "triage");
assert_eq!(
f.read(7).entered_at.as_deref(),
Some(AT),
"the wait restarted"
);
assert_eq!(f.read(7).source.as_deref(), Some("defer"));
assert_eq!(
kinds(&f.read(1)),
vec![HistoryEventKind::TriageAccepted.as_str()]
);
assert_eq!(
kinds(&f.read(3)),
vec![HistoryEventKind::TriageDeclined.as_str()]
);
assert_eq!(
kinds(&f.read(5)),
vec![HistoryEventKind::TriageDuplicated.as_str()]
);
assert_eq!(
kinds(&f.read(7)),
vec![HistoryEventKind::TriageDeferred.as_str()]
);
}
#[test]
fn triage_batch_a_defer_batch_refreshes_the_entry_and_repeats_as_a_no_op() {
let f = fixture(TRIAGE);
for number in [1, 2] {
seed(&f.store, number, &format!("Issue {number}"), "triage");
}
let mut plan = f.plan(&[1, 2], DecisionKind::Defer);
plan.confirm();
let report = batch::apply(&f.store, &plan).expect("the first defer applies");
assert_eq!(report.applied(), 2);
assert_eq!(f.read(1).entered_at.as_deref(), Some(AT));
assert_eq!(f.read(1).source.as_deref(), Some("defer"));
assert_eq!(
f.read(1).status.display(),
"triage",
"defer moves no column"
);
let mut again = f.plan(&[1, 2], DecisionKind::Defer);
again.confirm();
let again = batch::apply(&f.store, &again).expect("the second defer applies");
assert_eq!(
again.noop(),
2,
"an identical defer is explicit, not silent"
);
assert_eq!(kinds(&f.read(1)).len(), 1, "one event, not two");
assert_eq!(kinds(&f.read(2)).len(), 1, "one event, not two");
}
#[test]
fn triage_batch_history_counts_match_the_outcomes_exactly() {
let f = fixture(TRIAGE);
seed(&f.store, 1, "Issue 1", "triage");
seed(&f.store, 2, "Issue 2", "triage");
seed(&f.store, 5, "Issue 5", "todo");
let before: Vec<usize> = [1, 2, 5]
.iter()
.map(|number| f.read(*number).history.events().len())
.collect();
let mut plan = f.plan(&[1, 2, 5], DecisionKind::Accept);
plan.confirm();
let report = batch::apply(&f.store, &plan).expect("the batch applies");
for (index, (number, outcome)) in report.outcomes.iter().enumerate() {
let after = f.read(*number).history.events().len();
if outcome.is_applied() {
assert_eq!(
after,
before[index] + 1,
"#{number} was applied and gained exactly one event"
);
} else {
assert_eq!(
after, before[index],
"#{number} was not applied and gained none"
);
}
}
}
#[test]
fn triage_batch_the_preview_and_a_cancel_leave_a_canonical_tree_byte_identical() {
let f = fixture(TRIAGE);
seed(&f.store, 1, "Issue 1", "triage");
seed_in(&f.store, Some("alice"), 2, "Issue 2", "triage");
seed(&f.store, 9, "Target", "todo");
let canonical = f.snapshot();
assert_eq!(canonical.len(), 3, "two queued issues and one target");
let _ = f.plan(&[1, 2], DecisionKind::Decline);
assert_eq!(f.snapshot(), canonical, "the preview left the tree alone");
let mut plan = f.plan_of(&[1, 2], Some("9"));
plan.confirmation = None;
let _ = batch::apply(&f.store, &plan);
assert_eq!(f.snapshot(), canonical, "the cancel left the tree alone");
}
#[test]
fn triage_batch_the_applied_control_really_changes_the_tree() {
let f = fixture(TRIAGE);
seed(&f.store, 1, "Issue 1", "triage");
let before = f.snapshot();
let mut plan = f.plan(&[1], DecisionKind::Decline);
plan.confirm();
let report = batch::apply(&f.store, &plan).expect("the control applies");
assert_eq!(report.applied(), 1);
let (number, outcome) = &report.outcomes[0];
assert_eq!(
*number, 1,
"the control's outcome is where the selection put it"
);
let tix::bulk::ItemOutcome::Applied { filename } = outcome else {
panic!("the control was applied, not something quieter: {outcome:?}");
};
assert_eq!(
filename, "2026-09-01-issue-1.md",
"the outcome names the file it wrote, which is what a surface prints"
);
assert_eq!(
outcome.as_line(1, "Issue 1"),
"#1 Issue 1 -> applied (2026-09-01-issue-1.md)",
"the shared status line every surface prints (NFR-003)"
);
assert_ne!(f.snapshot(), before, "an applied decision writes");
assert_eq!(f.read(1).status.display(), "wontfix");
assert_eq!(f.read(1).entered_at, None, "it left the queue");
assert_eq!(
kinds(&f.read(1)),
vec![HistoryEventKind::TriageDeclined.as_str()]
);
}
#[test]
fn triage_batch_summary_counts_actionable_noop_and_refused_separately() {
let f = fixture(TRIAGE);
seed(&f.store, 1, "Issue 1", "triage");
seed(&f.store, 5, "Issue 5", "todo");
seed(&f.store, 6, "Issue 6", "triage");
let mut plan = f.plan(&[1, 5, 6], DecisionKind::Decline);
plan.confirm();
batch::apply(&f.store, &plan).expect("the first pass applies");
let plan = f.plan(&[1, 5, 6], DecisionKind::Decline);
assert_eq!(
plan.summary(),
"0 of 3 would change; 2 already in that state; 1 refused"
);
assert!(
plan.actionable().next().is_none(),
"no item would write anything"
);
assert_eq!(
plan.blocked().count(),
1,
"an empty plan still names what it refused, rather than saying nothing to do"
);
}
#[test]
fn triage_batch_a_missing_duplicate_target_refuses_every_item_and_writes_nothing() {
let f = fixture(TRIAGE);
for number in [1, 2, 3] {
seed(&f.store, number, &format!("Issue {number}"), "triage");
}
let before = f.snapshot();
let mut plan = f.plan_of(&[1, 2, 3], None);
plan.confirm();
let report = batch::apply(&f.store, &plan).expect("a refusal is not an error");
assert_eq!(report.outcomes.len(), 3);
assert_eq!(report.rejected(), 3);
assert_eq!(report.applied(), 0);
assert_eq!(report.failed(), 0);
for number in [1, 2, 3] {
assert!(
kinds(&f.read(number)).is_empty(),
"#{number} appended nothing"
);
}
assert_eq!(f.snapshot(), before, "nothing was written");
}
#[test]
#[cfg(unix)]
fn triage_batch_a_failed_layout_reconcile_rolls_the_item_back_within_its_transaction() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().expect("a fixture directory");
let dir = tmp.path().join("docs/issues");
fs::create_dir_all(&dir).expect("the issues directory");
let config = toml::from_str::<Config>(&format!(
"{BOARD}{TRIAGE}\n[layout]\ndirectory = \"{{assignee}}\"\nmove_on_change = true\n"
))
.expect("fixture config parses");
let f = Fixture {
_tmp: tmp,
store: Store {
issues_dir: dir,
config,
},
};
seed_in(&f.store, Some("staging"), 1, "Issue 1", "triage");
let unassigned = f.store.issues_dir.join("unassigned");
fs::create_dir_all(&unassigned).expect("the rendered segment exists");
let mut plan = f.plan(&[1], DecisionKind::Decline);
plan.confirm();
let before = f.snapshot();
let mut permissions = fs::metadata(&unassigned).unwrap().permissions();
permissions.set_mode(0o555);
fs::set_permissions(&unassigned, permissions).unwrap();
let report = batch::apply(&f.store, &plan);
let mut permissions = fs::metadata(&unassigned).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&unassigned, permissions).unwrap();
let report = report.expect("a per-item failure is not a batch failure");
assert_eq!(report.failed(), 1, "{report:?}");
assert_eq!(
f.snapshot(),
before,
"the item's decision write must be rolled back when its reconcile fails"
);
}