use std::fs;
use std::path::Path;
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::TempDir;
use tix::config::Config;
fn tix() -> Command {
Command::cargo_bin("tix").unwrap()
}
fn without_git_identity() -> Command {
let mut cmd = tix();
cmd.env("GIT_CONFIG_GLOBAL", "/nonexistent-gitconfig")
.env("GIT_CONFIG_SYSTEM", "/nonexistent-gitconfig")
.env("GIT_CONFIG_NOSYSTEM", "1");
cmd
}
fn git_author(dir: &Path, name: &str, email: &str) {
for args in [
&["init", "-q"][..],
&["config", "user.name", name][..],
&["config", "user.email", email][..],
] {
let status = std::process::Command::new("git")
.current_dir(dir)
.args(args)
.status()
.expect("`git` must be available to run the CLI tests");
assert!(status.success(), "git {args:?} failed");
}
}
fn read_config(dir: &Path) -> tix::error::Result<Config> {
Config::load_with_warning(&dir.join("tix.toml"), |_| {})
}
#[test]
fn init_seeds_planning_without_git_identity() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
without_git_identity()
.current_dir(dir)
.arg("init")
.assert()
.success()
.stdout(predicate::str::contains("Created"));
let cfg = read_config(dir).unwrap();
assert_eq!(cfg.planning.cycle_number, 1);
assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Default"));
assert_eq!(cfg.planning.default_milestone.as_deref(), Some("Backlog"));
assert_eq!(
cfg.planning.default_team, None,
"no git identity → no default team"
);
assert!(cfg.teams.is_empty(), "no team without a git identity");
assert_eq!(cfg.cycles.len(), 1);
assert!(cfg.cycles.contains_key("Default"));
assert_eq!(cfg.milestones_dir, Some("docs/milestones".into()));
let raw = fs::read_to_string(dir.join("tix.toml")).unwrap();
assert!(
raw.contains("[planning]"),
"seed must serialize `[planning]`:\n{raw}"
);
assert!(
raw.contains("[cycles.Default]") || raw.contains(r#"[cycles."Default"]"#),
"seed cycle key:\n{raw}"
);
assert!(
!raw.contains("[kanban"),
"board records belong in docs/boards/:\n{raw}"
);
assert!(
!raw.contains("card_order"),
"card_order belongs in docs/boards/:\n{raw}"
);
}
#[test]
fn init_seeds_default_team_from_git_author() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
git_author(dir, "Herb Mason", "herb@example.com");
tix().current_dir(dir).arg("init").assert().success();
let cfg = read_config(dir).unwrap();
assert_eq!(cfg.planning.default_team.as_deref(), Some("default"));
let team = cfg.teams.get("default").unwrap();
assert_eq!(team.members, ["Herb Mason <herb@example.com>"]);
assert!(cfg.cycles.contains_key("Default"));
}
#[test]
fn init_creates_backlog_milestone_file() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
without_git_identity()
.current_dir(dir)
.arg("init")
.assert()
.success();
let milestones = dir.join("docs").join("milestones");
let entries: Vec<String> = fs::read_dir(&milestones)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(
entries.len(),
1,
"exactly one milestone file in {milestones:?}"
);
let file_name = &entries[0];
assert!(file_name.ends_with("-backlog.md"), "got {file_name}");
let prefix = file_name.trim_end_matches("-backlog.md");
assert!(
tix::model::validate_date(prefix),
"date prefix {prefix} must be a calendar date"
);
let raw = fs::read_to_string(milestones.join(file_name)).unwrap();
assert!(
raw.starts_with("---\n"),
"must start with a `---` delimiter"
);
assert!(raw.contains("name: Backlog"), "frontmatter name:\n{raw}");
assert!(
raw.contains(&format!("created: {prefix}")),
"created must match the filename date prefix:\n{raw}"
);
assert!(raw.contains("updated:"), "frontmatter updated:\n{raw}");
assert!(!raw.contains("target_date"), "seed has no due date:\n{raw}");
assert!(
raw.contains("Default milestone for unscheduled work"),
"seed body:\n{raw}"
);
}
#[test]
fn init_refuses_to_overwrite() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
without_git_identity()
.current_dir(dir)
.arg("init")
.assert()
.success();
let first = read_config(dir).unwrap();
without_git_identity()
.current_dir(dir)
.arg("init")
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("refusing to overwrite"));
let second = read_config(dir).unwrap();
assert_eq!(first, second);
}
#[test]
fn init_scaffold_places_the_keybinding_example_in_its_own_file() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
without_git_identity()
.current_dir(dir)
.arg("init")
.assert()
.success();
let raw = fs::read_to_string(dir.join("tix.toml")).unwrap();
assert!(!raw.contains("keybindings"));
assert!(!raw.contains("[board]"));
let keybindings = fs::read_to_string(dir.join(tix::keybindings::KEYBINDINGS_FILE)).unwrap();
assert!(keybindings.contains("# [board]"));
assert!(keybindings.contains("# new_issue = [\"ctrl+n\"]"));
assert!(!keybindings.contains("\n[board]"));
assert!(!keybindings.contains("move_left ="));
assert!(raw.contains("# [templates.bug]"));
assert!(raw.contains("# labels = [\"bug\"]"));
assert!(!raw.contains("\n[templates."));
assert!(raw.contains("# [repos]"));
assert!(raw.contains("# parent_repo ="));
assert!(!raw.contains("\n[repos]"));
assert!(raw.contains("tags_dir = \"docs/tags\""), "{raw}");
assert!(raw.contains("# [type_colors]"), "{raw}");
assert!(raw.contains("types = ["), "{raw}");
}
#[test]
fn uncommented_init_template_example_loads() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
without_git_identity()
.current_dir(dir)
.arg("init")
.assert()
.success();
let path = dir.join("tix.toml");
let raw = fs::read_to_string(&path).unwrap();
let active = raw
.replace("# [templates.bug]", "[templates.bug]")
.replace("# labels = [\"bug\"]", "labels = [\"bug\"]")
.replace(
"# body = \"## Steps to reproduce\\n\\n## Expected\\n\"",
"body = \"## Steps to reproduce\\n\\n## Expected\\n\"",
);
fs::write(&path, active).unwrap();
let config = read_config(dir).unwrap();
let bug = config
.templates
.get("bug")
.expect("the documented example loads as a template");
assert_eq!(bug.labels, ["bug"]);
assert_eq!(
bug.body.as_deref(),
Some("## Steps to reproduce\n\n## Expected\n"),
"the documented TOML escapes load as real newlines"
);
}
#[test]
fn uncommented_init_keybinding_example_loads() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
without_git_identity()
.current_dir(dir)
.arg("init")
.assert()
.success();
let path = dir.join(tix::keybindings::KEYBINDINGS_FILE);
let raw = fs::read_to_string(&path).unwrap();
let active = raw
.replace("# [board]", "[board]")
.replace("# new_issue = [\"ctrl+n\"]", "new_issue = [\"ctrl+n\"]");
fs::write(&path, active).unwrap();
let bindings = tix::keybindings::KeyBindings::load_effective(dir).unwrap();
assert_eq!(
bindings.label(
tix::keybindings::InputContext::Board,
tix::keybindings::TuiAction::NewIssue
),
"Ctrl+n"
);
assert_eq!(
bindings.label(
tix::keybindings::InputContext::Board,
tix::keybindings::TuiAction::Open
),
"Enter"
);
}
#[test]
fn init_scaffold_documents_custom_attributes() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
without_git_identity()
.current_dir(dir)
.arg("init")
.assert()
.success();
let raw = fs::read_to_string(dir.join("tix.toml")).unwrap();
assert!(
raw.contains("# [custom_attributes]"),
"the scaffold documents the section:\n{raw}"
);
assert!(
raw.contains("# keys = [\"build_id\", \"reviewed_by\"]"),
"the scaffold shows a keys example:\n{raw}"
);
assert!(
!raw.contains("\n[custom_attributes]"),
"the example must stay commented out:\n{raw}"
);
assert_eq!(
read_config(dir).unwrap().custom_attributes(),
Vec::<String>::new(),
"a fresh project declares nothing"
);
}
#[test]
fn uncommented_custom_attribute_example_loads() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
without_git_identity()
.current_dir(dir)
.arg("init")
.assert()
.success();
let path = dir.join("tix.toml");
let raw = fs::read_to_string(&path).unwrap();
let active = raw
.replace("# [custom_attributes]", "[custom_attributes]")
.replace(
"# keys = [\"build_id\", \"reviewed_by\"]",
"keys = [\"build_id\", \"reviewed_by\"]",
);
fs::write(&path, active).unwrap();
assert_eq!(
read_config(dir).unwrap().custom_attributes(),
vec!["build_id".to_string(), "reviewed_by".to_string()],
"uncommenting the example declares both keys"
);
}
#[test]
fn seeded_config_roundtrips_planning_and_cycles() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
without_git_identity()
.current_dir(dir)
.arg("init")
.assert()
.success();
let cfg = read_config(dir).unwrap();
cfg.save(&dir.join("tix.toml")).unwrap();
let reloaded = read_config(dir).unwrap();
assert_eq!(cfg, reloaded, "save/load must be a stable round trip");
assert!(reloaded.cycles.contains_key("Default"));
}
#[test]
fn init_writes_default_board_and_leaves_cycles_implicit() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
without_git_identity()
.current_dir(dir)
.arg("init")
.assert()
.success();
let board_path = dir.join("docs").join("boards").join("default.md");
assert!(board_path.is_file(), "init writes docs/boards/default.md");
let raw = fs::read_to_string(&board_path).unwrap();
for column in [
"triage",
"todo",
"in progress",
"in review",
"done",
"wontfix",
] {
assert!(raw.contains(column), "missing column {column}:\n{raw}");
}
assert!(raw.contains("done_column: done"), "{raw}");
assert!(raw.contains("completed"), "category map:\n{raw}");
assert!(raw.contains("canceled"), "category map:\n{raw}");
assert!(
!dir.join("docs").join("cycles").exists(),
"init leaves docs/cycles/ absent; the Default cycle is implicit"
);
let cycles = tix::cycle_store::CycleStore::new(dir).list().unwrap();
assert_eq!(cycles.len(), 1);
assert_eq!(cycles[0].name, "Default");
assert_eq!(cycles[0].due_date, None, "Default has no end date");
let cfg = read_config(dir).unwrap();
assert_eq!(
cfg.default_config().columns,
[
"triage",
"todo",
"in progress",
"in review",
"done",
"wontfix"
]
);
without_git_identity()
.current_dir(dir)
.arg("init")
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("refusing to overwrite"));
assert_eq!(
fs::read_to_string(&board_path).unwrap(),
raw,
"the existing board file is not overwritten"
);
}
#[test]
fn legacy_tables_load_unchanged_and_files_win_when_present() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
fs::write(
dir.join("tix.toml"),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\n\n\
[cycles.\"Cycle 1\"]\nstart_date = \"2026-01-01\"\n",
)
.unwrap();
let legacy = read_config(dir).unwrap();
assert_eq!(legacy.default_config().columns, ["todo", "done"]);
assert!(legacy.cycles.contains_key("Cycle 1"));
assert!(!dir.join("docs/boards").exists());
assert!(!dir.join("docs/cycles").exists());
fs::create_dir_all(dir.join("docs/boards")).unwrap();
fs::write(
dir.join("docs/boards/default.md"),
"---\nname: default\ncolumns:\n- a\n- b\ndone_column: b\n---\n\n",
)
.unwrap();
fs::create_dir_all(dir.join("docs/cycles")).unwrap();
fs::write(
dir.join("docs/cycles/q1.md"),
"---\nname: q1\nstart_date: 2026-03-01\ndue_date: 2026-03-14\n---\n\n",
)
.unwrap();
let files = read_config(dir).unwrap();
assert_eq!(files.default_config().columns, ["a", "b"]);
assert!(files.cycles.contains_key("q1"));
assert!(files.cycles.contains_key("Default"), "implicit Default");
assert!(
!files.cycles.contains_key("Cycle 1"),
"legacy cycles are ignored when the file store exists"
);
}
#[test]
fn mentions_default_to_enabled_and_save_byte_identically_without_the_section() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let legacy = "issues_dir = \"docs/issues\"\n\n\
[kanban.legacy]\n\
columns = [\n \"backlog\",\n \"doing\",\n \"review\",\n \"shipped\",\n]\n\
done_column = \"shipped\"\n";
let path = dir.join("tix.toml");
fs::write(&path, legacy).unwrap();
let cfg = read_config(dir).unwrap();
assert!(
cfg.mentions.enabled,
"mentions are on when the section is absent"
);
assert_eq!(cfg.mentions.dir, std::path::PathBuf::from("docs/mentions"));
assert!(cfg.mentions.is_default());
cfg.save(&path).unwrap();
assert_eq!(
fs::read_to_string(&path).unwrap(),
legacy,
"an absent [mentions] section must not be written back"
);
}
#[test]
fn an_explicit_mentions_section_is_read() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let text = "issues_dir = \"docs/issues\"\n\n\
[kanban.default]\n\
columns = [\n \"todo\",\n \"in progress\",\n \"in review\",\n \"done\",\n]\n\n\
[mentions]\n\
enabled = false\n\
dir = \"meta/mentions\"\n";
fs::write(dir.join("tix.toml"), text).unwrap();
let cfg = read_config(dir).unwrap();
assert!(!cfg.mentions.enabled);
assert_eq!(cfg.mentions.dir, std::path::PathBuf::from("meta/mentions"));
cfg.save(&dir.join("tix.toml")).unwrap();
assert_eq!(fs::read_to_string(dir.join("tix.toml")).unwrap(), text);
}
#[test]
fn legacy_config_round_trips_byte_identically_on_save() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let legacy = "issues_dir = \"docs/issues\"\n\n\
[kanban.legacy]\n\
columns = [\n \"backlog\",\n \"doing\",\n \"review\",\n \"shipped\",\n]\n\
done_column = \"shipped\"\n";
let path = dir.join("tix.toml");
fs::write(&path, legacy).unwrap();
let cfg = read_config(dir).unwrap();
assert_eq!(
cfg.default_config().columns,
["backlog", "doing", "review", "shipped"]
);
assert_eq!(
cfg.default_config().effective_done_column(),
Some("shipped")
);
assert_eq!(
cfg.default_config().order_by,
tix::config::OrderBy::CreatedAtDesc
);
cfg.save(&path).unwrap();
let on_disk = fs::read_to_string(&path).unwrap();
assert_eq!(
on_disk, legacy,
"a legacy tix.toml must save byte-identically"
);
let reloaded = read_config(dir).unwrap();
assert_eq!(
reloaded.default_config().columns,
cfg.default_config().columns
);
assert_eq!(
reloaded.default_config().effective_done_column(),
Some("shipped")
);
assert_eq!(
reloaded.default_config().order_by,
cfg.default_config().order_by
);
}