use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use indexmap::IndexMap;
use tempfile::TempDir;
use tix::config::{BoardConfiguration, Config, KanbanSection};
fn tix_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_tix"))
}
const MAP: [(&str, &str); 5] = [
("triage", "backlog"),
("wip", "active"),
("review", "active"),
("shipped", "completed"),
("abandoned", "canceled"),
];
fn board() -> TempDir {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("docs").join("issues");
fs::create_dir_all(&dir).unwrap();
let config = Config {
issues_dir: dir,
kanban: KanbanSection {
configurations: IndexMap::from([(
"default".to_string(),
BoardConfiguration {
columns: MAP.iter().map(|(c, _)| (*c).to_string()).collect(),
done_column: Some("shipped".to_string()),
category_map: MAP
.iter()
.map(|(c, k)| ((*c).to_string(), (*k).to_string()))
.collect(),
..Default::default()
},
)]),
},
..Config::default()
};
config.save(&tmp.path().join("tix.toml")).unwrap();
let board = tmp.path().to_path_buf();
for i in 1..=5 {
run(&board, &["create", "--title", &format!("Issue{i}")])
.unwrap_or_else(|e| panic!("seeding issue {i}: {e}"));
}
tmp
}
fn run(dir: &Path, args: &[&str]) -> std::result::Result<Output, String> {
Command::new(tix_bin())
.current_dir(dir)
.args(args)
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@example.invalid")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@example.invalid")
.output()
.map_err(|e| e.to_string())
}
fn ok(dir: &Path, args: &[&str]) -> String {
let out = run(dir, args).expect("the binary runs");
assert!(
out.status.success(),
"tix {args:?} exited {:?}\nstdout: {}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn code(dir: &Path, args: &[&str], want: i32) -> (String, String) {
let out = run(dir, args).expect("the binary runs");
assert_eq!(
out.status.code(),
Some(want),
"tix {args:?} exited {:?}, wanted {want}\nstdout: {}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
fn snapshot(dir: &Path) -> Vec<(String, Vec<u8>)> {
let mut out = Vec::new();
let mut stack = vec![dir.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.push((
path.strip_prefix(dir)
.unwrap()
.to_string_lossy()
.to_string(),
fs::read(&path).unwrap(),
));
}
}
}
out.sort();
out
}
fn issues(dir: &Path) -> Vec<(String, Vec<u8>)> {
snapshot(&dir.join("docs").join("issues"))
}
#[test]
fn t026_the_contract_spelling_is_the_one_that_parses() {
let board = board();
let stdout = ok(
board.path(),
&["bulk", "edit", "--select", "1,2", "--status", "wip"],
);
assert!(stdout.contains("selection: 2 issues"), "{stdout}");
assert!(stdout.contains("#1 Issue1"), "{stdout}");
assert!(stdout.contains("#2 Issue2"), "{stdout}");
}
#[test]
fn t026_exactly_one_selection_grammar_is_required() {
let board = board();
for args in [
vec!["bulk", "edit", "--status", "wip"],
vec![
"bulk",
"edit",
"--select",
"1",
"--filter",
"status:wip",
"--status",
"wip",
],
vec![
"bulk", "edit", "--select", "1", "--search", "wip", "--status", "wip",
],
] {
let (_, stderr) = code(board.path(), &args, 1);
assert!(
stderr.contains("--select")
&& stderr.contains("--filter")
&& stderr.contains("--search"),
"the diagnostic must name all three grammars: {stderr}"
);
assert!(stderr.contains("will not guess"), "and say why: {stderr}");
}
}
#[test]
fn t026_each_grammar_selects_through_its_own_matcher() {
let board = board();
let by_select = ok(
board.path(),
&["bulk", "edit", "--select", "1", "--status", "wip"],
);
assert!(by_select.contains("selection: 1 issue ("), "{by_select}");
assert!(by_select.contains("selector"), "{by_select}");
ok(board.path(), &["move", "5", "abandoned"]);
let (by_filter, _) = code(
board.path(),
&[
"bulk",
"edit",
"--filter",
"status:abandoned",
"--status",
"review",
],
1,
);
assert!(by_filter.contains("filter"), "{by_filter}");
assert!(by_filter.contains("#5 Issue5"), "{by_filter}");
let by_search = ok(
board.path(),
&["bulk", "edit", "--search", "Issue3", "--status", "wip"],
);
assert!(by_search.contains("search"), "{by_search}");
assert!(by_search.contains("#3 Issue3"), "{by_search}");
let criterion = ok(
board.path(),
&[
"bulk",
"edit",
"--search",
"status:abandoned",
"--status",
"wip",
],
);
assert!(
criterion.contains("selection: 0 issues"),
"a search is a substring, not a criterion: {criterion}"
);
}
#[test]
fn t026_a_flag_the_action_does_not_take_is_a_usage_error() {
let board = board();
let out = run(
board.path(),
&["bulk", "archive", "--select", "1", "--priority", "high"],
)
.expect("the binary runs");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success(), "must not succeed");
assert!(
stderr.contains("unexpected argument") && stderr.contains("--priority"),
"clap should name the flag it does not take: {stderr}"
);
}
#[test]
fn t027_preview_is_the_default_and_writes_nothing() {
let board = board();
let before = snapshot(board.path());
let stdout = ok(
board.path(),
&[
"bulk",
"edit",
"--select",
"1,2",
"--status",
"wip",
"--estimate",
"3d",
],
);
assert_eq!(
snapshot(board.path()),
before,
"C-005: a preview is read-only — no file, no History, no tix.toml write"
);
assert!(stdout.contains("2 of 2 would change"), "{stdout}");
assert!(
!stdout.contains("applied ("),
"a preview must not use the apply vocabulary: {stdout}"
);
assert!(stdout.contains("-> wip"), "{stdout}");
assert!(stdout.contains("-> 3d"), "{stdout}");
}
#[test]
fn t027_a_refused_item_shows_the_refusal_and_nothing_else() {
let board = board();
ok(board.path(), &["move", "5", "abandoned"]);
let (stdout, _) = code(
board.path(),
&["bulk", "edit", "--select", "1,5", "--status", "review"],
1,
);
let delta = stdout
.rsplit_once("#5 Issue5")
.expect("#5 is listed")
.1
.split("\n#")
.next()
.expect("the item block");
assert!(
delta.contains("refused [status]"),
"the refusal is shown: {delta}"
);
assert!(
!delta.contains("transition_accepted"),
"and not a History intent that will not happen: {delta}"
);
assert!(
!delta.contains("status: \""),
"and not a proposed change that will not be made: {delta}"
);
}
#[test]
fn t027_apply_writes_and_reports_per_item_outcomes_and_final_paths() {
let board = board();
let stdout = ok(
board.path(),
&[
"bulk", "edit", "--select", "1,2", "--status", "wip", "--apply",
],
);
assert!(stdout.contains("#1 Issue1 -> applied ("), "{stdout}");
assert!(stdout.contains("#2 Issue2 -> applied ("), "{stdout}");
assert!(stdout.contains("final paths:"), "{stdout}");
let final_path = stdout
.lines()
.find_map(|line| line.trim().strip_prefix("#1 -> "))
.expect("a final path for #1");
assert!(
final_path.ends_with("-issue1.md"),
"the final path keeps the human slug: {final_path}"
);
assert!(
board.path().join("docs/issues").join(final_path).is_file(),
"the printed final path is the real one: {final_path}"
);
assert!(stdout.contains("2 of 2 applied"), "{stdout}");
assert!(stdout.contains("0 already in that state"), "{stdout}");
assert!(stdout.contains("0 refused"), "{stdout}");
}
#[test]
fn t027_a_retry_is_a_no_op_with_no_duplicate_event() {
let board = board();
ok(
board.path(),
&[
"bulk", "edit", "--select", "1", "--status", "wip", "--apply",
],
);
let (stdout, _) = code(
board.path(),
&[
"bulk", "edit", "--select", "1", "--status", "wip", "--apply",
],
1,
);
assert!(stdout.contains("already in that state"), "{stdout}");
assert!(stdout.contains("0 of 1 applied"), "{stdout}");
assert!(
!stdout.contains("final paths:"),
"nothing was written, so there are no final paths: {stdout}"
);
let files = issues(board.path());
let one = files
.iter()
.find(|(path, _)| path.contains("issue1"))
.expect("issue 1's file");
let text = String::from_utf8_lossy(&one.1);
assert_eq!(
text.matches("transition_accepted").count(),
1,
"one decision, not two: {text}"
);
}
#[test]
fn t027_clear_empties_a_field_and_names_the_accepted_set() {
let board = board();
ok(
board.path(),
&[
"bulk",
"edit",
"--select",
"1",
"--estimate",
"3d",
"--apply",
],
);
ok(
board.path(),
&[
"bulk", "edit", "--select", "1", "--clear", "estimate", "--apply",
],
);
let files = issues(board.path());
let one = files
.iter()
.find(|(path, _)| path.contains("issue1"))
.expect("issue 1's file");
let text = String::from_utf8_lossy(&one.1);
assert!(
!text.contains("estimate: 3d"),
"the estimate is gone: {text}"
);
for field in ["status", "body"] {
let (_, stderr) = code(
board.path(),
&["bulk", "edit", "--select", "1", "--clear", field],
1,
);
assert!(
stderr.contains(&format!("cannot clear '{field}'")),
"{stderr}"
);
assert!(
stderr.contains("clearable fields:"),
"and the accepted set is named: {stderr}"
);
}
}
#[test]
fn t027_a_blank_flag_is_unchanged_not_clear() {
let board = board();
let (_, stderr) = code(
board.path(),
&["bulk", "edit", "--select", "1", "--estimate", "", "--apply"],
1,
);
assert!(stderr.contains("no fields to change"), "{stderr}");
let (applied, _) = code(
board.path(),
&[
"bulk",
"edit",
"--select",
"1",
"--estimate",
"3d",
"--assignee",
"",
"--apply",
],
0,
);
assert!(applied.contains("1 of 1 applied"), "{applied}");
let files = issues(board.path());
let one = files
.iter()
.find(|(path, _)| path.contains("issue1"))
.expect("issue 1's file");
let text = String::from_utf8_lossy(&one.1);
assert!(text.contains("estimate: 3d"), "{text}");
assert!(
text.contains(&format!("assignee: {}", tix::storage::actor_identity())),
"and the blank assignee did not clear the identity: {text}"
);
}
#[test]
fn t027_no_patch_flag_names_the_vocabulary() {
let board = board();
let (_, stderr) = code(board.path(), &["bulk", "edit", "--select", "1"], 1);
assert!(stderr.contains("no fields to change"), "{stderr}");
for name in ["status", "priority", "assignee", "labels", "estimate"] {
assert!(stderr.contains(name), "'{name}' missing from: {stderr}");
}
}
#[test]
fn t027_a_bad_priority_uses_the_single_issue_core() {
let board = board();
let (_, stderr) = code(
board.path(),
&["bulk", "edit", "--select", "1", "--priority", "bogus"],
1,
);
assert!(
stderr.contains("invalid priority 'bogus'"),
"the shared message: {stderr}"
);
let (_, single) = code(board.path(), &["edit", "1", "--priority", "bogus"], 1);
assert!(single.contains("invalid priority 'bogus'"), "{single}");
}
#[test]
fn t028_link_previews_then_applies() {
let board = board();
let before = snapshot(board.path());
let stdout = ok(
board.path(),
&[
"bulk", "link", "--select", "1", "--target", "2", "--type", "blocks",
],
);
assert_eq!(
snapshot(board.path()),
before,
"a link preview writes nothing"
);
assert!(stdout.contains("would link"), "{stdout}");
let applied = ok(
board.path(),
&[
"bulk", "link", "--select", "1", "--target", "2", "--type", "blocks", "--apply",
],
);
assert!(
applied.contains("1 relationship operation applied"),
"{applied}"
);
let listed = ok(board.path(), &["link", "list", "1"]);
assert!(listed.contains("#2"), "the edge is listed: {listed}");
assert!(listed.contains("blocks"), "{listed}");
}
#[test]
fn t028_a_missing_target_is_a_diagnostic_and_writes_nothing() {
let board = board();
let before = snapshot(board.path());
let (stdout, _) = code(
board.path(),
&[
"bulk", "link", "--select", "1", "--target", "no-such", "--type", "blocks", "--apply",
],
1,
);
assert!(stdout.contains("refused"), "{stdout}");
assert!(stdout.contains("no-such"), "the target is named: {stdout}");
assert_eq!(snapshot(board.path()), before, "and nothing was written");
}
#[test]
fn t028_a_link_retry_is_a_no_op() {
let board = board();
let args = [
"bulk", "link", "--select", "1", "--target", "2", "--type", "blocks", "--apply",
];
ok(board.path(), &args);
let (stdout, _) = code(board.path(), &args, 1);
assert!(
stdout.contains("already in that state"),
"a retry is reported, never silent: {stdout}"
);
let listed = ok(board.path(), &["link", "list", "1"]);
assert_eq!(
listed.matches("blocks").count(),
1,
"one relationship, not two: {listed}"
);
}
#[test]
fn t028_archive_previews_then_applies_and_a_re_run_is_a_no_op() {
let board = board();
let before = snapshot(board.path());
let stdout = ok(board.path(), &["bulk", "archive", "--select", "1,2"]);
assert_eq!(snapshot(board.path()), before, "a preview archives nothing");
assert!(
stdout.contains("archive: would archive 2 issues"),
"the preview names the selection size, not a literal: {stdout}"
);
assert!(
stdout.contains("2 issues would be archived"),
"and the summary agrees with the line above it: {stdout}"
);
assert!(
!stdout.contains("1 issue would be archived"),
"the count is never the old hard-coded 1: {stdout}"
);
assert!(
!stdout.contains("delegation pending"),
"the delegation is no longer pending: {stdout}"
);
let applied = ok(
board.path(),
&["bulk", "archive", "--select", "1,2", "--apply"],
);
assert!(
applied.contains("selection: 2 issues"),
"the selection is named: {applied}"
);
assert!(
applied.contains("#1 Issue1 -> applied") && applied.contains("#2 Issue2 -> applied"),
"one outcome per selected item: {applied}"
);
assert!(
applied.contains("2 of 2 applied; 0 already in that state; 0 refused"),
"the apply count line is complete: {applied}"
);
assert!(applied.contains("final paths:"), "{applied}");
let after = snapshot(board.path());
let dot: Vec<&String> = after
.iter()
.map(|(p, _)| p)
.filter(|p| {
Path::new(p)
.file_name()
.is_some_and(|b| b.to_string_lossy().starts_with('.'))
})
.collect();
assert_eq!(dot.len(), 2, "two files gained the marker: {after:?}");
assert_eq!(
after.len(),
before.len(),
"archiving renames in place: no file added or removed"
);
let listed = ok(board.path(), &["list"]);
assert!(
!listed.contains("Issue1") && !listed.contains("Issue2"),
"archived issues are hidden by default: {listed}"
);
let with_archived = ok(board.path(), &["list", "--with-archived"]);
for n in 1..=5 {
assert!(
with_archived.contains(&format!("Issue{n}")),
"issue {n} is still there: {with_archived}"
);
}
let (again, stderr) = code(
board.path(),
&["bulk", "archive", "--select", "1,2", "--apply"],
1,
);
assert!(
again.contains("already in that state"),
"a retry is reported, never silent: {again}"
);
assert!(
again.contains("0 of 2 applied; 2 already in that state; 0 refused"),
"the no-ops are counted: {again}"
);
assert!(
stderr.is_empty(),
"a reported no-op is not an error on stderr: {stderr}"
);
assert_eq!(
snapshot(board.path()),
after,
"and a no-op writes no bytes and appends no second event"
);
}
#[test]
fn t029_a_partial_batch_exits_non_zero_and_keeps_every_outcome() {
let board = board();
ok(board.path(), &["move", "5", "abandoned"]);
let (stdout, _) = code(
board.path(),
&[
"bulk", "edit", "--select", "1,5", "--status", "review", "--apply",
],
1,
);
assert!(stdout.contains("#1 Issue1 -> applied"), "{stdout}");
assert!(stdout.contains("#5 Issue5 -> refused"), "{stdout}");
assert!(stdout.contains("1 of 2 applied"), "every count: {stdout}");
assert!(stdout.contains("1 refused"), "{stdout}");
assert!(
stdout.contains("final paths:"),
"and the paths that did change: {stdout}"
);
assert!(
!stdout.contains("rolled back") && !stdout.contains("reverted"),
"the output must not imply a rollback: {stdout}"
);
let moved = ok(board.path(), &["view", "1"]);
assert!(moved.contains("review"), "#1 is in review: {moved}");
}
#[test]
fn t029_a_successful_batch_exits_zero() {
let first = board();
code(
first.path(),
&[
"bulk", "edit", "--select", "1,2", "--status", "wip", "--apply",
],
0,
);
let fresh = board();
ok(fresh.path(), &["move", "5", "abandoned"]);
code(
fresh.path(),
&["bulk", "edit", "--select", "1,5", "--status", "review"],
1,
);
code(
fresh.path(),
&["bulk", "edit", "--select", "1,2", "--status", "review"],
0,
);
}
#[test]
fn t029_an_empty_selection_is_not_a_failure() {
let board = board();
let stdout = ok(
board.path(),
&[
"bulk",
"edit",
"--search",
"no-such-issue-anywhere",
"--status",
"wip",
],
);
assert!(stdout.contains("selection: 0 issues"), "{stdout}");
assert!(stdout.contains("0 of 0 would change"), "{stdout}");
}
#[test]
fn t030_the_existing_single_issue_commands_are_unchanged() {
let board = board();
ok(board.path(), &["view", "1"]);
ok(board.path(), &["list"]);
let searched = ok(board.path(), &["search", "Issue2"]);
assert!(searched.contains("Issue2"), "{searched}");
ok(board.path(), &["edit", "1", "--estimate", "5d"]);
ok(board.path(), &["move", "1", "wip"]);
let moved = ok(board.path(), &["view", "1"]);
assert!(moved.contains("wip"), "{moved}");
ok(board.path(), &["link", "1", "2", "--type", "blocks"]);
let listed = ok(board.path(), &["link", "list", "1"]);
assert!(listed.contains("blocks"), "{listed}");
ok(board.path(), &["unlink", "1", "2", "--type", "blocks"]);
let after = ok(board.path(), &["link", "list", "1"]);
assert!(!after.contains("blocks"), "the edge is gone: {after}");
let help = ok(board.path(), &["bulk", "--help"]);
assert!(help.contains("edit"), "{help}");
assert!(help.contains("archive"), "{help}");
}
#[test]
fn t030_bulk_uses_the_frozen_exit_code_table() {
let board = board();
let (_, stderr) = code(
board.path(),
&[
"bulk",
"edit",
"--select",
"no-such-issue",
"--status",
"wip",
],
2,
);
assert!(stderr.contains("not found"), "{stderr}");
ok(board.path(), &["create", "--title", "shared-thing"]);
ok(board.path(), &["create", "--title", "shared-thing"]);
let (_, stderr) = code(
board.path(),
&[
"bulk",
"edit",
"--select",
"shared-thing",
"--status",
"wip",
],
2,
);
assert!(stderr.contains("ambiguous"), "{stderr}");
let (_, _) = code(board.path(), &["bulk", "edit", "--status", "wip"], 1);
}
#[test]
fn t030_the_agents_documentation_covers_the_batch_surface() {
let doc = tix::commands::bulk::agents_doc();
for expected in [
"tix bulk edit",
"tix bulk link",
"tix bulk unlink",
"tix bulk archive",
"--select",
"--filter",
"--search",
"--apply",
"rolled back",
"delegates to the store's archive service",
] {
assert!(doc.contains(expected), "'{expected}' missing from:\n{doc}");
}
for field in tix::commands::bulk::PatchFlags::clearable() {
assert!(doc.contains(field), "'{field}' missing from:\n{doc}");
}
for field in tix::bulk::IssuePatch::field_names() {
assert!(doc.contains(field), "'{field}' missing from:\n{doc}");
}
}