#[allow(clippy::wildcard_imports)]
use super::*;
use std::path::PathBuf;
use tempfile::TempDir;
use crate::templates::{
PromptKind, ReferenceKind, Template, TemplateDefaults, TemplatePrompt, TemplateReference,
TemplateScope,
};
const LEGACY_PRETTY_BOARD: &str = r#"issues_dir = "docs/issues"
[kanban.default]
columns = [
"todo",
"in progress",
"in review",
"done",
]
"#;
fn write_toml(dir: &Path, contents: &str) -> PathBuf {
let path = dir.join("tix.toml");
std::fs::write(&path, contents).unwrap();
path
}
#[test]
fn starter_config_carries_a_working_inbox() {
let config = Config::starter();
config
.validate()
.expect("the config `tix init` writes must load");
let board = config.default_config();
for column in [
"triage",
"todo",
"in progress",
"in review",
"done",
"wontfix",
] {
assert!(
board.columns.iter().any(|c| c == column),
"the starter board is missing `{column}`: {:?}",
board.columns
);
}
let triage = config
.triage
.as_ref()
.expect("the starter config has an inbox");
assert_eq!(triage.entry_status.as_deref(), Some("triage"));
assert_eq!(triage.accepted_status.as_deref(), Some("todo"));
assert_eq!(triage.declined_status.as_deref(), Some("wontfix"));
assert_eq!(triage.effective_deferred_status(), Some("triage"));
assert_eq!(
board.category_map.get("wontfix").map(String::as_str),
Some("canceled")
);
assert_eq!(
board.category_map.get("done").map(String::as_str),
Some("completed")
);
for column in ["triage", "todo", "in progress", "in review"] {
assert!(
!board.category_map.contains_key(column),
"`{column}` must stay unmapped, or adjacency will refuse work moving back"
);
}
}
#[test]
fn repos_and_parent_repo_load_round_trip_and_resolve() {
let tmp = TempDir::new().unwrap();
let raw = "issues_dir = \"docs/issues\"\n\
parent_repo = \"../parent\"\n\n\
[repos]\n\
tix = \"../tix\"\n\
website = \"child/website\"\n";
let path = write_toml(tmp.path(), raw);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg.repos.len(), 2);
assert_eq!(cfg.repos["tix"], PathBuf::from("../tix"));
assert_eq!(cfg.repos["website"], PathBuf::from("child/website"));
assert_eq!(cfg.parent_repo(), Some(Path::new("../parent")));
cfg.save(&path).unwrap();
let reread = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(reread.repos, cfg.repos, "repos round-trip");
assert_eq!(
reread.parent_repo(),
cfg.parent_repo(),
"parent round-trips"
);
let mut resolved = cfg.clone();
resolved.resolve_repo_paths(Path::new("/root"));
assert_eq!(resolved.repos["tix"], PathBuf::from("/root/../tix"));
assert_eq!(
resolved.repos["website"],
PathBuf::from("/root/child/website")
);
assert_eq!(resolved.parent_repo(), Some(Path::new("/root/../parent")));
let mut absolute = Config::default();
absolute.repos.insert("x".into(), PathBuf::from("/abs/x"));
absolute.parent_repo = Some(PathBuf::from("/abs/parent"));
absolute.resolve_repo_paths(Path::new("/root"));
assert_eq!(absolute.repos["x"], PathBuf::from("/abs/x"));
assert_eq!(absolute.parent_repo(), Some(Path::new("/abs/parent")));
}
#[test]
fn types_rename_and_type_colors() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\ncustom_types = [\"bug\", \"epic\"]\n",
);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg.types(), vec!["bug", "epic"]);
cfg.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert!(after.contains("types = ["), "{after}");
assert!(!after.contains("custom_types"), "{after}");
let defaults = Config::default();
assert_eq!(defaults.type_color("bug").as_deref(), Some("red"));
assert_eq!(defaults.type_color("BUG").as_deref(), Some("red"));
assert_eq!(defaults.type_color("nonsense"), None);
let mut overridden = Config::default();
overridden.type_colors.insert("bug".into(), "cyan".into());
assert_eq!(overridden.type_color("Bug").as_deref(), Some("cyan"));
}
#[test]
fn a_blank_repo_path_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[repos]\ntix = \"\"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("repos.tix"), "got {err}");
}
#[test]
fn an_empty_repos_section_is_not_written() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
cfg.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert!(
!after.contains("[repos]") && !after.contains("parent_repo"),
"an untouched config gains no repos keys on save: {after}"
);
}
#[test]
fn defaults_match_contract() {
let c = Config::default();
let board = c.default_config();
assert_eq!(board.columns, ["todo", "in progress", "in review", "done"]);
assert_eq!(board.done_column.as_deref(), Some("done"));
assert_eq!(board.hidden_columns, Vec::<String>::new());
assert!(!board.show_mismatch);
assert!(board.wrap_titles);
assert_eq!(board.title_lines, 3);
assert_eq!(board.card_order, [] as [std::string::String; 0]);
assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
assert_eq!(c.issues_dir, PathBuf::from("docs/issues"));
assert_eq!(c.editor, None);
assert_eq!(c.state.next_number, 0);
assert_eq!(c.export.gitlab, None);
assert_eq!(c.export.linear, None);
}
#[test]
fn decision_pending_is_a_builtin_label_that_needs_no_key() {
let bare = Config::default();
assert_eq!(bare.labels, None);
assert_eq!(bare.labels(), vec!["decision-pending".to_string()]);
let starter = Config::starter();
assert_eq!(starter.labels, None);
assert_eq!(starter.labels(), vec!["decision-pending".to_string()]);
for written in [
toml::to_string_pretty(&bare).unwrap(),
toml::to_string_pretty(&starter).unwrap(),
] {
assert!(
!written.contains("labels ="),
"no labels key is written:\n{written}"
);
}
let configured = Config {
labels: Some(vec!["decision-pending".into(), "spec-kitty".into()]),
..Config::default()
};
assert_eq!(
configured.labels(),
vec!["decision-pending".to_string(), "spec-kitty".to_string()]
);
}
#[test]
fn load_returns_defaults_when_file_absent() {
let tmp = TempDir::new().unwrap();
let cfg = Config::load(tmp.path()).unwrap();
assert_eq!(cfg.state.next_number, 0);
assert_eq!(cfg.default_config().columns.len(), 4);
}
#[test]
fn load_synthesizes_default_config_when_kanban_section_missing() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg.kanban.configurations.len(), 1);
assert!(cfg.kanban.configurations.contains_key("default"));
assert_eq!(cfg.default_config().columns.len(), 4);
}
#[test]
fn load_parses_state_and_keeps_editor_and_remote_targets() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "tickets"
editor = "nvim"
[kanban.default]
columns = ["todo", "done"]
done_column = "done"
[state]
next_number = 17
[export.gitlab]
project = "acme/tix"
"#,
);
let cfg = Config::load_with_warning(&path, |_| panic!("no token, no warning")).unwrap();
assert_eq!(
cfg.kanban.configurations.get("default").unwrap().columns,
["todo", "done"]
);
assert_eq!(cfg.issues_dir, PathBuf::from("tickets"));
assert_eq!(cfg.editor.as_deref(), Some("nvim"));
assert_eq!(cfg.state.next_number, 17);
assert_eq!(
cfg.export.gitlab.unwrap().project.as_deref(),
Some("acme/tix")
);
assert_eq!(cfg.export.linear, None);
}
#[test]
fn viewer_is_parsed_and_blank_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "issues_dir = \"tickets\"\nviewer = \"less\"\n");
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg.viewer.as_deref(), Some("less"));
let blank = write_toml(tmp.path(), "issues_dir = \"tickets\"\nviewer = \" \"\n");
let err = Config::load_with_warning(&blank, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("viewer"), "{err}");
}
#[test]
fn viewer_is_omitted_from_the_file_when_unset() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("tix.toml");
let mut config = Config::default();
config.save(&path).unwrap();
assert!(
!std::fs::read_to_string(&path).unwrap().contains("viewer"),
"an unset viewer writes no key"
);
config.viewer = Some("less".to_string());
config.save(&path).unwrap();
assert!(
std::fs::read_to_string(&path)
.unwrap()
.contains(r#"viewer = "less""#),
"a set viewer round-trips"
);
}
#[test]
fn empty_columns_rejected_as_config_error() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "[kanban.scratch]\ncolumns = []\n");
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("kanban.scratch.columns"));
}
#[test]
fn duplicate_columns_rejected_as_config_error() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[kanban.scratch]\ncolumns = [\"todo\", \"todo\"]\n",
);
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("duplicate"));
}
#[test]
fn blank_column_name_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[kanban.scratch]\ncolumns = [\"todo\", \" \"]\n",
);
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
}
#[test]
fn done_column_must_be_in_columns() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[kanban.scratch]\ncolumns = [\"todo\", \"in progress\"]\ndone_column = \"in review\"\n",
);
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("kanban.scratch.done_column"));
}
#[test]
fn hidden_columns_must_be_subset_of_columns() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\nhidden_columns = [\"nope\"]\n",
);
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("kanban.scratch.hidden_columns"));
}
#[test]
fn hidden_done_column_loads_fine() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\nhidden_columns = [\"done\"]\n",
);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
let board = cfg.kanban.configurations.get("scratch").unwrap();
assert_eq!(board.done_column.as_deref(), Some("done"));
assert_eq!(board.hidden_columns, ["done"]);
}
#[test]
fn legacy_top_level_keys_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "columns = [\"todo\", \"done\"]\n");
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("tix.toml"));
assert!(err.to_string().contains("columns"));
}
#[test]
fn token_warning_fires_once_per_remote_with_token() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"[export.gitlab]
project = "a/b"
token = "gl-secret"
[export.linear]
team = "Eng"
token = "lin-secret"
"#,
);
let mut warnings: Vec<String> = Vec::new();
let cfg = Config::load_with_warning(&path, |w| warnings.push(w.to_string())).unwrap();
assert_eq!(warnings.len(), 2);
assert!(warnings[0].contains("GITLAB_TOKEN"));
assert!(warnings[1].contains("LINEAR_TOKEN"));
for w in &warnings {
assert!(!w.contains("secret"));
}
assert_eq!(
cfg.export.gitlab.unwrap().token.as_deref(),
Some("gl-secret")
);
}
#[test]
fn no_token_means_no_warning() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
let mut fired = false;
Config::load_with_warning(&path, |_| fired = true).unwrap();
assert!(!fired);
}
#[test]
fn save_never_writes_the_state_section() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
let mut cfg = Config::default();
cfg.state.next_number = 42;
cfg.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(reloaded.state.next_number, 0, "state is never persisted");
assert_eq!(
reloaded.default_config().columns,
cfg.default_config().columns
);
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(
!on_disk.contains("next_number"),
"next_number leaked into tix.toml:\n{on_disk}"
);
assert!(
!on_disk.contains("[state]"),
"state section written:\n{on_disk}"
);
}
#[test]
fn multi_config_roundtrip_preserves_declaration_order() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.release]
columns = ["planned", "in progress", "done"]
done_column = "done"
show_mismatch = true
[kanban.personal]
columns = ["todo", "done"]
done_column = "done"
hidden_columns = ["done"]
"#,
);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
let names: Vec<&str> = cfg.configs().map(|(n, _)| n).collect();
assert_eq!(names, ["release", "personal"]);
assert_eq!(
cfg.default_config().columns,
["planned", "in progress", "done"]
);
cfg.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
cfg, reloaded,
"order and values survive save/load round trip"
);
let names2: Vec<&str> = reloaded.configs().map(|(n, _)| n).collect();
assert_eq!(names2, ["release", "personal"]);
}
#[test]
fn save_never_serializes_tokens_back_out() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
let mut cfg = Config::default();
cfg.export.gitlab = Some(RemoteConfig {
token: Some("secret".into()),
project: Some("a/b".into()),
..RemoteConfig::default()
});
cfg.save(&path).unwrap();
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(
!on_disk.contains("secret"),
"token leaked to disk:\n{on_disk}"
);
assert!(
on_disk.contains("a/b"),
"non-secret fields survive:\n{on_disk}"
);
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(reloaded.export.gitlab.unwrap().token, None);
}
#[test]
fn save_is_atomic_and_leaves_no_temp_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
Config::default().save(&path).unwrap();
let entries: Vec<_> = std::fs::read_dir(tmp.path())
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].file_name().to_string_lossy(), "tix.toml");
}
#[test]
fn default_loadwrites_are_idempotent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
let cfg = Config::default();
cfg.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg, reloaded);
}
#[test]
fn effective_done_column_falls_back_to_last() {
let board = BoardConfiguration {
columns: vec!["todo".into(), "later".into()],
done_column: None,
..Default::default()
};
assert_eq!(board.effective_done_column(), Some("later"));
let with_done = BoardConfiguration {
columns: vec!["todo".into(), "done".into(), "later".into()],
done_column: None,
..Default::default()
};
assert_eq!(with_done.effective_done_column(), Some("done"));
}
#[test]
fn project_root_walks_up_the_tree() {
let tmp = TempDir::new().unwrap();
let nested = tmp.path().join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(tmp.path().join("tix.toml"), "issues_dir = \"tix\"\n").unwrap();
assert_eq!(
Config::project_root(&nested),
Some(tmp.path().to_path_buf())
);
assert_eq!(
Config::project_root(tmp.path()),
Some(tmp.path().to_path_buf())
);
}
#[test]
fn project_root_is_none_when_no_toml_exists() {
let tmp = TempDir::new().unwrap();
assert_eq!(Config::project_root(tmp.path()), None);
}
#[test]
fn order_by_parses_all_names_and_defaults_to_desc() {
let tmp = TempDir::new().unwrap();
for name in ["created_at_desc", "created_at_asc", "none"] {
let path = write_toml(
tmp.path(),
&format!(
"[kanban.x]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"{name}\"\n"
),
);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
let board = cfg.kanban.configurations.get("x").unwrap();
assert_eq!(board.order_by.as_str(), name);
}
assert_eq!(OrderBy::default(), OrderBy::CreatedAtDesc);
assert_eq!(OrderBy::CreatedAtDesc.next(), OrderBy::CreatedAtAsc);
assert_eq!(OrderBy::CreatedAtAsc.next(), OrderBy::None);
assert_eq!(OrderBy::None.next(), OrderBy::CreatedAtDesc);
}
#[test]
fn zero_title_lines_rejected_as_config_error() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\ntitle_lines = 0\n",
);
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(
err.to_string().contains("kanban.scratch.title_lines"),
"error names the offending field: {err}"
);
}
#[test]
fn unknown_order_by_rejected_as_config_error() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"bogus\"\n",
);
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
}
#[test]
fn board_without_display_keys_loads_default_display_settings() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[kanban.default]\ncolumns = [\"todo\", \"done\"]\n",
);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
let board = cfg.default_config();
assert!(board.wrap_titles);
assert_eq!(board.title_lines, 3);
assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
}
#[test]
fn display_settings_roundtrip_through_save() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
let mut cfg = Config::default();
{
let board = cfg.kanban.configurations.get_mut("default").unwrap();
board.wrap_titles = false;
board.title_lines = 5;
board.order_by = OrderBy::None;
}
cfg.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
reloaded, cfg,
"display settings survive save/load round trip"
);
let on_disk = std::fs::read_to_string(&path).unwrap();
for key in ["wrap_titles", "title_lines", "order_by"] {
assert!(on_disk.contains(key), "missing `{key}` in:\n{on_disk}");
}
}
#[test]
fn manual_card_order_roundtrips_through_save() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
let mut cfg = Config::default();
{
let board = cfg.kanban.configurations.get_mut("default").unwrap();
board.card_order = vec!["a.md".into(), "b.md".into()];
}
cfg.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
reloaded
.kanban
.configurations
.get("default")
.unwrap()
.card_order,
vec!["a.md".to_string(), "b.md".to_string()]
);
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(
on_disk.contains("card_order"),
"missing card_order in:\n{on_disk}"
);
}
#[test]
fn planning_defaults_match_contract() {
let c = Config::default();
assert_eq!(c.planning.cycle_number, 1);
assert_eq!(c.planning.default_cycle, None);
assert_eq!(c.planning.default_milestone, None);
assert_eq!(c.planning.default_team, None);
assert!(c.teams.is_empty());
assert!(c.cycles.is_empty());
assert_eq!(c.milestones_dir, None);
assert_eq!(c.milestones_dir(), Path::new("docs/milestones"));
}
#[test]
fn planning_sections_roundtrip_and_preserve_declaration_order() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
milestones_dir = "docs/milestones"
[planning]
cycle_number = 1
default_cycle = "Cycle 1"
default_milestone = "Backlog"
default_team = "default"
[cycles."Cycle 1"]
[cycles."Cycle 2"]
start_date = "2026-09-21"
due_date = "2026-09-30"
[teams.default]
members = ["Herb Mason <herb@example.com>", "@alice"]
[teams.platform]
members = ["bob"]
"#,
);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg.planning.cycle_number, 1);
assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
assert_eq!(cfg.planning.default_milestone.as_deref(), Some("Backlog"));
assert_eq!(cfg.planning.default_team.as_deref(), Some("default"));
assert_eq!(cfg.milestones_dir(), Path::new("docs/milestones"));
let cycles: Vec<&str> = cfg.cycles.keys().map(String::as_str).collect();
assert_eq!(cycles, ["Cycle 1", "Cycle 2"]);
let cycle2 = cfg.cycles.get("Cycle 2").unwrap();
assert_eq!(cycle2.start_date.as_deref(), Some("2026-09-21"));
assert_eq!(cycle2.due_date.as_deref(), Some("2026-09-30"));
assert_eq!(cycle2.description, None);
let teams: Vec<&str> = cfg.teams.keys().map(String::as_str).collect();
assert_eq!(teams, ["default", "platform"]);
assert_eq!(
cfg.teams.get("default").unwrap().members,
["Herb Mason <herb@example.com>", "@alice"]
);
cfg.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg, reloaded);
assert_eq!(
reloaded
.cycles
.keys()
.map(String::as_str)
.collect::<Vec<_>>(),
["Cycle 1", "Cycle 2"]
);
assert_eq!(
reloaded
.teams
.keys()
.map(String::as_str)
.collect::<Vec<_>>(),
["default", "platform"]
);
}
const FULL_CATEGORIES: &str = r#"issues_dir = "docs/issues"
[categories]
order = ["backlog", "unstarted", "active", "blocked", "completed", "canceled"]
default = "unstarted"
[transitions]
terminal_locked = true
[kanban.default]
columns = ["triage", "wip", "review", "shipped"]
done_column = "shipped"
category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
[export]
"#;
#[test]
fn category_sections_parse_and_default_sensibly() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), FULL_CATEGORIES);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg.categories.order, CATEGORY_KEYS);
assert_eq!(cfg.categories.default, "unstarted");
assert!(cfg.transitions.terminal_locked);
let board = cfg.default_config();
assert_eq!(
board.category_map.get("shipped").map(String::as_str),
Some("completed")
);
assert_eq!(
board.category_map.get("triage").map(String::as_str),
Some("backlog")
);
}
#[test]
fn category_sections_default_when_absent() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg.categories, CategoryConfig::default());
assert_eq!(cfg.transitions, TransitionConfig::default());
assert!(cfg.categories.is_default(), "and is not written back");
assert!(cfg.transitions.is_default());
}
#[test]
fn deny_unknown_fields_rejects_an_unknown_key_in_each_new_section() {
let tmp = TempDir::new().unwrap();
let bad_categories = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[categories]\nnope = 1\n",
);
let err = Config::load_with_warning(&bad_categories, |_| {})
.expect_err("an unknown key in [categories] must be refused");
assert!(err.to_string().contains("unknown field"), "got: {err}");
let tmp2 = TempDir::new().unwrap();
let bad_transitions = write_toml(
tmp2.path(),
"issues_dir = \"docs/issues\"\n\n[transitions]\nnope = 1\n",
);
let err = Config::load_with_warning(&bad_transitions, |_| {})
.expect_err("an unknown key in [transitions] must be refused");
assert!(err.to_string().contains("unknown field"), "got: {err}");
}
#[test]
fn a_configured_vocabulary_naming_an_unknown_key_is_refused() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[categories]\norder = [\"backlog\", \"wontfix\"]\n",
);
let err = Config::load_with_warning(&path, |_| {})
.expect_err("the vocabulary is global and fixed; a new key is not a default");
assert!(err.to_string().contains("unknown_category"), "got: {err}");
}
#[test]
fn a_default_of_completed_is_refused() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"completed\"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.expect_err("a completion-changing default must be an escalation");
let msg = err.to_string();
assert!(msg.contains("may not be `completed`"), "got: {msg}");
}
#[test]
fn a_default_outside_the_vocabulary_is_refused() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"wontfix\"\n",
);
let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
assert!(err.to_string().contains("unknown_category"), "got: {err}");
}
#[test]
fn a_mapped_board_with_no_completion_column_is_refused() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["triage", "wip", "shipped"]
done_column = "shipped"
category_map = { triage = "backlog", wip = "active" }
"#,
);
let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
assert!(
err.to_string().contains("missing_completion_category"),
"got: {err}"
);
}
#[test]
fn a_board_with_two_completion_columns_is_refused() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["shipped", "cancelled"]
done_column = "shipped"
category_map = { shipped = "completed", cancelled = "completed" }
"#,
);
let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
let msg = err.to_string();
assert!(
msg.contains("exactly one column may complete"),
"got: {msg}"
);
}
#[test]
fn a_mapping_for_a_column_the_board_does_not_configure_is_refused() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["triage", "shipped"]
done_column = "shipped"
category_map = { triage = "backlog", shipped = "completed", nope = "active" }
"#,
);
let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
assert!(
err.to_string().contains("mapping_for_unknown_column"),
"got: {err}"
);
}
#[test]
fn a_mapping_to_a_category_outside_the_vocabulary_is_refused() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["triage", "shipped"]
done_column = "shipped"
category_map = { triage = "wontfix", shipped = "completed" }
"#,
);
let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
assert!(err.to_string().contains("unknown_category"), "got: {err}");
}
#[test]
fn an_unmapped_column_is_accepted_and_resolves_by_the_safe_default() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["triage", "wip", "shipped"]
done_column = "shipped"
category_map = { shipped = "completed" }
"#,
);
let cfg = Config::load_with_warning(&path, |_| {}).expect("an unmapped column is not an error");
let board = cfg.default_config();
assert_eq!(board.category_for("triage", &cfg.categories), "unstarted");
assert_eq!(board.category_for("wip", &cfg.categories), "unstarted");
assert_eq!(board.category_for("shipped", &cfg.categories), "completed");
}
#[test]
fn a_legacy_board_with_no_map_takes_done_column_as_completed() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["todo", "in progress", "in review", "done"]
done_column = "done"
"#,
);
let cfg =
Config::load_with_warning(&path, |_| {}).expect("a legacy board must load, and it does");
let board = cfg.default_config();
assert!(board.category_map.is_empty());
assert_eq!(board.category_for("done", &cfg.categories), "completed");
assert_eq!(board.category_for("todo", &cfg.categories), "unstarted");
assert_eq!(
board.category_for("in review", &cfg.categories),
"unstarted",
"and nothing else is silently complete"
);
}
#[test]
fn a_defaulted_config_gains_no_new_keys_on_save() {
let tmp = TempDir::new().unwrap();
let legacy = LEGACY_PRETTY_BOARD;
let path = write_toml(tmp.path(), legacy);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
cfg.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(
after, legacy,
"no `[categories]`, no `[transitions]`, no `category_map` may appear"
);
}
#[test]
fn explicit_default_board_keys_are_normalized_away_by_save() {
let tmp = TempDir::new().unwrap();
let explicit = r#"issues_dir = "docs/issues"
[kanban.default]
columns = [
"todo",
"in progress",
"in review",
"done",
]
done_column = "done"
hidden_columns = []
show_mismatch = false
wrap_titles = true
title_lines = 3
order_by = "created_at_desc"
[export]
"#;
let path = write_toml(tmp.path(), explicit);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
cfg.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(
after, LEGACY_PRETTY_BOARD,
"a redundant explicit default collapses to the canonical form"
);
for key in [
"done_column",
"hidden_columns",
"show_mismatch",
"wrap_titles",
"title_lines",
"order_by",
"[export]",
] {
assert!(!after.contains(key), "`{key}` must be omitted:\n{after}");
}
assert_eq!(
cfg,
Config::load_with_warning(&path, |_| {}).unwrap(),
"and the collapsed form reloads to the same config"
);
}
#[test]
fn a_board_mapping_without_a_categories_section_uses_the_built_in_vocabulary() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["triage", "shipped"]
done_column = "shipped"
category_map = { triage = "backlog", shipped = "completed" }
[export]
"#,
);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg.categories, CategoryConfig::default());
cfg.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert!(
!after.contains("[categories]"),
"the section must stay unmaterialized: {after}"
);
assert!(
after.contains("category_map"),
"while the board's own mapping survives: {after}"
);
}
#[test]
fn a_full_category_config_round_trips_with_its_values_and_order() {
let tmp = TempDir::new().unwrap();
let authored = r#"issues_dir = "docs/issues"
[categories]
order = ["backlog", "active", "completed", "canceled"]
default = "backlog"
[transitions]
terminal_locked = false
[kanban.default]
columns = ["triage", "wip", "shipped"]
done_column = "shipped"
category_map = { triage = "backlog", wip = "active", shipped = "completed" }
[export]
"#;
let path = write_toml(tmp.path(), authored);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
cfg.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
reloaded.categories, cfg.categories,
"the vocabulary and its default survive"
);
assert_eq!(
reloaded.categories.order,
vec!["backlog", "active", "completed", "canceled"],
"and `order` is a Vec, so the declared order is preserved exactly"
);
assert_eq!(
reloaded.default_config().category_map,
cfg.default_config().category_map,
"the mapping's contents survive, in whatever order they are written"
);
assert!(after.contains("[categories]"), "after: {after}");
assert!(after.contains("[transitions]"), "after: {after}");
assert!(
!reloaded.transitions.terminal_locked,
"and the value survives"
);
let reloaded_again = Config::load_with_warning(&path, |_| {}).unwrap();
reloaded_again.save(&path).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
after,
"a second save must be a no-op"
);
}
#[test]
fn two_boards_with_different_columns_map_to_the_same_categories() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.alpha]
columns = ["triage", "wip", "review", "shipped"]
done_column = "shipped"
category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
[kanban.beta]
columns = ["new", "doing", "merged"]
done_column = "merged"
[export]
"#,
);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
let alpha = &cfg.kanban.configurations["alpha"];
let beta = &cfg.kanban.configurations["beta"];
assert_eq!(alpha.category_for("shipped", &cfg.categories), "completed");
assert_eq!(beta.category_for("merged", &cfg.categories), "completed");
assert_eq!(alpha.category_for("wip", &cfg.categories), "active");
assert_eq!(beta.category_for("doing", &cfg.categories), "unstarted");
assert!(beta.category_map.is_empty());
}
#[test]
fn canceled_is_terminal_but_not_complete() {
assert!(TERMINAL_CATEGORIES.contains(&"canceled"));
assert!(TERMINAL_CATEGORIES.contains(&"completed"));
assert_eq!(
COMPLETE_CATEGORY, "completed",
"completion is defined by this category alone"
);
assert_ne!(COMPLETE_CATEGORY, "canceled");
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["triage", "dropped", "shipped"]
done_column = "shipped"
category_map = { triage = "backlog", dropped = "canceled", shipped = "completed" }
"#,
);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
let board = cfg.default_config();
assert_eq!(board.category_for("dropped", &cfg.categories), "canceled");
assert_ne!(
board.category_for("dropped", &cfg.categories),
COMPLETE_CATEGORY
);
}
#[test]
fn blocked_as_a_category_is_mappable_and_is_its_own_key() {
assert!(CATEGORY_KEYS.contains(&"blocked"));
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["wip", "on hold", "shipped"]
done_column = "shipped"
category_map = { wip = "active", "on hold" = "blocked", shipped = "completed" }
"#,
);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
let board = cfg.default_config();
assert_eq!(board.category_for("on hold", &cfg.categories), "blocked");
assert!(
!cfg.default_config()
.category_map
.values()
.any(|v| v.contains("state")),
"a category is a position, never a derived state"
);
}
#[test]
fn legacy_file_roundtrips_byte_identical() {
let tmp = TempDir::new().unwrap();
let legacy = LEGACY_PRETTY_BOARD;
let path = write_toml(tmp.path(), legacy);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg.planning, PlanningConfig::default());
assert!(cfg.teams.is_empty());
assert!(cfg.cycles.is_empty());
assert_eq!(cfg.milestones_dir, None);
assert_eq!(
cfg.default_config().columns,
["todo", "in progress", "in review", "done"]
);
assert_eq!(cfg.default_config().effective_done_column(), Some("done"));
assert_eq!(cfg.default_config().order_by, OrderBy::CreatedAtDesc);
assert_eq!(
cfg.default_config().hidden_columns,
[] as [std::string::String; 0]
);
assert!(!cfg.default_config().show_mismatch);
assert!(cfg.default_config().wrap_titles);
assert_eq!(cfg.default_config().title_lines, DEFAULT_TITLE_LINES);
assert!(!cfg.default_config().category_map.contains_key("done"));
cfg.save(&path).unwrap();
let on_disk = std::fs::read_to_string(&path).unwrap();
assert_eq!(
on_disk, legacy,
"legacy tix.toml must save byte-identical (NFR-003)"
);
assert!(
!on_disk.contains("[export]"),
"an empty `[export]` table must not be materialized:\n{on_disk}"
);
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg, reloaded);
}
#[test]
fn legacy_file_in_any_layout_never_gains_planning_keys() {
let tmp = TempDir::new().unwrap();
let legacy = r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["todo", "in progress", "in review", "done"]
done_column = "done"
"#;
let path = write_toml(tmp.path(), legacy);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
cfg.save(&path).unwrap();
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(!on_disk.contains("milestones_dir"));
assert!(!on_disk.contains("[planning]"));
assert!(!on_disk.contains("[teams]"));
assert!(!on_disk.contains("[cycles"));
}
#[test]
fn planning_default_section_with_partial_keys_loads() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "[planning]\ndefault_cycle = \"Cycle 1\"\n");
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
cfg.planning.cycle_number, 1,
"counter defaults when omitted"
);
assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
assert_eq!(cfg.planning.default_milestone, None);
assert_eq!(cfg.planning.default_team, None);
}
#[test]
fn planning_rejects_unknown_keys() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "[planning]\ncounter = 5\n");
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("counter"));
}
#[test]
fn planning_rejects_zero_cycle_number() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "[planning]\ncycle_number = 0\n");
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("cycle_number"));
}
#[test]
fn cycle_dates_are_validated_and_compared() {
let tmp = TempDir::new().unwrap();
let bad = write_toml(tmp.path(), "[cycles.x]\nstart_date = \"2026-13-01\"\n");
let err = Config::load_with_warning(&bad, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("start_date"));
let ordered = write_toml(
tmp.path(),
"[cycles.x]\nstart_date = \"2026-09-30\"\ndue_date = \"2026-09-01\"\n",
);
let err = Config::load_with_warning(&ordered, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("due_date"));
assert!(err.to_string().contains("start_date"));
}
#[test]
fn cycles_reject_unknown_keys() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "[cycles.X]\nbogus = 1\n");
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("bogus"));
}
#[test]
fn cycle_without_dates_loads_fine() {
let tmp = TempDir::new().unwrap();
let ok = write_toml(tmp.path(), "[cycles.Alpha]\ndescription = \"a\"\n");
let cfg = Config::load_with_warning(&ok, |_| {}).unwrap();
assert_eq!(
cfg.cycles.get("Alpha").unwrap().description.as_deref(),
Some("a")
);
}
#[test]
fn teams_validate_members_nonempty_and_unique() {
let tmp = TempDir::new().unwrap();
let empty = write_toml(tmp.path(), "[teams.dev]\nmembers = []\n");
let err = Config::load_with_warning(&empty, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("members"));
let dup = write_toml(
tmp.path(),
"[teams.dev]\nmembers = [\"alice\", \"alice\"]\n",
);
let err = Config::load_with_warning(&dup, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("unique"));
}
#[test]
fn empty_teams_table_is_disabled_not_error() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "[teams]\n");
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(cfg.teams.is_empty());
cfg.save(&path).unwrap();
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(!on_disk.contains("[teams]"));
}
#[test]
fn blank_milestones_dir_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "milestones_dir = \"\"\n");
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("milestones_dir"));
}
#[test]
fn sparse_keybindings_roundtrip_without_defaults() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[keybindings.board]\nnew_issue = [\"ctrl+n\", \"f4\"]\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
config.save(&path).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("[keybindings.board]"));
assert!(!contents.contains("move_left"));
assert_eq!(Config::load_with_warning(&path, |_| {}).unwrap(), config);
}
#[test]
fn omitted_keybindings_retain_effective_defaults() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let bindings = config.effective_keybindings().unwrap();
assert_eq!(
bindings.label(
crate::keybindings::InputContext::Board,
crate::keybindings::TuiAction::NewIssue
),
"n"
);
}
#[test]
fn edit_conflict_keybindings_override_the_prompt() {
use crate::keybindings::{InputContext, TuiAction};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[keybindings.edit_conflict]\nconfirm = [\"ctrl+k\"]\nignore = [\"escape\"]\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let bindings = config.effective_keybindings().unwrap();
assert_eq!(
bindings.label(InputContext::EditConflict, TuiAction::Confirm),
"Ctrl+k"
);
assert_eq!(
bindings.action(
InputContext::EditConflict,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)
),
Some(TuiAction::Confirm)
);
assert_eq!(
bindings.action(
InputContext::EditConflict,
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)
),
None,
"an override replaces the action's whole alias list"
);
assert_eq!(
bindings.action(
InputContext::EditConflict,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)
),
Some(TuiAction::Reedit)
);
assert_eq!(
bindings.action(
InputContext::EditConflict,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
),
Some(TuiAction::Ignore)
);
}
#[test]
fn keybindings_reject_unknown_fields_and_empty_actions() {
let tmp = TempDir::new().unwrap();
let unknown = write_toml(tmp.path(), "[keybindings.board]\nbogus = [\"x\"]\n");
assert!(
Config::load_with_warning(&unknown, |_| {})
.unwrap_err()
.to_string()
.contains("bogus")
);
let empty = write_toml(tmp.path(), "[keybindings.board]\nnew_issue = []\n");
assert!(
Config::load_with_warning(&empty, |_| {})
.unwrap_err()
.to_string()
.contains("new_issue")
);
}
#[test]
fn shell_mode_switches_are_configurable() {
use crate::keybindings::{InputContext, TuiAction};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[keybindings.shell]\nswitch_to_milestones = [\"g\"]\nswitch_to_board = [\"f3\", \"b\"]\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let bindings = config.effective_keybindings().unwrap();
assert_eq!(
bindings.label(InputContext::Shell, TuiAction::SwitchToMilestones),
"g"
);
assert_eq!(
bindings.label(InputContext::Shell, TuiAction::SwitchToBoard),
"F3 / b"
);
assert_eq!(
bindings.action(
InputContext::Shell,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)
),
Some(TuiAction::SwitchToMilestones)
);
assert_eq!(
bindings.action(
InputContext::Shell,
KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)
),
None,
"an override replaces the action's whole alias list"
);
assert_eq!(
bindings.action(
InputContext::Shell,
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)
),
Some(TuiAction::SwitchToBoard),
"a second alias keeps working"
);
let unknown = write_toml(
tmp.path(),
"[keybindings.shell]\nswitch_to_nowhere = [\"g\"]\n",
);
let error = Config::load_with_warning(&unknown, |_| {})
.unwrap_err()
.to_string();
assert!(
error.contains("switch_to_nowhere"),
"the error names the offending key: {error:?}"
);
let bad_chord = write_toml(
tmp.path(),
"[keybindings.shell]\nswitch_to_milestones = [\"ctrl+nosuchkey\"]\n",
);
let error = Config::load_with_warning(&bad_chord, |_| {})
.unwrap_err()
.to_string();
assert!(
error.contains("switch_to_milestones") && error.contains("nosuchkey"),
"the error names the key and the chord: {error:?}"
);
}
#[test]
fn templates_round_trip_through_save_and_load() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
let mut config = Config::starter();
config.templates.insert(
"bug".into(),
IssueTemplate {
title: Some("Bug: ".into()),
body: Some("## Steps\n".into()),
labels: vec!["bug".into(), "urgent".into()],
status: Some("todo".into()),
cycles: vec!["Default".into()],
start_date: Some("2026-01-01".into()),
due_date: Some("2026-01-02".into()),
..IssueTemplate::default()
},
);
config.validate().expect("the fixture is a valid config");
config.save(&path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("[templates.bug]"), "{raw}");
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(reloaded.templates, config.templates);
let bug = &reloaded.templates["bug"];
assert_eq!(bug.labels, ["bug".to_string(), "urgent".to_string()]);
assert_eq!(bug.body.as_deref(), Some("## Steps\n"));
assert_eq!(bug.due_date.as_deref(), Some("2026-01-02"));
}
#[test]
fn a_config_without_templates_writes_no_section() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
Config::default().save(&path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(
!raw.contains("templates"),
"an absent section must not appear on disk:\n{raw}"
);
assert!(
Config::load_with_warning(&path, |_| {})
.unwrap()
.templates
.is_empty()
);
}
#[test]
fn a_template_value_with_a_comma_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[templates.bug]\nlabels = [\"bug,urgent\"]\n",
);
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(
err.to_string().contains("must not contain a comma"),
"got {err}"
);
}
#[test]
fn a_template_with_a_backwards_date_range_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[templates.bug]\n\
start_date = \"2026-02-01\"\ndue_date = \"2026-01-01\"\n",
);
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(err.to_string().contains(">= start_date"), "got {err}");
}
#[test]
fn an_unknown_template_key_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[templates.bug]\ntitel = \"typo\"\n",
);
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(err.to_string().contains("unknown field"), "got {err}");
}
#[test]
fn issue_templates_round_trip_through_save_and_load() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
let mut config = Config::starter();
config
.issue_templates
.insert(
"bug",
Template {
name: Some("Bug report".to_string()),
defaults: TemplateDefaults {
labels: vec!["bug".to_string()],
priority: Some("high".to_string()),
..TemplateDefaults::default()
},
prompts: vec![TemplatePrompt {
key: "priority".to_string(),
label: Some("Priority".to_string()),
kind: PromptKind::Select,
required: true,
default: None,
options_ref: Some("priorities".to_string()),
}],
references: vec![TemplateReference {
kind: ReferenceKind::Label,
value: "bug".to_string(),
required: true,
}],
..Template::default()
},
)
.unwrap();
config.validate().expect("the fixture is a valid config");
config.save(&path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("[issue_templates.bug]"), "{raw}");
assert!(raw.contains("[[issue_templates.bug.prompts]]"), "{raw}");
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(reloaded.issue_templates, config.issue_templates);
let bug = reloaded.issue_templates.get("bug").unwrap();
assert_eq!(bug.name.as_deref(), Some("Bug report"));
assert_eq!(bug.defaults.labels, ["bug".to_string()]);
assert_eq!(bug.prompts[0].kind, PromptKind::Select);
assert!(bug.prompts[0].required);
assert_eq!(bug.references[0].kind, ReferenceKind::Label);
}
#[test]
fn a_config_without_issue_templates_writes_no_section() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
Config::default().save(&path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(
!raw.contains("issue_templates"),
"an absent section must not appear on disk:\n{raw}"
);
assert!(
Config::load_with_warning(&path, |_| {})
.unwrap()
.issue_templates
.is_empty()
);
}
#[test]
fn a_legacy_config_gains_no_issue_templates_section() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
let before = std::fs::read_to_string(&path).unwrap();
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(
config.issue_templates.is_empty(),
"a legacy config has no stable-ID registry"
);
config.save(&path).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
before,
"a legacy config must round-trip byte-identically"
);
}
#[test]
fn issue_template_declaration_order_is_restored() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[issue_templates.zeta]\nname = \"Zeta\"\n\n\
[issue_templates.alpha]\nname = \"Alpha\"\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
config.issue_templates.ids().collect::<Vec<_>>(),
["zeta", "alpha"],
"declaration order is the display order"
);
config.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
reloaded.issue_templates.ids().collect::<Vec<_>>(),
["zeta", "alpha"]
);
}
#[test]
fn a_rejected_template_write_leaves_tix_toml_unchanged() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[issue_templates.bug]\nname = \"Bug report\"\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let before = std::fs::read(&path).unwrap();
let mut rejected = config.clone();
rejected.issue_templates.get_mut("bug").unwrap().scope = TemplateScope::board("no-such-board");
let err = rejected.validate().unwrap_err().to_string();
assert!(
err.contains("issue_templates.bug.scope") && err.contains("no-such-board"),
"{err}"
);
assert_eq!(
std::fs::read(&path).unwrap(),
before,
"a rejected write must not touch tix.toml"
);
let mut duplicate = config.clone();
let err = duplicate
.issue_templates
.insert("bug", Template::default())
.unwrap_err();
assert!(err.to_string().contains("already exists"), "{err}");
assert_eq!(duplicate.issue_templates, config.issue_templates);
assert_eq!(std::fs::read(&path).unwrap(), before);
}
#[test]
fn unknown_issue_template_metadata_round_trips_byte_stably() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[issue_templates.bug]\nname = \"Bug report\"\n\
reporter_hint = \"include the build id\"\n\n\
[issue_templates.bug.future]\nlevel = 3\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let bug = config.issue_templates.get("bug").unwrap();
assert_eq!(
bug.extensions
.get("reporter_hint")
.and_then(toml::Value::as_str),
Some("include the build id")
);
assert!(bug.extensions.get("future").is_some());
config.save(&path).unwrap();
let first = std::fs::read(&path).unwrap();
let text = String::from_utf8(first.clone()).unwrap();
assert!(
text.contains("reporter_hint") && text.contains("future"),
"unknown metadata must survive a save:\n{text}"
);
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
reloaded.save(&path).unwrap();
assert_eq!(
std::fs::read(&path).unwrap(),
first,
"a second round trip must be byte-identical"
);
}
#[test]
fn wip_config_round_trips_byte_stably_and_resolves() {
let tmp = TempDir::new().unwrap();
let configured = "issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\n \"todo\",\n \"in progress\",\n \"done\",\n]\n\n\
[kanban.default.wip.units]\nd = 1\nw = 5\n\n\
[kanban.default.wip.limits.\"in progress\"]\nmax_items = 3\n\
max_estimate = 10\nunit = \"d\"\npolicy = \"block\"\n";
let path = write_toml(tmp.path(), configured);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let board = config.default_config();
let limit = board
.wip
.effective_limit("in progress")
.expect("the configured limit is in force");
assert_eq!(limit.max_items, Some(3));
assert_eq!(limit.max_estimate, Some(10));
assert_eq!(limit.unit.as_deref(), Some("d"));
assert_eq!(limit.policy, Some(crate::wip::LimitPolicy::Block));
assert_eq!(board.wip.factor("w"), Some(5));
assert!(
board.wip.effective_limit("done").is_none(),
"a column without a limit has none"
);
config.save(&path).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
configured,
"a configured board must round trip byte-for-byte"
);
}
#[test]
fn wip_config_limit_on_an_unknown_column_is_refused() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[kanban.default.wip.limits.\"in progress\"]\nmax_items = 3\n\
policy = \"warn\"\n",
);
let error = Config::load_with_warning(&path, |_| {})
.expect_err("an unknown column is refused")
.to_string();
assert!(
error.contains("kanban.default.wip.limits.`in progress`"),
"{error}"
);
}
#[test]
fn wip_config_absent_limits_add_no_key() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(config.default_config().wip.is_empty());
config.save(&path).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
LEGACY_PRETTY_BOARD,
"a board without limits must not gain a `wip` key"
);
}
const TWO_PROJECTS: &str = "issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[projects.\"p-3f9a2c\"]\n\
name = \"Platform hardening\"\n\
description = \"Reduce operational risk across the sync path.\"\n\
status = \"active\"\n\
lead = \"Alice <alice@example.com>\"\n\
start_date = \"2026-09-01\"\n\
due_date = \"2026-12-31\"\n\n\
[projects.\"p-7b1e04\"]\n\
name = \"Docs refresh\"\n";
#[test]
fn project_registry_parses_every_contract_field() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), TWO_PROJECTS);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let platform = &config.projects["p-3f9a2c"];
assert_eq!(platform.name, "Platform hardening");
assert_eq!(
platform.description.as_deref(),
Some("Reduce operational risk across the sync path.")
);
assert_eq!(platform.status.as_deref(), Some("active"));
assert_eq!(platform.lead.as_deref(), Some("Alice <alice@example.com>"));
assert_eq!(platform.start_date.as_deref(), Some("2026-09-01"));
assert_eq!(platform.due_date.as_deref(), Some("2026-12-31"));
let docs = &config.projects["p-7b1e04"];
assert_eq!(docs.name, "Docs refresh");
assert!(docs.description.is_none() && docs.status.is_none() && docs.lead.is_none());
assert!(docs.start_date.is_none() && docs.due_date.is_none());
}
#[test]
fn project_registry_rejects_an_unknown_field() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[projects.\"p-1\"]\nname = \"One\"\nownr = \"typo\"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("ownr"), "got {err}");
}
#[test]
fn project_declaration_order_is_restored() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[projects.\"p-zeta\"]\nname = \"Zeta\"\n\n\
[projects.\"p-alpha\"]\nname = \"Alpha\"\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
config.projects.keys().collect::<Vec<_>>(),
["p-zeta", "p-alpha"],
"declaration order is the display order"
);
config.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
reloaded.projects.keys().collect::<Vec<_>>(),
["p-zeta", "p-alpha"],
"declaration order survives a save/reload through the ordered map"
);
}
#[test]
fn a_legacy_tix_toml_gains_no_projects_section() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(config.projects.is_empty());
config.save(&path).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
LEGACY_PRETTY_BOARD,
"a repository with no projects must round-trip byte-identically"
);
}
#[test]
fn an_empty_project_record_writes_no_optional_keys() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[projects.\"p-1\"]\nname = \"One\"\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
config.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
for key in ["description", "status", "lead", "start_date", "due_date"] {
assert!(
!after.contains(key),
"an absent optional field must not be written: {key} in {after}"
);
}
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(reloaded, config);
}
#[test]
fn a_blank_project_name_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[projects.\"p-1\"]\nname = \" \"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("projects.p-1.name"), "got {err}");
}
#[test]
fn duplicate_project_names_are_rejected() {
for second in ["Same", "same"] {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
&format!(
"issues_dir = \"docs/issues\"\n\n\
[projects.\"p-1\"]\nname = \"Same\"\n\n\
[projects.\"p-2\"]\nname = \"{second}\"\n"
),
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("must be unique"), "got {err}");
}
}
#[test]
fn a_padded_project_name_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[projects.\"p-1\"]\nname = \" One \"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("leading or trailing whitespace"), "got {err}");
}
#[test]
fn a_blank_project_id_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[projects.\"\"]\nname = \"No id\"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("must not be blank"), "got {err}");
}
#[test]
fn a_project_id_with_a_structural_character_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[projects.\"p.1\"]\nname = \"Dotted\"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("contains"), "got {err}");
}
#[test]
fn blank_optional_project_text_is_rejected() {
for key in ["description", "status", "lead"] {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
&format!(
"issues_dir = \"docs/issues\"\n\n\
[projects.\"p-1\"]\nname = \"One\"\n{key} = \"\"\n"
),
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains(&format!("projects.p-1.{key}")), "got {err}");
}
}
#[test]
fn a_bad_or_inverted_project_date_is_rejected() {
let tmp = TempDir::new().unwrap();
let bad = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[projects.\"p-1\"]\nname = \"One\"\nstart_date = \"2026-13-01\"\n",
);
let err = Config::load_with_warning(&bad, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("projects.p-1"), "got {err}");
let inverted = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[projects.\"p-1\"]\nname = \"One\"\n\
start_date = \"2026-12-01\"\ndue_date = \"2026-01-01\"\n",
);
let err = Config::load_with_warning(&inverted, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("projects.p-1"), "got {err}");
}
#[test]
fn a_valid_project_date_pair_passes() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), TWO_PROJECTS);
Config::load_with_warning(&path, |_| {}).expect("a valid registry must load");
}
#[test]
fn allocated_project_ids_have_the_generated_shape_and_successive_draws_differ() {
let mut config = Config::default();
let first = config.allocate_project_id();
config.projects.insert(
first.to_string(),
ProjectConfiguration {
name: "One".into(),
..ProjectConfiguration::default()
},
);
let second = config.allocate_project_id();
assert_ne!(first, second, "two allocations must be distinct");
assert_eq!(first.as_str().len(), 18, "`p-` plus 16 hex digits");
assert!(first.as_str().starts_with("p-"));
config.projects.shift_remove(first.as_str());
let third = config.allocate_project_id();
assert_ne!(
third, first,
"a deleted project's id must not be handed out"
);
assert_ne!(third, second);
}
#[test]
fn allocation_redraws_past_a_stored_id() {
let mut config = Config::default();
config.projects.insert(
"p-aaaa".into(),
ProjectConfiguration {
name: "Stored".into(),
..ProjectConfiguration::default()
},
);
let scripted = ["p-aaaa", "p-bbbb"];
let mut draws = 0;
let allocated = config.allocate_project_id_with(|| {
let drawn = scripted[draws];
draws += 1;
ProjectId::new(drawn).unwrap()
});
assert_eq!(allocated.as_str(), "p-bbbb");
assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
}
#[test]
fn schedule_allocation_redraws_past_a_stored_id() {
let mut config = Config::default();
config.cycle_schedules.insert(
"cs-aaaa".into(),
crate::cycle_schedule::config::CycleScheduleConfiguration {
name: "Stored".into(),
..crate::cycle_schedule::config::CycleScheduleConfiguration::default()
},
);
let scripted = ["cs-aaaa", "cs-bbbb"];
let mut draws = 0;
let allocated = config.allocate_cycle_schedule_id_with(|| {
let drawn = scripted[draws];
draws += 1;
crate::cycle_schedule::config::CycleScheduleId::new(drawn).unwrap()
});
assert_eq!(allocated.as_str(), "cs-bbbb");
assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
}
#[test]
fn a_hand_written_project_id_loads_and_is_never_allocated_again() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[projects.\"p-ffffffffffffffff\"]\nname = \"Hand written\"\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(config.projects.contains_key("p-ffffffffffffffff"));
let mut draws = 0;
let allocated = config.allocate_project_id_with(|| {
draws += 1;
if draws < 3 {
ProjectId::new("p-ffffffffffffffff").unwrap()
} else {
ProjectId::new("p-0000000000000000").unwrap()
}
});
assert_eq!(allocated.as_str(), "p-0000000000000000");
assert_eq!(draws, 3);
}
#[test]
fn a_rename_changes_the_label_and_never_the_key() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), TWO_PROJECTS);
let mut config = Config::load_with_warning(&path, |_| {}).unwrap();
config.projects["p-3f9a2c"].name = "Platform hardening (renamed)".into();
config.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(
reloaded.projects.contains_key("p-3f9a2c"),
"the id is the key and must survive a rename"
);
assert_eq!(
reloaded.projects["p-3f9a2c"].name,
"Platform hardening (renamed)"
);
}
#[test]
fn project_id_validation_accepts_handles_and_rejects_junk() {
assert!(ProjectId::new("p-platform").is_ok());
assert!(ProjectId::new("p-3f9a2c").is_ok());
assert!(ProjectId::new("").is_err());
assert!(ProjectId::new(" p-1").is_err(), "surrounding whitespace");
assert!(ProjectId::new("p 1").is_err(), "inner whitespace");
assert!(ProjectId::new("p.1").is_err(), "structural `.`");
assert!(ProjectId::new(&"x".repeat(ProjectId::MAX_LEN + 1)).is_err());
}
const TWO_EPICS: &str = "issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[epics.\"e-7c4a91\"]\n\
name = \"Harden the sync path\"\n\
description = \"Reduce operational risk across every tracker adapter.\"\n\
status = \"active\"\n\
owner = \"Alice <alice@example.com>\"\n\
start_date = \"2026-09-01\"\n\
due_date = \"2026-12-31\"\n\
weight = 5\n\
parent = \"e-2b8f30\"\n\
projects = [\"p-3f9a2c\"]\n\
milestone = \"2026-Q4\"\n\
cycle = \"October 2026\"\n\n\
[epics.\"e-2b8f30\"]\n\
name = \"Q4 reliability\"\n";
#[test]
fn epic_registry_parses_every_contract_field() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), TWO_EPICS);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let sync = &config.epics["e-7c4a91"];
assert_eq!(sync.name, "Harden the sync path");
assert_eq!(
sync.description.as_deref(),
Some("Reduce operational risk across every tracker adapter.")
);
assert_eq!(sync.status.as_deref(), Some("active"));
assert_eq!(sync.owner.as_deref(), Some("Alice <alice@example.com>"));
assert_eq!(sync.start_date.as_deref(), Some("2026-09-01"));
assert_eq!(sync.due_date.as_deref(), Some("2026-12-31"));
assert_eq!(sync.weight, Some(5));
assert_eq!(sync.parent.as_deref(), Some("e-2b8f30"));
assert_eq!(sync.projects, ["p-3f9a2c"]);
assert_eq!(sync.milestone.as_deref(), Some("2026-Q4"));
assert_eq!(sync.cycle.as_deref(), Some("October 2026"));
let q4 = &config.epics["e-2b8f30"];
assert_eq!(q4.name, "Q4 reliability");
assert!(q4.description.is_none() && q4.status.is_none() && q4.owner.is_none());
assert!(q4.start_date.is_none() && q4.due_date.is_none());
assert!(q4.weight.is_none() && q4.parent.is_none() && q4.milestone.is_none());
assert!(q4.cycle.is_none());
assert_eq!(q4.projects, [] as [std::string::String; 0]);
}
#[test]
fn epic_registry_rejects_an_unknown_field() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[epics.\"e-1\"]\nname = \"One\"\nownr = \"typo\"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("ownr"), "got {err}");
}
#[test]
fn an_epic_record_takes_weight_but_never_an_estimate_field() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[epics.\"e-1\"]\nname = \"One\"\nestimate = \"2\"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("estimate"), "got {err}");
}
#[test]
fn epic_declaration_order_is_restored() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[epics.\"e-zeta\"]\nname = \"Zeta\"\n\n\
[epics.\"e-alpha\"]\nname = \"Alpha\"\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
config.epics.keys().collect::<Vec<_>>(),
["e-zeta", "e-alpha"],
"declaration order is the display order"
);
config.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
reloaded.epics.keys().collect::<Vec<_>>(),
["e-zeta", "e-alpha"],
"declaration order survives a save/reload through the ordered map"
);
}
#[test]
fn a_legacy_tix_toml_gains_no_epics_section() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(config.epics.is_empty());
config.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(
after, LEGACY_PRETTY_BOARD,
"a repository with no epics must round-trip byte-identically"
);
assert!(
!after.contains("[epics"),
"an empty registry writes no section: {after}"
);
}
#[test]
fn epics_and_projects_registries_coexist() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), TWO_PROJECTS);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(config.epics.is_empty(), "no `[epics]` means no epics");
config.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert!(!after.contains("[epics"), "got {after}");
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(reloaded.projects, config.projects);
assert!(reloaded.epics.is_empty());
let both_dir = TempDir::new().unwrap();
let both = write_toml(
both_dir.path(),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[projects.\"p-3f9a2c\"]\nname = \"Platform\"\n\n\
[epics.\"e-1\"]\nname = \"Harden\"\nprojects = [\"p-3f9a2c\"]\n",
);
let config = Config::load_with_warning(&both, |_| {}).unwrap();
assert_eq!(config.projects.keys().collect::<Vec<_>>(), ["p-3f9a2c"]);
assert_eq!(config.epics.keys().collect::<Vec<_>>(), ["e-1"]);
config.save(&both).unwrap();
let reloaded = Config::load_with_warning(&both, |_| {}).unwrap();
assert_eq!(reloaded.projects, config.projects);
assert_eq!(reloaded.epics, config.epics);
}
#[test]
fn an_empty_epic_record_writes_no_optional_keys() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[epics.\"e-1\"]\nname = \"One\"\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
config.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
for key in [
"description",
"status",
"owner",
"start_date",
"due_date",
"weight",
"parent",
"projects",
"milestone",
"cycle",
] {
assert!(
!after.contains(key),
"an absent optional field must not be written: {key} in {after}"
);
}
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(reloaded, config);
}
#[test]
fn a_blank_epic_name_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \" \"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("epics.e-1.name"), "got {err}");
}
#[test]
fn duplicate_epic_names_are_rejected() {
for second in ["Same", "same"] {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
&format!(
"issues_dir = \"docs/issues\"\n\n\
[epics.\"e-1\"]\nname = \"Same\"\n\n\
[epics.\"e-2\"]\nname = \"{second}\"\n"
),
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("must be unique"), "got {err}");
}
}
#[test]
fn a_padded_epic_name_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \" One \"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("leading or trailing whitespace"), "got {err}");
}
#[test]
fn a_blank_epic_id_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[epics.\"\"]\nname = \"No id\"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("must not be blank"), "got {err}");
}
#[test]
fn an_epic_id_with_a_structural_character_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[epics.\"e.1\"]\nname = \"Dotted\"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("contains"), "got {err}");
}
#[test]
fn blank_optional_epic_text_is_rejected() {
for key in ["description", "status", "owner", "milestone", "cycle"] {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
&format!(
"issues_dir = \"docs/issues\"\n\n\
[epics.\"e-1\"]\nname = \"One\"\n{key} = \"\"\n"
),
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains(&format!("epics.e-1.{key}")), "got {err}");
}
}
#[test]
fn a_bad_or_inverted_epic_date_is_rejected() {
let tmp = TempDir::new().unwrap();
let bad = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[epics.\"e-1\"]\nname = \"One\"\nstart_date = \"2026-13-01\"\n",
);
let err = Config::load_with_warning(&bad, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("epics.e-1"), "got {err}");
let inverted = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[epics.\"e-1\"]\nname = \"One\"\n\
start_date = \"2026-12-01\"\ndue_date = \"2026-01-01\"\n",
);
let err = Config::load_with_warning(&inverted, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("epics.e-1"), "got {err}");
}
#[test]
fn a_non_positive_or_unparseable_weight_is_rejected() {
for body in ["weight = 0", "weight = -3"] {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
&format!("issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \"One\"\n{body}\n"),
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("positive integer"), "{body}: got {err}");
assert!(err.contains("epics.e-1.weight"), "{body}: got {err}");
}
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \"One\"\nweight = \"big\"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("weight"), "got {err}");
}
#[test]
fn epic_references_are_shape_checked_but_never_resolved() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[epics.\"e-1\"]\nname = \"Child\"\n\
parent = \"e-missing\"\nprojects = [\"p-missing\"]\n\
milestone = \"2026-Q4\"\ncycle = \"No such cycle\"\n",
);
let config =
Config::load_with_warning(&path, |_| {}).expect("a dangling reference must stay loadable");
let epic = &config.epics["e-1"];
assert_eq!(epic.parent.as_deref(), Some("e-missing"));
assert_eq!(epic.projects, ["p-missing"]);
assert_eq!(epic.milestone.as_deref(), Some("2026-Q4"));
assert_eq!(epic.cycle.as_deref(), Some("No such cycle"));
for (body, needle) in [
("parent = \"e.1\"", "epics.e-1.parent"),
("parent = \"\"", "epics.e-1.parent"),
("parent = \"e-1\"", "must not name the epic itself"),
("projects = [\"p.1\"]", "epics.e-1.projects"),
] {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
&format!("issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \"One\"\n{body}\n"),
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains(needle), "{body}: got {err}");
}
}
#[test]
fn allocated_epic_ids_have_the_generated_shape_and_successive_draws_differ() {
let mut config = Config::default();
let first = config.allocate_epic_id();
config.epics.insert(
first.to_string(),
EpicConfiguration {
name: "One".into(),
..EpicConfiguration::default()
},
);
let second = config.allocate_epic_id();
assert_ne!(first, second, "two allocations must be distinct");
assert_eq!(first.as_str().len(), 18, "`e-` plus 16 hex digits");
assert!(first.as_str().starts_with("e-"));
config.epics.shift_remove(first.as_str());
let third = config.allocate_epic_id();
assert_ne!(third, first, "a deleted epic's id must not be handed out");
assert_ne!(third, second);
}
#[test]
fn epic_allocation_redraws_past_a_stored_id() {
let mut config = Config::default();
config.epics.insert(
"e-aaaa".into(),
EpicConfiguration {
name: "Stored".into(),
..EpicConfiguration::default()
},
);
let scripted = ["e-aaaa", "e-bbbb"];
let mut draws = 0;
let allocated = config.allocate_epic_id_with(|| {
let drawn = scripted[draws];
draws += 1;
EpicId::new(drawn).unwrap()
});
assert_eq!(allocated.as_str(), "e-bbbb");
assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
}
#[test]
fn a_hand_written_epic_id_loads_and_is_never_allocated_again() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[epics.\"e-ffffffffffffffff\"]\nname = \"Hand written\"\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(config.epics.contains_key("e-ffffffffffffffff"));
let mut draws = 0;
let allocated = config.allocate_epic_id_with(|| {
draws += 1;
if draws < 3 {
EpicId::new("e-ffffffffffffffff").unwrap()
} else {
EpicId::new("e-0000000000000000").unwrap()
}
});
assert_eq!(allocated.as_str(), "e-0000000000000000");
assert_eq!(draws, 3);
}
#[test]
fn an_epic_rename_changes_the_label_and_never_the_key() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), TWO_EPICS);
let mut config = Config::load_with_warning(&path, |_| {}).unwrap();
config.epics["e-7c4a91"].name = "Harden the sync path (renamed)".into();
config.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(
reloaded.epics.contains_key("e-7c4a91"),
"the id is the key and must survive a rename"
);
assert_eq!(
reloaded.epics["e-7c4a91"].name,
"Harden the sync path (renamed)"
);
}
#[test]
fn epic_id_validation_accepts_handles_and_rejects_junk() {
assert!(EpicId::new("e-platform").is_ok());
assert!(EpicId::new("e-7c4a91").is_ok());
assert!(EpicId::new("").is_err());
assert!(EpicId::new(" e-1").is_err(), "surrounding whitespace");
assert!(EpicId::new("e 1").is_err(), "inner whitespace");
assert!(EpicId::new("e.1").is_err(), "structural `.`");
assert!(EpicId::new(&"x".repeat(EpicId::MAX_LEN + 1)).is_err());
}
#[test]
fn the_two_registries_share_the_id_rules() {
assert_eq!(
ProjectId::new("").unwrap_err().to_string(),
"config error: a project id must not be blank"
);
assert_eq!(
EpicId::new("").unwrap_err().to_string(),
"config error: an epic id must not be blank"
);
assert_eq!(
ProjectId::new(" p-1").unwrap_err().to_string(),
"config error: project id ` p-1` has leading or trailing whitespace"
);
assert_eq!(
EpicId::new("e-1 ").unwrap_err().to_string(),
"config error: epic id `e-1 ` has leading or trailing whitespace"
);
assert_eq!(ProjectId::MAX_LEN, EpicId::MAX_LEN);
for handle in ["x-y", "a_b", "a:b", "a-1"] {
assert!(ProjectId::new(handle).is_ok(), "{handle}");
assert!(EpicId::new(handle).is_ok(), "{handle}");
}
for junk in ["", " a", "a b", "a.b", "a[b", "a\"b"] {
assert!(ProjectId::new(junk).is_err(), "{junk}");
assert!(EpicId::new(junk).is_err(), "{junk}");
}
}
const ONE_INITIATIVE: &str = "issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[initiatives.\"i-1\"]\nname = \"Grow the platform\"\n";
#[test]
fn initiative_id_validation_accepts_handles_and_rejects_junk() {
assert!(InitiativeId::new("i-outcome").is_ok());
assert!(InitiativeId::new("i-7c4a91").is_ok());
assert!(InitiativeId::new("").is_err());
assert!(InitiativeId::new(" i-1").is_err(), "surrounding whitespace");
assert!(InitiativeId::new("i 1").is_err(), "inner whitespace");
assert!(InitiativeId::new("i.1").is_err(), "structural `.`");
assert!(InitiativeId::new(&"x".repeat(InitiativeId::MAX_LEN + 1)).is_err());
}
#[test]
fn the_three_registries_share_the_id_rules() {
assert_eq!(
InitiativeId::new("").unwrap_err().to_string(),
"config error: an initiative id must not be blank"
);
assert_eq!(
InitiativeId::new(" i-1").unwrap_err().to_string(),
"config error: initiative id ` i-1` has leading or trailing whitespace"
);
assert_eq!(InitiativeId::MAX_LEN, ProjectId::MAX_LEN);
assert_eq!(InitiativeId::MAX_LEN, EpicId::MAX_LEN);
}
#[test]
fn allocated_initiative_ids_have_the_generated_shape_and_successive_draws_differ() {
let mut config = Config::default();
let first = config.allocate_initiative_id();
config.initiatives.insert(
first.to_string(),
InitiativeConfiguration {
name: "One".into(),
..InitiativeConfiguration::default()
},
);
let second = config.allocate_initiative_id();
assert_ne!(first, second, "two allocations must be distinct");
assert_eq!(first.as_str().len(), 18, "`i-` plus 16 hex digits");
assert!(first.as_str().starts_with("i-"));
config.initiatives.shift_remove(first.as_str());
let third = config.allocate_initiative_id();
assert_ne!(
third, first,
"a deleted initiative's id must not be handed out"
);
assert_ne!(third, second);
}
#[test]
fn initiative_allocation_redraws_past_a_stored_id() {
let mut config = Config::default();
config.initiatives.insert(
"i-aaaa".into(),
InitiativeConfiguration {
name: "Stored".into(),
..InitiativeConfiguration::default()
},
);
let scripted = ["i-aaaa", "i-bbbb"];
let mut draws = 0;
let allocated = config.allocate_initiative_id_with(|| {
let drawn = scripted[draws];
draws += 1;
InitiativeId::new(drawn).unwrap()
});
assert_eq!(allocated.as_str(), "i-bbbb");
assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
}
#[test]
fn an_absent_initiatives_section_and_an_empty_record_write_nothing_extra() {
let tmp = TempDir::new().unwrap();
let legacy = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[kanban.default]\ncolumns = [\"todo\", \"done\"]\n",
);
let config = Config::load_with_warning(&legacy, |_| {}).unwrap();
assert!(config.initiatives.is_empty());
config.save(&legacy).unwrap();
assert!(
!std::fs::read_to_string(&legacy)
.unwrap()
.contains("[initiatives"),
"a legacy file gains no section"
);
let path = write_toml(tmp.path(), ONE_INITIATIVE);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
config.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
for key in [
"outcome",
"owner",
"status",
"priority",
"labels",
"target_date",
"projects",
"epics",
"milestones",
] {
assert!(
!after.contains(key),
"an absent optional field must not be written: {key} in {after}"
);
}
assert_eq!(Config::load_with_warning(&path, |_| {}).unwrap(), config);
}
#[test]
fn an_initiative_preserves_unknown_metadata_through_a_round_trip() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[initiatives.\"i-1\"]\nname = \"One\"\ncustom_field = \"keep me\"\n\
nested = { answer = 42 }\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let extensions = &config.initiatives["i-1"].extensions;
assert_eq!(extensions.len(), 2);
assert_eq!(
extensions.get("custom_field").and_then(toml::Value::as_str),
Some("keep me")
);
config.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(reloaded.initiatives["i-1"].extensions, *extensions);
}
#[test]
fn initiative_validation_refuses_blank_names_bad_dates_and_bad_references() {
for (body, needle) in [
("name = \" \"", "initiatives.i-1.name"),
("name = \" One \"", "leading or trailing whitespace"),
(
"name = \"One\"\ntarget_date = \"2026-13-01\"",
"initiatives.i-1.target_date",
),
(
"name = \"One\"\ntarget_date = \"not a date\"",
"initiatives.i-1.target_date",
),
(
"name = \"One\"\nprojects = [\"p.1\"]",
"initiatives.i-1.projects",
),
("name = \"One\"\nepics = [\"e.1\"]", "initiatives.i-1.epics"),
(
"name = \"One\"\nmilestones = [\"\"]",
"initiatives.i-1.milestones",
),
(
"name = \"One\"\nlabels = [\" \"]",
"initiatives.i-1.labels",
),
("name = \"One\"\nowner = \"\"", "initiatives.i-1.owner"),
] {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
&format!(
"issues_dir = \"docs/issues\"\n\n[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[initiatives.\"i-1\"]\n{body}\n"
),
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains(needle), "{body}: got {err}");
}
}
#[test]
fn duplicate_initiative_names_are_rejected_case_folded() {
for second in ["Same", "same"] {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
&format!(
"issues_dir = \"docs/issues\"\n\n\
[initiatives.\"i-1\"]\nname = \"Same\"\n\n\
[initiatives.\"i-2\"]\nname = \"{second}\"\n"
),
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("must be unique"), "got {err}");
}
}
#[test]
fn initiative_references_are_shape_checked_but_never_resolved() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[initiatives.\"i-1\"]\nname = \"One\"\n\
projects = [\"p-missing\"]\nepics = [\"e-missing\"]\nmilestones = [\"2026-Q4\"]\n",
);
let config =
Config::load_with_warning(&path, |_| {}).expect("a dangling reference must stay loadable");
let initiative = &config.initiatives["i-1"];
assert_eq!(initiative.projects, ["p-missing"]);
assert_eq!(initiative.epics, ["e-missing"]);
assert_eq!(initiative.milestones, ["2026-Q4"]);
}
#[test]
fn initiative_declaration_order_survives_a_save_and_reload() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[initiatives.\"i-zulu\"]\nname = \"Zulu\"\n\n\
[initiatives.\"i-alpha\"]\nname = \"Alpha\"\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
config.initiatives.keys().collect::<Vec<_>>(),
["i-zulu", "i-alpha"]
);
config.save(&path).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert!(
written.find("[initiatives.i-zulu]").unwrap()
< written.find("[initiatives.i-alpha]").unwrap(),
"declaration order is preserved on write:\n{written}"
);
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
reloaded.initiatives.keys().collect::<Vec<_>>(),
["i-zulu", "i-alpha"]
);
}
#[test]
fn an_initiative_priority_uses_the_shared_shape() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[initiatives.\"i-1\"]\nname = \"Known\"\npriority = \"HIGH\"\n\n\
[initiatives.\"i-2\"]\nname = \"Unknown\"\npriority = \"urgent-ish\"\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
config.initiatives["i-1"].priority,
Some(crate::model::Priority::High)
);
assert_eq!(
config.initiatives["i-2"].priority,
Some(crate::model::Priority::Other("urgent-ish".into()))
);
config.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("priority = \"high\""), "{written}");
assert!(written.contains("priority = \"urgent-ish\""), "{written}");
assert_eq!(reloaded.initiatives, config.initiatives);
}
#[test]
fn a_shadowing_extension_key_is_refused_by_validation() {
for key in INITIATIVE_KNOWN_FIELDS {
let mut extensions = crate::roadmap::registry::InitiativeExtensions::default();
extensions.insert(*key, toml::Value::String("evil".into()));
let mut config = Config::default();
config.initiatives.insert(
"i-1".into(),
InitiativeConfiguration {
name: "One".into(),
extensions,
..InitiativeConfiguration::default()
},
);
let err = config.validate().unwrap_err().to_string();
assert!(
err.contains("collides with a built-in field"),
"{key}: got {err}"
);
}
let mut config = Config::default();
config.initiatives.insert(
"i-1".into(),
InitiativeConfiguration {
name: "One".into(),
..InitiativeConfiguration::default()
},
);
config.validate().unwrap();
}