use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use indexmap::IndexMap;
use tix::config::{BoardConfiguration, CategoryConfig, Config, KanbanSection};
const MAPPED_COLUMNS: [&str; 5] = ["triage", "wip", "review", "shipped", "abandoned"];
const MAPPED_MAP: [(&str, &str); 5] = [
("triage", "backlog"),
("wip", "active"),
("review", "active"),
("shipped", "completed"),
("abandoned", "canceled"),
];
fn mapped_board() -> BoardConfiguration {
BoardConfiguration {
columns: MAPPED_COLUMNS.iter().map(|c| (*c).to_string()).collect(),
done_column: Some("shipped".to_string()),
category_map: MAPPED_MAP
.iter()
.map(|(c, k)| ((*c).to_string(), (*k).to_string()))
.collect(),
..Default::default()
}
}
fn split_completion_board() -> BoardConfiguration {
BoardConfiguration {
columns: vec!["todo".into(), "shipped".into(), "done".into()],
done_column: Some("done".to_string()),
category_map: IndexMap::from([
("todo".to_string(), "unstarted".to_string()),
("shipped".to_string(), "completed".to_string()),
("done".to_string(), "active".to_string()),
]),
..Default::default()
}
}
fn config_with(board: BoardConfiguration) -> Config {
Config {
kanban: KanbanSection {
configurations: IndexMap::from([("default".to_string(), board)]),
},
..Config::default()
}
}
fn mapped_config() -> Config {
config_with(mapped_board())
}
fn split_completion_config() -> Config {
config_with(split_completion_board())
}
fn repo_with(config: &Config, columns: &[&str]) -> (tempfile::TempDir, tix::storage::Store) {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("docs").join("issues");
fs::create_dir_all(&dir).unwrap();
let mut config = config.clone();
config.issues_dir = dir.clone();
config.save(&tmp.path().join("tix.toml")).unwrap();
let config = Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
let store = tix::storage::Store {
issues_dir: config.issues_dir.clone(),
config: config.clone(),
};
for (i, column) in columns.iter().enumerate() {
let title = format!("Issue{}", i + 1);
tix::commands::create::create(
&config,
&store,
&title,
None,
Some(tix::model::Status::new(*column)),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
}
(tmp, store)
}
fn mapped_repo() -> (tempfile::TempDir, tix::storage::Store) {
repo_with(
&mapped_config(),
&["triage", "wip", "review", "shipped", "abandoned"],
)
}
fn tix_bin() -> PathBuf {
let mut path = std::env::current_exe().unwrap();
path.pop();
if path.ends_with("deps") {
path.pop();
}
path.join("tix")
}
fn cli(dir: &Path, args: &[&str]) -> std::process::Output {
Command::new(tix_bin())
.current_dir(dir)
.args(args)
.output()
.expect("the tix binary")
}
fn stdout_of(out: &std::process::Output) -> String {
String::from_utf8_lossy(&out.stdout).to_string()
}
fn stderr_of(out: &std::process::Output) -> String {
String::from_utf8_lossy(&out.stderr).to_string()
}
fn snapshot(dir: &Path) -> Vec<(String, Vec<u8>)> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(cur) = stack.pop() {
for entry in fs::read_dir(&cur).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
} else {
let rel = path
.strip_prefix(dir)
.unwrap()
.to_string_lossy()
.to_string();
out.push((rel, fs::read(&path).unwrap()));
}
}
}
out.sort();
out
}
fn status_of(store: &tix::storage::Store, number: u32) -> String {
let issue = store
.get(tix::storage::IssueSelector::Number(number))
.unwrap();
issue.status.display().to_string()
}
fn diagnostic_of(text: &str) -> String {
const KINDS: [&str; 7] = [
"missing_completion_category",
"unknown_category",
"mapping_for_unknown_column",
"unmapped_column_default",
"unknown_column",
"invalid_transition",
"direct_edit_policy_violation",
];
KINDS
.iter()
.find(|k| text.contains(**k))
.map_or_else(String::new, |k| (*k).to_string())
}
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn mcp(
config: &Config,
store: &tix::storage::Store,
tool: &str,
args: serde_json::Value,
) -> serde_json::Value {
tix::mcp::tools::call(
config,
store,
tix::mcp::protocol::Id::Number(1),
tool,
&args,
)
.expect("a protocol-level call")
}
#[test]
fn nfr002_paths_1_and_2_cli_mv_and_edit_agree_with_the_service() {
let cases: [(&str, &[&str], &[&str]); 2] = [
(
"tix mv",
&["move", "4", "triage"],
&["move", "4", "triage", "--reason", "reopening"],
),
(
"tix edit --status",
&["edit", "4", "--status", "triage"],
&["edit", "4", "--status", "triage", "--reason", "reopening"],
),
];
for (label, refuse_args, accept_args) in cases {
let (tmp, _store) = mapped_repo();
let out = cli(tmp.path(), refuse_args);
assert!(!out.status.success(), "{label}: the move must be refused");
assert_eq!(
diagnostic_of(&stderr_of(&out)),
"invalid_transition",
"{label}: the named diagnostic"
);
let (tmp, store2) = mapped_repo();
let out = cli(tmp.path(), accept_args);
assert!(out.status.success(), "{label}: {}", stderr_of(&out));
assert_eq!(status_of(&store2, 4), "triage", "{label}: applied");
}
let (_tmp, store) = mapped_repo();
let issue = store.get(tix::storage::IssueSelector::Number(4)).unwrap();
let (_, resolution) = tix::workflow::active_resolution(&store.config);
let refused =
tix::workflow::validate_transition(&issue, "triage", &resolution, &store.config, None);
assert!(matches!(
refused,
tix::workflow::TransitionOutcome::Rejected(_)
));
let accepted = tix::workflow::validate_transition(
&issue,
"triage",
&resolution,
&store.config,
Some("reopening"),
);
assert!(matches!(
accepted,
tix::workflow::TransitionOutcome::Accepted { .. }
));
let _ = &store;
}
#[test]
fn nfr002_path_3_tui_keyboard_move_reaches_the_service() {
use tix::app::{App, Mode};
let (tmp, store) = mapped_repo();
let mut app = App::new(&store.config).unwrap();
app.board.select_column(0);
app.board.selected = 0;
let before = snapshot(tmp.path());
app.handle_key(press(KeyCode::Char('>'))).unwrap();
assert_eq!(
status_of(&store, 1),
"wip",
"the TUI's own writer moved the issue (mode {:?}, col {}, selected {})",
app.mode,
app.board.active_col,
app.board.selected
);
assert_ne!(
snapshot(tmp.path()),
before,
"a legal TUI move does change the fixture"
);
let (tmp2, store2) = mapped_repo();
let mut app2 = App::new(&store2.config).unwrap();
app2.board.select_column(3); app2.board.selected = 0;
let before2 = snapshot(tmp2.path());
app2.handle_key(press(KeyCode::Char('<'))).unwrap();
assert!(
matches!(app2.mode, Mode::TransitionReason { .. }),
"the TUI must route the refusal to its reason prompt, not fail silently; \
got mode {:?}",
app2.mode
);
let Mode::TransitionReason { reason, .. } = &app2.mode else {
unreachable!()
};
assert!(
reason.contains("invalid_transition"),
"and it must be the named diagnostic the CLI printed: {reason}"
);
assert_eq!(
snapshot(tmp2.path()),
before2,
"SC-005: a refused TUI transition writes nothing at all — no file, no \
date, no History entry"
);
assert_eq!(
status_of(&store2, 4),
"shipped",
"and the issue is where it was"
);
}
#[test]
fn nfr002_path_4_the_tui_has_no_form_status_write_that_bypasses_the_seam() {
let source = app_source();
assert!(
!source.contains("issue.status = "),
"`issue.status = ` anywhere under `src/app/` is a status write outside the \
seam; `Status` is a tuple struct with a public field, so such a write \
compiles and silently skips both the policy and the decision (research R7)"
);
assert!(
source.contains("TransitionReason"),
"the reasoned-override prompt is the form's replacement for a `--reason` flag"
);
}
fn app_source() -> String {
fn walk(dir: &Path, out: &mut String) {
for entry in fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
walk(&path, out);
} else if path.extension().is_some_and(|extension| extension == "rs") {
let text = fs::read_to_string(&path).unwrap();
out.push_str(text.split("#[cfg(test)]").next().unwrap_or(&text));
out.push('\n');
}
}
}
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/app");
let mut out = String::new();
walk(&root, &mut out);
out
}
#[test]
fn nfr002_paths_5_and_6_mcp_move_and_update_agree_with_the_cli() {
let (tmp_a, _store_a) = mapped_repo();
let out = cli(tmp_a.path(), &["move", "3", "triage"]);
let cli_diagnostic = diagnostic_of(&stderr_of(&out));
assert_eq!(cli_diagnostic, "invalid_transition", "the CLI's answer");
let (_tmp_b, store_b) = mapped_repo();
for tool in ["move_issue", "update_issue"] {
let result = mcp(
&store_b.config,
&store_b,
tool,
serde_json::json!({ "issue": "4", "column": "triage", "status": "triage" }),
);
assert_eq!(
result["isError"], true,
"{tool}: the CLI refused the same move"
);
assert_eq!(
diagnostic_of(result["content"][0]["text"].as_str().unwrap()),
cli_diagnostic,
"{tool}: the **same named** diagnostic as the CLI, not merely a refusal"
);
let text = result["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("reason"),
"{tool}: the message must point at the same escape hatch; got {text:?}"
);
}
assert_eq!(status_of(&store_b, 4), "shipped", "and nothing was written");
}
#[test]
fn nfr002_path_7_bulk_operations_report_per_item_outcomes() {
let (tmp, store) = mapped_repo();
let moved = cli(tmp.path(), &["move", "5", "wip", "--reason", "reopening"]);
assert!(moved.status.success(), "{}", stderr_of(&moved));
assert_eq!(status_of(&store, 5), "wip");
let refused = cli(tmp.path(), &["move", "4", "triage"]);
assert!(!refused.status.success());
assert_eq!(diagnostic_of(&stderr_of(&refused)), "invalid_transition");
assert_eq!(
status_of(&store, 4),
"shipped",
"the accepted sibling is unaffected by the refused one"
);
}
#[test]
fn nfr002_path_8_the_import_path_validates_through_the_service() {
let source = include_str!("../src/sync/import.rs");
assert!(
!source.contains("remote_to_local_status(tool, &remote.state, config)];\n if let Some(a)"),
"the unconditional status write is back in the import path: the overwrite \\
assigns the incoming status without consulting the service"
);
assert!(
source.contains("crate::workflow::validate_transition("),
"and the import must call the service for the transition it is applying"
);
let (_tmp, store) = mapped_repo();
let _before = snapshot(&store.issues_dir);
let (_, resolution) = tix::workflow::active_resolution(&store.config);
assert!(resolution.has_completion());
}
#[test]
fn nfr002_cross_direction_an_mcp_write_is_read_correctly_by_the_cli() {
let (tmp, store) = mapped_repo();
let result = mcp(
&store.config,
&store,
"move_issue",
serde_json::json!({ "issue": "1", "column": "wip", "reason": "starting" }),
);
assert_eq!(result["isError"], false, "{}", result["content"][0]["text"]);
let listed = stdout_of(&cli(
tmp.path(),
&["list", "--all", "--columns", "num,title,status"],
));
assert!(
listed.contains("wip"),
"`tix list` must see the MCP write:\n{listed}"
);
let shown = stdout_of(&cli(tmp.path(), &["show", "1"]));
assert!(
shown.contains("- category: active (In progress)"),
"`tix show` must agree with MCP's own `category` field:\n{shown}"
);
let structured = result["structuredContent"]["category"].as_str().unwrap();
assert_eq!(structured, "active", "MCP's own answer");
}
#[test]
fn nfr002_two_boards_with_equal_mappings_report_equal_completion() {
let a = Config {
issues_dir: PathBuf::from("/a"),
..mapped_config()
};
let b = Config {
issues_dir: PathBuf::from("/b"),
..Config {
kanban: KanbanSection {
configurations: IndexMap::from([(
"default".to_string(),
BoardConfiguration {
columns: vec![
"icebox".into(),
"doing".into(),
"verifying".into(),
"released".into(),
"dropped".into(),
],
done_column: Some("released".to_string()),
category_map: IndexMap::from([
("icebox".to_string(), "backlog".to_string()),
("doing".to_string(), "active".to_string()),
("verifying".to_string(), "active".to_string()),
("released".to_string(), "completed".to_string()),
("dropped".to_string(), "canceled".to_string()),
]),
..Default::default()
},
)]),
},
..Config::default()
}
};
let ra = tix::workflow::resolution(&a, a.default_config(), "default");
let rb = tix::workflow::resolution(&b, b.default_config(), "default");
const COLUMN_PAIRS: [(&str, &str); 5] = [
("triage", "icebox"),
("wip", "doing"),
("review", "verifying"),
("shipped", "released"),
("abandoned", "dropped"),
];
for (column_a, column_b) in COLUMN_PAIRS {
let ca = ra
.resolve(column_a)
.unwrap_or_else(|| panic!("board A must configure {column_a}"));
let cb = rb
.resolve(column_b)
.unwrap_or_else(|| panic!("board B must configure {column_b}"));
assert_eq!(
ca.key(),
cb.key(),
"{column_a} and {column_b} must resolve to the same category"
);
assert_eq!(
ca.is_complete(),
cb.is_complete(),
"{column_a} and {column_b} must agree on completion"
);
assert_eq!(ca.label(), cb.label(), "and on the label they print");
}
}
#[test]
fn nfr002_the_blocked_move_guard_uses_the_services_completion_column() {
let config = split_completion_config();
assert_eq!(
tix::workflow::completion_column(config.default_config(), &config),
Some("shipped".into())
);
assert_eq!(
config.default_config().effective_done_column(),
Some("done")
);
let (tmp, store) = repo_with(&config, &["todo", "todo"]);
let (blocker, blocked) = {
let issues = store.list().unwrap();
(issues[0].clone(), issues[1].clone())
};
store
.link(
&blocker.number.to_string(),
&blocked.number.to_string(),
tix::relationships::RelationKind::Blocks,
)
.unwrap();
let out = cli(
tmp.path(),
&[
"move",
&blocked.number.to_string(),
"shipped",
"--commit",
"abc1234",
],
);
assert!(
!out.status.success(),
"a blocked move into the completion column must be refused: {}",
stdout_of(&out)
);
let err = stderr_of(&out);
assert!(
err.contains("blocked"),
"and the reason must name the blockers: {err}"
);
assert_eq!(
status_of(&store, blocked.number),
"todo",
"and nothing was written"
);
let (tmp, store) = repo_with(&config, &["todo", "todo"]);
let (blocker, other) = {
let issues = store.list().unwrap();
(issues[0].clone(), issues[1].clone())
};
store
.link(
&blocker.number.to_string(),
&other.number.to_string(),
tix::relationships::RelationKind::Blocks,
)
.unwrap();
let out = cli(tmp.path(), &["move", &other.number.to_string(), "done"]);
assert!(
out.status.success(),
"a move into a non-completion column must not demand confirmation: {}",
stderr_of(&out)
);
assert_eq!(status_of(&store, other.number), "done");
}
#[test]
fn nfr002_canceled_is_not_complete_on_any_surface() {
for config in [mapped_config(), split_completion_config()] {
let resolution = tix::workflow::resolution(&config, config.default_config(), "default");
for column in config.default_config().columns.clone() {
if let Some(category) = resolution.resolve(&column) {
assert!(
!(category.key() == "canceled" && category.is_complete()),
"{column} resolves to canceled, which must not be complete"
);
}
}
}
}
#[test]
fn c004_no_file_outside_workflow_compares_a_status_to_a_column_name() {
const PARAMETER_SITES: [(&str, &str); 1] = [(
"src/commands/workflow.rs",
"\"Completion column: {}\"",
)];
const ALWAYS_DEFECT: [&str; 5] = [
"unwrap_or(\"done\")",
"status.is(\"done\")",
".status == \"done\"",
".status != \"done\"",
"status() == \"done\"",
];
let mut findings: Vec<String> = Vec::new();
let mut parameter_sites: Vec<(String, usize)> = Vec::new();
for path in tracked_rust_sources() {
let rel = path
.strip_prefix(repo_root())
.unwrap()
.to_string_lossy()
.to_string();
if rel == "src/workflow.rs" || rel == "src/config.rs" {
continue;
}
let text = fs::read_to_string(&path).unwrap();
for (n, trimmed) in production_lines(&text) {
for pattern in ALWAYS_DEFECT {
if trimmed.contains(pattern) {
findings.push(format!("{rel}:{n}: {pattern} | {trimmed}"));
}
}
if trimmed.contains("effective_done_column()") {
parameter_sites.push((rel.clone(), n));
}
}
}
for (rel, n) in ¶meter_sites {
let text = fs::read_to_string(repo_root().join(rel)).unwrap();
let matched = PARAMETER_SITES
.iter()
.any(|(file, snippet)| file == rel && text.contains(snippet));
if !matched {
findings.push(format!(
"{rel}:{n}: effective_done_column() outside an enumerated \
parameter site — either route it through \
`workflow::completion_column`, or add it to PARAMETER_SITES with the \
service that receives the column | {}",
text.lines().nth(n - 1).unwrap_or("").trim()
));
}
}
assert!(
findings.is_empty(),
"a status is being decided from a column name outside the service (C-004). \
Each must ask `workflow::is_completed` / `workflow::completion_column`, or \
take the column as a parameter of a service that decides:\n{}",
findings.join("\n")
);
}
fn production_lines(text: &str) -> Vec<(usize, &str)> {
let mut out = Vec::new();
let mut depth = 0i32;
let mut in_test: Option<i32> = None;
for (i, line) in text.lines().enumerate() {
let n = i + 1;
if let Some(base) = in_test {
depth += brace_delta(line);
if depth <= base {
in_test = None;
}
continue;
}
if line.contains("#[cfg(test)]") {
in_test = Some(depth);
depth += brace_delta(line);
continue;
}
let trimmed = line.trim();
if !trimmed.starts_with("//") && !trimmed.starts_with("///") {
out.push((n, trimmed));
}
depth += brace_delta(line);
}
out
}
fn brace_delta(line: &str) -> i32 {
let opens = line.matches('{').count() as i32;
let closes = line.matches('}').count() as i32;
opens - closes
}
fn tracked_rust_sources() -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = fs::read_dir(repo_root().join("src"))
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "rs"))
.collect();
for dir in ["commands", "ui", "mcp", "sync"] {
let path = repo_root().join("src").join(dir);
if !path.is_dir() {
continue;
}
let mut stack = vec![path];
while let Some(cur) = stack.pop() {
for entry in fs::read_dir(&cur).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|x| x == "rs") {
out.push(path);
}
}
}
}
out.sort();
out
}
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
#[test]
fn nfr001_resolving_ten_thousand_issues_is_one_map_and_n_lookups() {
const ISSUES: usize = 10_000;
let config = mapped_config();
let board = config.default_config();
const DISTRIBUTION: [(&str, usize); 6] = [
("triage", 1_600), ("wip", 1_600), ("review", 1_600), ("shipped", 1_600), ("abandoned", 1_600), ("not-a-column", 2_000), ];
assert_eq!(
DISTRIBUTION.iter().map(|(_, n)| n).sum::<usize>(),
ISSUES,
"the fixture must be exactly {ISSUES} issues"
);
let mut statuses: Vec<String> = Vec::with_capacity(ISSUES);
for (column, count) in DISTRIBUTION {
for _ in 0..count {
statuses.push(column.to_string());
}
}
let started = std::time::Instant::now();
let resolution = tix::workflow::resolution(&config, board, "default");
let built = started.elapsed();
let started = std::time::Instant::now();
let mut counts: std::collections::BTreeMap<&'static str, usize> =
std::collections::BTreeMap::new();
for column in &statuses {
if let Some(category) = resolution.resolve(column) {
*counts.entry(category.key()).or_default() += 1;
}
}
let looked_up = started.elapsed();
let configured: usize = DISTRIBUTION
.iter()
.filter(|(c, _)| *c != "not-a-column")
.map(|(_, n)| n)
.sum();
let total: usize = counts.values().sum();
assert_eq!(total, configured, "every configured issue must resolve");
assert_eq!(
counts.get("active"),
Some(&3_200),
"`wip` and `review` share `active`"
);
assert_eq!(counts.get("backlog"), Some(&1_600));
assert_eq!(counts.get("completed"), Some(&1_600));
assert_eq!(counts.get("canceled"), Some(&1_600));
assert_eq!(
resolution.resolve("not-a-column"),
None,
"a column the board does not configure has no category"
);
let mut board = mapped_board();
board.columns.push("parking".to_string());
let with_default = config_with(board);
let r2 = tix::workflow::resolution(&with_default, with_default.default_config(), "default");
assert_eq!(
r2.resolve("parking")
.map(tix::workflow::WorkflowCategory::key),
Some("unstarted"),
"a configured but unmapped column resolves to the safe default"
);
let started = std::time::Instant::now();
for column in &statuses {
let _ = resolution.resolve(column);
}
let again = started.elapsed();
assert!(
again.as_nanos() < looked_up.as_nanos() * 4,
"a second pass over the built map should be the same order as the first \
({:?} vs {:?}); a much larger gap means the map is being rebuilt per issue",
again,
looked_up
);
println!(
"NFR-001: {ISSUES} issues — resolution built in {built:?}, \
{ISSUES} lookups in {looked_up:?}, repeat pass {again:?} \
(budget 100 ms; safety factor ~{:.0}x)",
100_000.0 / looked_up.as_micros().max(1) as f64
);
}
#[test]
fn nfr001_worst_shape_all_issues_in_one_category_plus_an_unmapped_column() {
let mut board = mapped_board();
board.columns.push("parking".to_string());
let config = config_with(board);
let resolution = tix::workflow::resolution(&config, config.default_config(), "default");
let column = "wip";
let category = resolution.resolve(column).unwrap();
assert_eq!(category.key(), "active");
let started = std::time::Instant::now();
for _ in 0..10_000 {
assert_eq!(resolution.resolve(column).unwrap().key(), "active");
}
let elapsed = started.elapsed();
assert_eq!(
resolution
.resolve("parking")
.map(tix::workflow::WorkflowCategory::key),
Some("unstarted"),
"a configured but unmapped column resolves to the safe default, not to nothing"
);
assert!(
elapsed.as_millis() < 100,
"10,000 lookups took {elapsed:?}; the NFR budget is 100 ms, so this is no \
longer a constant-time lookup"
);
println!("NFR-001 worst shape: 10,000 identical lookups in {elapsed:?}");
}
const LEGACY_TOML: &str = r#"issues_dir = "docs/issues"
[kanban.legacy]
columns = ["backlog", "doing", "review", "shipped"]
done_column = "shipped"
[kanban.legacy_epic]
columns = ["icebox", "doing", "released", "dropped"]
done_column = "released"
[kanban.minimal]
columns = ["todo", "in progress", "done"]
"#;
#[test]
fn nfr003_a_legacy_config_loads_resolves_and_still_completes() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
fs::write(&path, LEGACY_TOML).unwrap();
let mut warnings: Vec<String> = Vec::new();
let config = Config::load_with_warning(&path, |w| warnings.push(w.to_string())).unwrap();
assert!(
warnings.is_empty(),
"a legacy config must load silently; got: {warnings:?}"
);
for name in ["legacy", "legacy_epic", "minimal"] {
let board = config
.kanban
.configurations
.get(name)
.unwrap_or_else(|| panic!("board {name} must survive the round trip"));
assert!(
board.category_map.is_empty(),
"{name} declared no category_map and must not acquire one"
);
let resolution = tix::workflow::resolution(&config, board, name);
for column in &board.columns {
assert!(
resolution.resolve(column).is_some(),
"{name}: column {column} must resolve"
);
}
assert!(
resolution.has_completion(),
"{name}: completion must still be detectable"
);
let done = board.effective_done_column().unwrap();
assert_eq!(
resolution.resolve(done).map(|c| c.key()),
Some("completed"),
"{name}: done_column {done} reconciles to completed"
);
}
let board = config.kanban.configurations.get("legacy").unwrap();
let resolution = tix::workflow::resolution(&config, board, "legacy");
let defaulted: Vec<&String> = board
.columns
.iter()
.filter(|c| {
resolution
.resolve(c)
.is_some_and(|cat| cat.key() == tix::workflow::SAFE_DEFAULT)
})
.collect();
assert_eq!(
defaulted.len(),
3,
"backlog, doing and review resolve through the safe default"
);
let board = config.kanban.configurations.get("legacy").unwrap();
let resolution = tix::workflow::resolution(&config, board, "legacy");
assert!(
tix::workflow::lint_direct_edits(&[], &resolution, &config).is_empty(),
"no issue files, so no direct-edit findings"
);
let config_findings = tix::workflow::validate_config(&config);
let reported: Vec<&str> = config_findings.iter().map(|d| d.kind.as_str()).collect();
assert!(
reported.contains(&"unmapped_column_default"),
"the safe default must be **reported**, not silently absorbed: {config_findings:?}"
);
}
#[test]
fn nfr003_load_save_load_introduces_no_new_key() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
fs::write(&path, LEGACY_TOML).unwrap();
let first = Config::load_with_warning(&path, |_| {}).unwrap();
first.save(&path).unwrap();
let saved = fs::read_to_string(&path).unwrap();
let second = Config::load_with_warning(&path, |_| {}).unwrap();
second.save(&path).unwrap();
let saved_twice = fs::read_to_string(&path).unwrap();
assert_eq!(
saved, saved_twice,
"load → save → load → save must be a fixed point"
);
let original: std::collections::BTreeSet<String> = toml_key_set(LEGACY_TOML);
let now: std::collections::BTreeSet<String> = toml_key_set(&saved);
let lost: Vec<&String> = original.iter().filter(|k| !now.contains(*k)).collect();
assert!(lost.is_empty(), "saving **lost** key(s): {lost:?}\n{saved}");
const DEFAULTABLE_SUFFIXES: [&str; 6] = [
".show_mismatch",
".title_lines",
".order_by",
".wrap_titles",
".hidden_columns",
".done_column",
];
let added: Vec<&String> = now.iter().filter(|k| !original.contains(*k)).collect();
let unexpected: Vec<&&String> = added
.iter()
.filter(|k| k.as_str() != "export" && !DEFAULTABLE_SUFFIXES.iter().any(|s| k.ends_with(s)))
.collect();
assert!(
unexpected.is_empty(),
"saving introduced key(s) that are not known default-able fields, which \
would be data appearing from nowhere: {unexpected:?}\n{saved}"
);
assert!(!saved.contains("[categories]"), "{saved}");
assert!(!saved.contains("[transitions]"), "{saved}");
assert!(!saved.contains("category_map"), "{saved}");
}
#[test]
fn nfr003_a_partial_category_map_loads_and_resolves() {
let config = Config {
kanban: KanbanSection {
configurations: IndexMap::from([(
"default".to_string(),
BoardConfiguration {
columns: vec!["triage".into(), "wip".into(), "shipped".into()],
done_column: Some("shipped".to_string()),
category_map: IndexMap::from([
("triage".to_string(), "backlog".to_string()),
("shipped".to_string(), "completed".to_string()),
]),
..Default::default()
},
)]),
},
..Config::default()
};
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
config.save(&path).unwrap();
let loaded = Config::load_with_warning(&path, |_| {}).unwrap();
let board = loaded.default_config();
assert_eq!(board.category_map.len(), 2, "both entries survive");
assert!(
!board.category_map.contains_key("wip"),
"and no entry is invented"
);
let resolution = tix::workflow::resolution(&loaded, board, "default");
assert_eq!(
resolution.resolve("wip").map(|c| c.key()),
Some("unstarted"),
"the unmapped column resolves through the documented default"
);
assert_eq!(
resolution.resolve("triage").map(|c| c.key()),
Some("backlog")
);
assert_eq!(
resolution.resolve("shipped").map(|c| c.key()),
Some("completed")
);
assert!(
resolution.explicit_policy,
"a declared category_map is an intent signal, whatever it leaves unmapped"
);
}
#[test]
fn c007_an_explicit_map_without_completed_is_refused_but_no_map_is_not() {
let bad = Config {
kanban: KanbanSection {
configurations: IndexMap::from([(
"default".to_string(),
BoardConfiguration {
columns: vec!["triage".into(), "wip".into()],
done_column: Some("triage".to_string()),
category_map: IndexMap::from([
("triage".to_string(), "backlog".to_string()),
("wip".to_string(), "active".to_string()),
]),
..Default::default()
},
)]),
},
..Config::default()
};
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
bad.save(&path).unwrap();
let err = Config::load_with_warning(&path, |_| {})
.expect_err("an explicit map with no completion category must be refused")
.to_string();
assert!(
err.contains("missing_completion_category"),
"the named diagnostic, not a generic parse error: {err}"
);
let ok = Config {
kanban: KanbanSection {
configurations: IndexMap::from([(
"default".to_string(),
BoardConfiguration {
columns: vec!["triage".into(), "wip".into()],
done_column: Some("triage".to_string()),
..Default::default()
},
)]),
},
..Config::default()
};
ok.save(&path).unwrap();
let loaded =
Config::load_with_warning(&path, |_| {}).expect("a board with no category_map must load");
let board = loaded.default_config();
let resolution = tix::workflow::resolution(&loaded, board, "default");
assert_eq!(
resolution.resolve("triage").map(|c| c.key()),
Some("completed"),
"done_column reconciles to completed on a legacy board"
);
assert_eq!(
resolution.resolve("wip").map(|c| c.key()),
Some("unstarted")
);
assert!(
!resolution.explicit_policy,
"and no transition policy is imposed on a board that never declared one"
);
}
#[test]
fn c007_the_category_section_is_validated() {
let bad = Config {
categories: CategoryConfig {
order: vec!["unstarted".into(), "completed".into()],
default: "completed".into(),
},
..Config::default()
};
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
bad.save(&path).unwrap();
let err = Config::load_with_warning(&path, |_| {})
.expect_err("a safe default of `completed` must be refused")
.to_string();
assert!(err.contains("categories"), "{err}");
let invented = Config {
categories: CategoryConfig {
order: vec!["unstarted".into(), "invented".into()],
default: "unstarted".into(),
},
..Config::default()
};
invented.save(&path).unwrap();
let err = Config::load_with_warning(&path, |_| {})
.expect_err("a category outside the global vocabulary must be refused")
.to_string();
assert!(err.contains("unknown_category"), "{err}");
}
fn toml_key_set(text: &str) -> std::collections::BTreeSet<String> {
let value: toml::Value = toml::from_str(text).expect("the fixture parses");
let mut out = std::collections::BTreeSet::new();
collect_keys(&value, "", &mut out);
out
}
fn collect_keys(value: &toml::Value, prefix: &str, out: &mut std::collections::BTreeSet<String>) {
let table = match value.as_table() {
Some(t) => t,
None => return,
};
for (key, child) in table {
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
out.insert(path.clone());
collect_keys(child, &path, out);
}
}
#[test]
fn sc005_a_rejected_transition_is_inert_on_every_surface() {
let (tmp, _store) = mapped_repo();
let before = snapshot(tmp.path());
let out = cli(tmp.path(), &["move", "4", "triage"]);
assert!(!out.status.success());
assert_eq!(
diagnostic_of(&stderr_of(&out)),
"invalid_transition",
"and it is a policy refusal, not a typo"
);
assert_eq!(
snapshot(tmp.path()),
before,
"C-005: the CLI's refusal wrote nothing"
);
let (tmp, _store) = mapped_repo();
let before = snapshot(tmp.path());
let out = cli(tmp.path(), &["edit", "4", "--status", "triage"]);
assert!(!out.status.success());
assert_eq!(snapshot(tmp.path()), before, "and neither did `tix edit`");
let (tmp, store) = mapped_repo();
let before = snapshot(tmp.path());
let result = mcp(
&store.config,
&store,
"move_issue",
serde_json::json!({ "issue": "4", "column": "triage" }),
);
assert_eq!(result["isError"], true);
assert_eq!(
diagnostic_of(result["content"][0]["text"].as_str().unwrap()),
"invalid_transition"
);
assert_eq!(snapshot(tmp.path()), before, "and neither did MCP");
}
#[test]
fn sc005_a_valid_transition_changes_exactly_one_file_and_appends_one_decision() {
let (tmp, store) = mapped_repo();
let before = snapshot(tmp.path());
let out = cli(tmp.path(), &["move", "1", "wip"]).assert_ok();
let after = snapshot(tmp.path());
let changed: Vec<&String> = after
.iter()
.filter(|(path, bytes)| {
before
.iter()
.find(|(p, _)| p == path)
.is_none_or(|(_, b)| b != bytes)
})
.map(|(p, _)| p)
.collect();
assert_eq!(
changed.len(),
1,
"exactly one file may change; changed: {changed:?}"
);
assert!(
changed[0].contains("issue1"),
"and it must be the moved issue: {changed:?}"
);
let _ = &out;
let history = history_of(&store, 1);
let decisions = history
.lines()
.filter(|l| l.contains("transition_accepted"))
.count();
assert_eq!(decisions, 1, "exactly one decision:\n{history}");
assert!(
history.contains("triage -> wip"),
"and it names the move:\n{history}"
);
}
#[test]
fn sc005_lint_reports_a_direct_edit_without_rewriting_the_file_or_its_history() {
let (tmp, store) = mapped_repo();
cli(tmp.path(), &["move", "1", "wip"]).assert_ok();
let path = issue_path(&store, 1);
let original = fs::read_to_string(&path).unwrap();
let edited = original.replace("status: wip", "status: shipped");
assert_ne!(edited, original, "the fixture must actually be edited");
fs::write(&path, &edited).unwrap();
let before = snapshot(tmp.path());
let out = cli(tmp.path(), &["lint"]);
let stderr = stderr_of(&out);
assert!(
stderr.contains("direct_edit_policy_violation"),
"the violation must be reported:\n{stderr}"
);
assert_eq!(
snapshot(tmp.path()),
before,
"C-005: lint is read-only — the hand-edited file, its History and tix.toml \
are all byte-identical afterwards"
);
assert_eq!(
fs::read_to_string(&path).unwrap(),
edited,
"including the line the user changed"
);
}
#[test]
fn sc005_a_direct_edit_is_still_readable_and_usable() {
let (tmp, store) = mapped_repo();
cli(tmp.path(), &["move", "1", "wip"]).assert_ok();
let path = issue_path(&store, 1);
let original = fs::read_to_string(&path).unwrap();
fs::write(&path, original.replace("status: wip", "status: shipped")).unwrap();
for args in [vec!["list"], vec!["show", "1"], vec!["status"]] {
let out = cli(tmp.path(), &args);
assert!(
out.status.success(),
"{args:?} must still work on a hand-edited file: {}",
stderr_of(&out)
);
}
let linted = cli(tmp.path(), &["lint"]);
let stderr = stderr_of(&linted);
assert!(
stderr.contains("direct_edit_policy_violation"),
"`tix lint` must still report the violation: {stderr}"
);
assert!(
!stderr.contains("panic") && !stderr.contains("could not be parsed"),
"and must not have failed to read the file: {stderr}"
);
let shown = stdout_of(&cli(
tmp.path(),
&["list", "--all", "--columns", "num,title,status"],
));
assert!(
shown.contains("shipped"),
"`tix list` reports the file, not the history: {shown}"
);
}
#[test]
fn sc005_reapplying_a_transition_is_inert_on_cli_and_mcp() {
let (tmp, store) = mapped_repo();
cli(tmp.path(), &["move", "1", "wip"]).assert_ok();
let after_first = snapshot(tmp.path());
let out = cli(tmp.path(), &["move", "1", "wip"]).assert_ok();
assert_eq!(
snapshot(tmp.path()),
after_first,
"a same-column re-apply writes nothing, bumps no date and appends no decision"
);
let _ = &out;
let result = mcp(
&store.config,
&store,
"move_issue",
serde_json::json!({ "issue": "1", "column": "wip" }),
);
assert_eq!(result["isError"], false, "{}", result["content"][0]["text"]);
assert_eq!(snapshot(tmp.path()), after_first, "and neither does MCP");
}
fn issue_path(store: &tix::storage::Store, number: u32) -> PathBuf {
let issue = store
.get(tix::storage::IssueSelector::Number(number))
.unwrap();
let want = Path::new(&issue.filename)
.file_name()
.expect("a filename has a last component");
let mut stack = vec![store.issues_dir.clone()];
while let Some(cur) = stack.pop() {
for entry in fs::read_dir(&cur).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
} else if path.file_name() == Some(want) {
return path;
}
}
}
panic!(
"no file named {want:?} under {}",
store.issues_dir.display()
);
}
fn history_of(store: &tix::storage::Store, number: u32) -> String {
store
.get(tix::storage::IssueSelector::Number(number))
.unwrap()
.to_file_contents()
}
trait AssertOk {
fn assert_ok(self) -> std::process::Output;
}
impl AssertOk for std::process::Output {
fn assert_ok(self) -> std::process::Output {
assert!(
self.status.success(),
"expected success, got {}: {}",
self.status,
stderr_of(&self)
);
self
}
}