use std::fs;
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use tempfile::TempDir;
fn tix() -> Command {
Command::cargo_bin("tix").unwrap()
}
fn project() -> TempDir {
let tmp = TempDir::new().unwrap();
let mut cmd = tix();
cmd.current_dir(tmp.path()).arg("init").assert().success();
tmp
}
fn stdout(dir: &Path, args: &[&str]) -> String {
let mut cmd = tix();
cmd.current_dir(dir).args(args);
let output = cmd.assert().success().get_output().clone();
String::from_utf8(output.stdout).unwrap()
}
fn failure(dir: &Path, args: &[&str]) -> (i32, String, String) {
let mut cmd = tix();
cmd.current_dir(dir).args(args);
let output = cmd.assert().failure().get_output().clone();
(
output.status.code().unwrap_or(-1),
String::from_utf8(output.stdout).unwrap(),
String::from_utf8(output.stderr).unwrap(),
)
}
fn issue_files_under(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
walk(dir, &mut out);
out.sort();
out
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = path.file_name().unwrap().to_string_lossy().to_string();
if path.is_dir() {
if name == "boards" || name.starts_with('.') {
continue;
}
walk(&path, out);
} else if path.extension().is_some_and(|e| e == "md") {
out.push(path);
}
}
}
fn issues(dir: &Path) -> Vec<PathBuf> {
issue_files_under(&dir.join("docs").join("issues"))
}
fn read_issues(dir: &Path) -> String {
issues(dir)
.iter()
.map(|p| fs::read_to_string(p).unwrap())
.collect::<Vec<_>>()
.join("\n---\n")
}
fn create_bug_template(dir: &Path) {
stdout(
dir,
&[
"template",
"create",
"--id",
"bug",
"--name",
"Bug report",
"--scope",
"repository",
"--default",
"status=todo",
"--default",
"priority=high",
"--prompt",
"key=title,kind=text,required=true",
"--prompt",
"key=labels,kind=multi-select,required=true,options_ref=labels",
"--reference",
"kind=label,value=bug,required=true",
],
);
}
#[test]
fn template_create_list_and_show_expose_defaults_prompts_and_references() {
let tmp = project();
let dir = tmp.path();
create_bug_template(dir);
let listed = stdout(dir, &["template", "list"]);
assert_eq!(
listed.lines().collect::<Vec<_>>(),
vec!["bug\tBug report\trepository"],
"{listed:?}"
);
let shown = stdout(dir, &["template", "show", "bug"]);
for expected in [
"id: bug",
"name: Bug report",
"scope: repository",
"defaults.status: todo",
"defaults.priority: high",
"prompt[0].key: title",
"prompt[0].kind: text",
"prompt[0].required: true",
"prompt[1].key: labels",
"prompt[1].kind: multi-select",
"prompt[1].options_ref: labels",
"reference[0].kind: label",
"reference[0].value: bug",
"reference[0].required: true",
] {
assert!(
shown.contains(expected),
"missing {expected:?} in:\n{shown}"
);
}
}
#[test]
fn template_list_on_a_project_with_no_templates_prints_nothing() {
let tmp = project();
assert_eq!(stdout(tmp.path(), &["template", "list"]), "");
}
#[test]
fn template_show_of_an_unknown_reference_is_not_found() {
let tmp = project();
let (code, out, err) = failure(tmp.path(), &["template", "show", "nope"]);
assert_eq!(code, 2, "stderr: {err}");
assert_eq!(out, "");
assert!(err.contains("no template with id or name `nope`"), "{err}");
}
#[test]
fn template_create_refuses_an_unknown_create_field_and_writes_nothing() {
let tmp = project();
let dir = tmp.path();
let before = fs::read(dir.join("tix.toml")).unwrap();
let (code, _, err) = failure(
dir,
&["template", "create", "--id", "bad", "--default", "nope=1"],
);
assert_eq!(code, 1, "stderr: {err}");
assert!(err.contains("`nope` is not a create field"), "{err}");
assert_eq!(fs::read(dir.join("tix.toml")).unwrap(), before);
}
#[test]
fn template_update_renames_without_touching_the_id_scope_or_data() {
let tmp = project();
let dir = tmp.path();
create_bug_template(dir);
let renamed = stdout(
dir,
&["template", "update", "bug", "--name", "Incident report"],
);
assert!(renamed.contains("updated template `bug`"), "{renamed}");
let shown = stdout(dir, &["template", "show", "bug"]);
assert!(shown.contains("id: bug"), "{shown}");
assert!(shown.contains("name: Incident report"), "{shown}");
assert!(shown.contains("scope: repository"), "{shown}");
assert!(shown.contains("defaults.priority: high"), "{shown}");
assert!(shown.contains("prompt[1].key: labels"), "{shown}");
assert!(stdout(dir, &["template", "list"]).contains("bug\tIncident report\trepository"));
}
#[test]
fn template_duplicate_allocates_a_new_id_and_leaves_the_source_intact() {
let tmp = project();
let dir = tmp.path();
create_bug_template(dir);
let duplicated = stdout(dir, &["template", "duplicate", "bug", "--id", "bug-copy"]);
assert!(
duplicated.contains("duplicated template `bug` as `bug-copy`"),
"{duplicated}"
);
let listed = stdout(dir, &["template", "list"]);
assert!(listed.contains("bug\tBug report\trepository"), "{listed}");
assert!(
listed.contains("bug-copy\tBug report\trepository"),
"{listed}"
);
let copy = stdout(dir, &["template", "show", "bug-copy"]);
assert!(copy.contains("id: bug-copy"), "{copy}");
assert!(copy.contains("defaults.priority: high"), "{copy}");
}
#[test]
fn template_duplicate_without_an_id_allocates_the_documented_copy_id() {
let tmp = project();
let dir = tmp.path();
create_bug_template(dir);
let duplicated = stdout(dir, &["template", "duplicate", "bug"]);
assert!(
duplicated.contains("duplicated template `bug` as `bug-copy`"),
"{duplicated}"
);
assert!(stdout(dir, &["template", "list"]).contains("bug-copy"));
}
#[test]
fn template_delete_removes_only_the_definition_and_never_an_issue() {
let tmp = project();
let dir = tmp.path();
create_bug_template(dir);
stdout(
dir,
&[
"create",
"--template",
"bug",
"--title",
"Crash on login",
"--labels",
"bug",
],
);
assert_eq!(issues(dir).len(), 1);
let deleted = stdout(dir, &["template", "delete", "bug"]);
assert!(deleted.contains("deleted template `bug`"), "{deleted}");
assert_eq!(stdout(dir, &["template", "list"]), "");
assert_eq!(
issues(dir).len(),
1,
"delete must never touch an issue file"
);
let (code, _, err) = failure(dir, &["template", "show", "bug"]);
assert_eq!(code, 2, "stderr: {err}");
}
#[test]
fn template_show_and_update_preserve_unknown_metadata() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
fs::write(
dir.join("tix.toml"),
"issues_dir = \"docs/issues\"\n\n\
[issue_templates.legacy]\n\
name = \"Legacy\"\n\
owner = \"team-x\"\n\
retention_days = 30\n",
)
.unwrap();
let before = stdout(dir, &["template", "show", "legacy"]);
assert!(before.contains("unknown.owner:"), "{before}");
assert!(before.contains("team-x"), "{before}");
assert!(before.contains("unknown.retention_days:"), "{before}");
stdout(
dir,
&["template", "update", "legacy", "--name", "Legacy v2"],
);
let after = stdout(dir, &["template", "show", "legacy"]);
assert!(after.contains("name: Legacy v2"), "{after}");
assert!(after.contains("unknown.owner:"), "{after}");
assert!(after.contains("team-x"), "{after}");
assert!(after.contains("retention_days"), "{after}");
}
#[test]
fn create_from_template_merges_defaults_and_writes_one_issue() {
let tmp = project();
let dir = tmp.path();
create_bug_template(dir);
let mut cmd = tix();
let output = cmd
.current_dir(dir)
.args([
"create",
"--template",
"bug",
"--title",
"Crash on login",
"--labels",
"bug",
])
.assert()
.success()
.get_output()
.clone();
let out = String::from_utf8(output.stdout).unwrap();
let err = String::from_utf8(output.stderr).unwrap();
assert!(out.starts_with("Created "), "{out}");
assert!(
err.contains("using template `bug` at scope `repository`"),
"{err}"
);
assert_eq!(issues(dir).len(), 1);
let contents = read_issues(dir);
assert!(contents.contains("priority: high"), "{contents}");
assert!(contents.contains("status: todo"), "{contents}");
assert!(contents.contains("Crash on login"), "{contents}");
}
#[test]
fn a_user_flag_overrides_a_template_default() {
let tmp = project();
let dir = tmp.path();
create_bug_template(dir);
stdout(
dir,
&[
"create",
"--template",
"bug",
"--title",
"Crash on login",
"--labels",
"bug",
"--priority",
"low",
],
);
let contents = read_issues(dir);
assert!(contents.contains("priority: low"), "{contents}");
assert!(!contents.contains("priority: high"), "{contents}");
}
#[test]
fn a_board_scoped_template_is_reachable_by_its_explicit_scope() {
let tmp = project();
let dir = tmp.path();
stdout(
dir,
&[
"template",
"create",
"--id",
"board-bug",
"--name",
"Board bug",
"--scope",
"board:default",
"--default",
"status=todo",
],
);
let mut cmd = tix();
let output = cmd
.current_dir(dir)
.args([
"create",
"--template",
"board-bug",
"--scope",
"board:default",
"--title",
"Board scoped",
])
.assert()
.success()
.get_output()
.clone();
let err = String::from_utf8(output.stderr).unwrap();
assert!(
err.contains("using template `board-bug` at scope `board:default`"),
"{err}"
);
assert_eq!(issues(dir).len(), 1);
}
#[test]
fn create_from_template_with_a_missing_required_prompt_writes_nothing() {
let tmp = project();
let dir = tmp.path();
create_bug_template(dir);
let config_before = fs::read(dir.join("tix.toml")).unwrap();
let (code, out, err) = failure(
dir,
&["create", "--template", "bug", "--title", "Crash on login"],
);
assert_eq!(code, 1, "stderr: {err}");
assert_eq!(out, "", "a failed resolution must not print `Created`");
assert!(
err.contains("template `bug` at scope `repository` did not resolve"),
"{err}"
);
assert!(
err.contains("missing-required") && err.contains("labels"),
"the missing prompt must be named: {err}"
);
assert!(issues(dir).is_empty(), "no partial issue may be written");
assert_eq!(fs::read(dir.join("tix.toml")).unwrap(), config_before);
}
#[test]
fn create_from_template_with_an_unknown_reference_writes_nothing() {
let tmp = project();
let dir = tmp.path();
stdout(
dir,
&[
"template",
"create",
"--id",
"planned",
"--name",
"Planned",
"--default",
"status=todo",
"--reference",
"kind=cycle,value=no-such-cycle,required=true",
],
);
let (code, out, err) = failure(dir, &["create", "--template", "planned", "--title", "X"]);
assert_eq!(code, 1, "stderr: {err}");
assert_eq!(out, "");
assert!(
err.contains("unknown-reference") && err.contains("no-such-cycle"),
"{err}"
);
assert!(issues(dir).is_empty());
}
#[test]
fn create_from_template_lists_every_diagnostic_at_once() {
let tmp = project();
let dir = tmp.path();
stdout(
dir,
&[
"template",
"create",
"--id",
"strict",
"--name",
"Strict",
"--default",
"status=todo",
"--prompt",
"key=title,kind=text,required=true",
"--prompt",
"key=labels,kind=multi-select,required=true,options_ref=labels",
],
);
let (code, _, err) = failure(dir, &["create", "--template", "strict"]);
assert_eq!(code, 1, "stderr: {err}");
assert!(err.contains("title"), "title diagnostic missing: {err}");
assert!(err.contains("labels"), "labels diagnostic missing: {err}");
assert!(issues(dir).is_empty());
}
#[test]
fn create_from_template_with_an_unknown_id_is_reported() {
let tmp = project();
let dir = tmp.path();
let (code, out, err) = failure(dir, &["create", "--template", "ghost", "--title", "X"]);
assert_eq!(code, 1, "stderr: {err}");
assert_eq!(out, "");
assert!(err.contains("unknown-template"), "{err}");
assert!(issues(dir).is_empty());
}
#[test]
fn create_from_template_with_a_mismatched_scope_writes_nothing() {
let tmp = project();
let dir = tmp.path();
create_bug_template(dir);
let (code, _, err) = failure(
dir,
&[
"create",
"--template",
"bug",
"--scope",
"board:default",
"--title",
"Crash",
"--labels",
"bug",
],
);
assert_eq!(code, 1, "stderr: {err}");
assert!(err.contains("scope-mismatch"), "{err}");
assert!(issues(dir).is_empty());
}
#[test]
fn create_without_a_template_behaves_exactly_as_before() {
let tmp = project();
let dir = tmp.path();
create_bug_template(dir);
let mut cmd = tix();
let output = cmd
.current_dir(dir)
.args(["create", "--title", "Plain issue", "--priority", "high"])
.assert()
.success()
.get_output()
.clone();
let out = String::from_utf8(output.stdout).unwrap();
let err = String::from_utf8(output.stderr).unwrap();
assert_eq!(out.lines().count(), 1, "stdout: {out:?}");
assert!(out.starts_with("Created "), "{out:?}");
assert!(
!err.contains("using template"),
"the no-template path must not mention a template: {err}"
);
assert_eq!(issues(dir).len(), 1);
let contents = read_issues(dir);
assert!(contents.contains("priority: high"), "{contents}");
assert!(contents.contains("Plain issue"), "{contents}");
}
#[test]
fn scope_without_a_template_is_a_usage_error() {
let tmp = project();
let dir = tmp.path();
let (code, _, err) = failure(
dir,
&["create", "--scope", "repository", "--title", "Plain"],
);
assert_eq!(code, 2, "stderr: {err}");
assert!(err.contains("--template"), "{err}");
assert!(issues(dir).is_empty());
}