#[allow(clippy::wildcard_imports)]
use super::*;
use crate::config::Config;
use std::fs;
use tempfile::TempDir;
fn test_config() -> Config {
let mut config = Config::default();
config.layout.move_on_change = false;
config
}
fn mapped_repo() -> (TempDir, Config, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config = Config {
issues_dir: dir.clone(),
kanban: crate::config::KanbanSection {
configurations: indexmap::IndexMap::from([(
"default".to_string(),
crate::config::BoardConfiguration {
columns: vec![
"triage".into(),
"wip".into(),
"review".into(),
"shipped".into(),
"abandoned".into(),
],
done_column: Some("shipped".to_string()),
category_map: indexmap::IndexMap::from([
("triage".to_string(), "backlog".to_string()),
("wip".to_string(), "active".to_string()),
("review".to_string(), "active".to_string()),
("shipped".to_string(), "completed".to_string()),
("abandoned".to_string(), "canceled".to_string()),
]),
..Default::default()
},
)]),
},
..test_config()
};
let config_path = tmp.path().join("tix.toml");
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let store = Store {
issues_dir: dir,
config: config.clone(),
};
(tmp, config, store)
}
fn make_repo() -> (TempDir, Config, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = Config {
issues_dir: dir.clone(),
..test_config()
};
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let store = Store {
issues_dir: dir,
config: config.clone(),
};
(tmp, config, store)
}
fn write_issue(store: &Store, number: u32, title: &str, status: &str) {
let contents = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nbody"
);
let slug = crate::slug::slugify(title);
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::write(
store.issues_dir.join(format!("alice/2026-01-01-{slug}.md")),
contents,
)
.unwrap();
}
fn invoke(config: &Config, store: &Store, name: &str, args: Value) -> Value {
call(config, store, protocol::Id::Number(1), name, &args)
.expect("protocol-level calls in unit tests must not fail")
}
#[test]
fn every_declared_tool_name_dispatches() {
let (_tmp, config, store) = make_repo();
for tool in list() {
let name = tool["name"].as_str().expect("a tool name");
let outcome = dispatch(&config, &store, name, &json!({}));
assert!(
outcome.is_some(),
"`{name}` is declared in the registry but `dispatch` returns None for \
it, so every call would answer \"unknown tool\". Either add the arm \
or drop the declaration."
);
}
}
#[test]
fn dispatch_rejects_a_name_nobody_declared() {
let (_tmp, config, store) = make_repo();
assert!(
dispatch(&config, &store, "definitely_not_a_tool", &json!({})).is_none(),
"an undeclared name must not dispatch"
);
}
#[test]
fn every_declared_tool_has_a_valid_schema() {
for tool in list() {
let schema = &tool["inputSchema"];
assert_eq!(schema["type"], "object", "{}", tool["name"]);
assert_eq!(schema["additionalProperties"], false, "{}", tool["name"]);
assert!(
tool["description"].as_str().is_some_and(|d| !d.is_empty()),
"{}",
tool["name"]
);
}
}
#[test]
fn the_documented_tool_count_matches_the_registry() {
let registered = registry().len();
let doc = include_str!("../../../../docs/mcp.md");
let mut stated: Vec<(usize, &str)> = Vec::new();
for line in doc.lines() {
let after = |marker: &str| -> Option<usize> {
line.split(marker)
.nth(1)?
.split_whitespace()
.next()?
.parse::<usize>()
.ok()
};
for marker in ["gains ", "## The ", "call the "] {
if let Some(n) = after(marker) {
stated.push((n, line.trim()));
}
}
}
assert_eq!(
stated.len(),
3,
"the doc states the count in three places and all three must be found; \
found {stated:?}"
);
for (n, line) in stated {
assert_eq!(
n, registered,
"docs/mcp.md says {n} tools (`{line}`); the registry has {registered}"
);
}
let listed: Vec<&str> = doc
.lines()
.filter_map(|l| l.strip_prefix("| `"))
.filter_map(|l| l.split('`').next())
.filter(|name| registry().iter().any(|d| d.name == *name))
.collect();
let mut expected: Vec<&str> = registry().iter().map(|d| d.name).collect();
expected.sort_unstable();
let mut got = listed.clone();
got.sort_unstable();
got.dedup();
assert_eq!(
got, expected,
"the tool table must list every registered tool exactly once; \
missing or extra entries show here"
);
assert_eq!(
listed.len(),
expected.len(),
"one row per registered tool, with no duplicates"
);
}
#[test]
fn every_declared_tool_dispatches() {
let (_tmp, config, store) = make_repo();
let mut undispatched = Vec::new();
for tool in registry() {
if dispatch(&config, &store, tool.name, &json!({})).is_none() {
undispatched.push(tool.name);
}
}
assert!(
undispatched.is_empty(),
"declared but not dispatched: {undispatched:?}"
);
}
#[test]
fn an_undeclared_tool_name_is_a_clean_error_not_a_panic() {
let (_tmp, config, store) = make_repo();
assert!(
dispatch(&config, &store, "no_such_tool", &json!({})).is_none(),
"an undeclared name must not dispatch"
);
let result = call(
&config,
&store,
protocol::Id::Number(1),
"no_such_tool",
&json!({}),
);
assert!(result.is_err(), "an undeclared name is a protocol error");
}
#[test]
fn arg_helpers_reject_wrong_types() {
let args = json!({ "title": 42, "dry_run": "yes", "issue": true });
assert!(req_string(&args, "title").is_err());
assert!(opt_string(&args, "title").is_err());
assert!(opt_bool(&args, "dry_run").is_err());
assert!(selector(&args, "issue").is_err());
assert_eq!(selector(&json!({ "issue": 123 }), "issue").unwrap(), "123");
assert_eq!(
selector(&json!({ "issue": "n 1" }), "issue").unwrap(),
"n 1"
);
}
#[test]
fn null_optionals_are_treated_as_absent() {
assert_eq!(opt_string(&json!({ "x": null }), "x").unwrap(), None);
assert_eq!(opt_bool(&json!({ "x": null }), "x").unwrap(), None);
assert!(req_string(&json!({ "x": null }), "x").is_err());
}
#[test]
fn call_type_mismatch_is_invalid_params() {
let (_tmp, config, store) = make_repo();
let err = call(
&config,
&store,
protocol::Id::Number(1),
"create_issue",
&json!({ "title": 5 }),
)
.unwrap_err();
assert_eq!(err.error.code, protocol::INVALID_PARAMS);
assert_eq!(err.id, protocol::Id::Number(1));
}
#[test]
fn call_unknown_tool_is_method_not_found() {
let (_tmp, config, store) = make_repo();
let err = call(
&config,
&store,
protocol::Id::Number(2),
"bogus",
&json!({}),
)
.unwrap_err();
assert_eq!(err.error.code, protocol::METHOD_NOT_FOUND);
}
#[test]
fn create_issue_writes_file_and_returns_structured_result() {
let (_tmp, config, store) = make_repo();
let result = invoke(
&config,
&store,
"create_issue",
json!({ "title": "Build the tool registry", "labels": "mcp, core", "priority": "high" }),
);
assert_eq!(result["isError"], false);
let structured = &result["structuredContent"];
assert_eq!(structured["title"], "Build the tool registry");
assert_eq!(structured["labels"], json!(["mcp", "core"]));
assert_eq!(structured["priority"], "high");
let filename = structured["filename"].as_str().unwrap();
assert!(store.issues_dir.join(filename).exists());
assert!(
result["content"][0]["text"]
.as_str()
.unwrap()
.starts_with("Created ")
);
}
#[test]
fn delete_issue_requires_confirm_and_files_survive_refusal() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Alpha", "todo");
let refused = invoke(&config, &store, "delete_issue", json!({ "issue": "1" }));
assert_eq!(refused["isError"], true);
assert!(store.issues_dir.join("alice/2026-01-01-alpha.md").exists());
let done = invoke(
&config,
&store,
"delete_issue",
json!({ "issue": 1, "confirm": true }),
);
assert_eq!(done["isError"], false);
assert_eq!(
done["structuredContent"]["deleted"],
"alice/2026-01-01-alpha.md"
);
assert!(!store.issues_dir.join("alice/2026-01-01-alpha.md").exists());
}
#[test]
fn update_issue_with_no_fields_is_a_tool_error() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Alpha", "todo");
let result = invoke(&config, &store, "update_issue", json!({ "issue": "1" }));
assert_eq!(result["isError"], true);
assert!(
result["content"][0]["text"]
.as_str()
.unwrap()
.contains("no fields to update")
);
}
#[test]
fn create_issue_accepts_cycles_milestones_reviewer_and_dates() {
let (_tmp, config, store) = make_repo();
let mut config = config;
config
.cycles
.insert("Cycle 1".into(), crate::config::PlanningCycle::default());
let milestone_dir =
crate::planning::Planner::from_config(config.clone(), std::path::PathBuf::new())
.milestones_dir();
std::fs::create_dir_all(&milestone_dir).unwrap();
std::fs::write(
milestone_dir.join("2026-09-21-mcp-create-1.md"),
"---\nname: v1.3\n---\n",
)
.unwrap();
let result = invoke(
&config,
&store,
"create_issue",
json!({
"title": "Planned",
"assignee": "",
"cycles": "Cycle 1",
"milestones": "v1.3",
"reviewer": "Alice",
"start_date": "2026-09-07",
"due_date": "2026-09-18"
}),
);
assert_eq!(result["isError"], false);
let structured = &result["structuredContent"];
assert_eq!(structured["cycles"], json!(["Cycle 1"]));
assert_eq!(structured["milestones"], json!(["v1.3"]));
assert_eq!(structured["reviewer"], "Alice");
assert_eq!(structured["start_date"], "2026-09-07");
assert_eq!(structured["due_date"], "2026-09-18");
}
#[test]
fn update_issue_sets_cycles_milestones_reviewer_and_dates() {
let (_tmp, config, store) = make_repo();
let mut config = config;
config
.cycles
.insert("Cycle 1".into(), crate::config::PlanningCycle::default());
let milestone_dir =
crate::planning::Planner::from_config(config.clone(), std::path::PathBuf::new())
.milestones_dir();
std::fs::create_dir_all(&milestone_dir).unwrap();
std::fs::write(
milestone_dir.join("2026-09-21-mcp-update-1.md"),
"---\nname: v1.2\n---\n",
)
.unwrap();
let created = invoke(
&config,
&store,
"create_issue",
json!({ "title": "Alpha", "assignee": "" }),
);
let num = created["structuredContent"]["number"].as_u64().unwrap();
let result = invoke(
&config,
&store,
"update_issue",
json!({
"issue": num,
"cycles": "Cycle 1",
"milestones": "v1.2",
"reviewer": "Alice",
"start_date": "2026-09-07",
"due_date": "2026-09-18"
}),
);
assert_eq!(result["isError"], false);
let structured = &result["structuredContent"];
assert_eq!(structured["cycles"], json!(["Cycle 1"]));
assert_eq!(structured["milestones"], json!(["v1.2"]));
assert_eq!(structured["reviewer"], "Alice");
assert_eq!(structured["start_date"], "2026-09-07");
assert_eq!(structured["due_date"], "2026-09-18");
}
#[test]
fn create_issue_rejects_unknown_planning_refs_with_cli_text() {
let (_tmp, config, store) = make_repo();
let result = invoke(
&config,
&store,
"create_issue",
json!({ "title": "Broken", "cycles": "Nope", "milestones": "Missing" }),
);
assert_eq!(result["isError"], true);
let text = result["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("invalid input: unknown cycle `Nope` — valid cycles: none configured"),
"got: {text}"
);
}
#[test]
fn move_issue_unknown_column_reports_the_shared_named_diagnostic() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Alpha", "todo");
let result = invoke(
&config,
&store,
"move_issue",
json!({ "issue": "1", "column": "nope" }),
);
assert_eq!(result["isError"], true);
let text = result["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("(unknown_column)"),
"the shared diagnostic name must reach the client: {text}"
);
assert!(
text.contains("valid columns: todo, in progress, in review, done"),
"the valid-column list is what the user needs in order to recover: {text}"
);
}
#[test]
fn lint_clean_store_reports_ok() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Alpha", "todo");
let result = invoke(&config, &store, "lint_issues", json!({}));
assert_eq!(result["isError"], false);
let text = result["content"][0]["text"].as_str().unwrap();
assert!(
text.starts_with("Lint OK with 1 warning: 1 issues checked."),
"the CLI's own wording, so the two surfaces agree: {text}"
);
}
#[test]
fn export_without_gitlab_config_is_a_structured_error() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Alpha", "todo");
let result = invoke(
&config,
&store,
"export_issues",
json!({ "target": "gitlab", "dry_run": true }),
);
assert_eq!(result["isError"], true);
let text = result["content"][0]["text"].as_str().unwrap();
assert!(text.contains("no `[export.gitlab]` section"), "got: {text}");
}
fn err(r: &Value) -> bool {
r["isError"].as_bool().unwrap_or(false)
}
fn text(r: &Value) -> String {
r["content"][0]["text"].as_str().unwrap().to_string()
}
fn sc(r: &Value) -> &Value {
&r["structuredContent"]
}
fn snap(store: &Store) -> std::collections::BTreeMap<String, String> {
store
.list()
.unwrap()
.into_iter()
.map(|i| {
(
i.filename.clone(),
fs::read_to_string(store.issues_dir.join(&i.filename)).unwrap(),
)
})
.collect()
}
fn three_issues() -> (TempDir, Config, Store) {
let (tmp, config, store) = make_repo();
write_issue(&store, 1, "A", "todo");
write_issue(&store, 2, "B", "todo");
write_issue(&store, 3, "C", "todo");
(tmp, config, store)
}
#[test]
fn t021_a_missing_kind_is_rejected_and_names_the_argument() {
let (_tmp, config, store) = three_issues();
let before = snap(&store);
let r = invoke(&config, &store, "link_issue", json!({ "a": "1", "b": "2" }));
assert!(err(&r), "a missing kind must be rejected: {}", text(&r));
let t = text(&r);
assert!(t.contains("unknown_kind"), "{t}");
assert!(t.contains("`kind`"), "names the argument: {t}");
for spelling in ["blocks", "blocked_by", "duplicates", "related"] {
assert!(t.contains(spelling), "lists `{spelling}`: {t}");
}
assert_eq!(snap(&store), before, "a rejected create writes nothing");
}
#[test]
fn t021_an_unknown_kind_rejects_with_the_clis_exact_text() {
let (_tmp, config, store) = three_issues();
let before = snap(&store);
let r = invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "depends-on" }),
);
assert!(err(&r));
let expected =
crate::error::render_error(&crate::commands::link::parse_kind("depends-on").unwrap_err());
assert_eq!(
text(&r),
expected,
"MCP and the CLI must say the same thing"
);
assert_eq!(snap(&store), before);
}
#[test]
fn t021_a_create_returns_id_kind_target_and_display() {
let (_tmp, config, store) = three_issues();
let r = invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "blocks" }),
);
assert!(!err(&r), "{}", text(&r));
let rel = &sc(&r)["relationship"];
assert!(
rel["id"].as_str().is_some_and(|s| s.starts_with("rel_")),
"{rel}"
);
assert_eq!(rel["kind"], "blocks");
assert_eq!(rel["from"], 1);
assert_eq!(rel["to"], 2);
assert_eq!(rel["display"], "blocks #2 B");
assert_eq!(sc(&r)["changed"], true);
}
#[test]
fn t021_a_self_link_is_rejected_with_the_clis_diagnostic() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "A", "todo");
let before = snap(&store);
let r = invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "1", "kind": "blocks" }),
);
assert!(err(&r));
assert!(text(&r).contains("self_link"), "{}", text(&r));
assert_eq!(snap(&store), before);
}
#[test]
fn t021_a_missing_target_is_rejected_and_nothing_is_written() {
let (_tmp, config, store) = three_issues();
let before = snap(&store);
let r = invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "999", "kind": "blocks" }),
);
assert!(err(&r), "{}", text(&r));
assert_eq!(snap(&store), before);
}
#[test]
fn t021_edit_preserves_the_id_and_remove_is_idempotent() {
let (_tmp, config, store) = three_issues();
let created = invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "blocks" }),
);
let id = sc(&created)["relationship"]["id"]
.as_str()
.unwrap()
.to_string();
let edited = invoke(
&config,
&store,
"edit_relationship",
json!({ "id": id, "to": "3" }),
);
assert!(!err(&edited), "{}", text(&edited));
assert_eq!(sc(&edited)["relationship"]["id"], id.as_str());
assert_eq!(sc(&edited)["relationship"]["to"], 3);
let removed = invoke(&config, &store, "remove_relationship", json!({ "id": id }));
assert!(!err(&removed), "{}", text(&removed));
assert_eq!(sc(&removed)["changed"], true);
let after_first = snap(&store);
let again = invoke(&config, &store, "remove_relationship", json!({ "id": id }));
assert!(!err(&again), "an absent id is a no-op, not a failure");
assert_eq!(sc(&again)["changed"], false);
assert_eq!(snap(&store), after_first, "no second write");
}
#[test]
fn t021_an_edit_with_nothing_to_change_is_refused() {
let (_tmp, config, store) = three_issues();
let created = invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "blocks" }),
);
let id = sc(&created)["relationship"]["id"]
.as_str()
.unwrap()
.to_string();
let before = snap(&store);
let r = invoke(&config, &store, "edit_relationship", json!({ "id": id }));
assert!(err(&r), "the identity edit must be refused: {}", text(&r));
assert!(text(&r).contains("nothing to change"), "{}", text(&r));
assert_eq!(snap(&store), before);
}
#[test]
fn t021_list_is_ordered_by_kind_then_target_and_filters_exactly() {
let (_tmp, config, store) = make_repo();
for (n, t) in [(1, "A"), (2, "B"), (3, "C"), (4, "D")] {
write_issue(&store, n, t, "todo");
}
for (target, kind) in [("2", "blocks"), ("3", "duplicates"), ("4", "related")] {
invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": target, "kind": kind }),
);
}
let all = invoke(
&config,
&store,
"list_relationships",
json!({ "issue": "1" }),
);
let kinds: Vec<&str> = sc(&all)["relationships"]
.as_array()
.unwrap()
.iter()
.map(|r| r["kind"].as_str().unwrap())
.collect();
assert_eq!(
kinds,
vec!["blocks", "duplicates", "related"],
"{}",
text(&all)
);
let by_input = invoke(
&config,
&store,
"list_relationships",
json!({ "issue": "1", "kind": "blocked_by" }),
);
let kinds: Vec<&str> = sc(&by_input)["relationships"]
.as_array()
.unwrap()
.iter()
.map(|r| r["kind"].as_str().unwrap())
.collect();
assert_eq!(kinds, vec!["blocks"], "must not match nothing");
let again = invoke(
&config,
&store,
"list_relationships",
json!({ "issue": "1" }),
);
assert_eq!(text(&all), text(&again));
}
#[test]
fn t022_chain_returns_the_service_order_with_numbers_and_titles() {
let (_tmp, config, store) = three_issues();
invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "blocks" }),
);
invoke(
&config,
&store,
"link_issue",
json!({ "a": "2", "b": "3", "kind": "blocks" }),
);
let r = invoke(&config, &store, "issue_chain", json!({ "issue": "1" }));
assert!(!err(&r), "{}", text(&r));
assert_eq!(sc(&r)["truncated"], false);
let path = sc(&r)["paths"][0].as_array().unwrap().clone();
let numbers: Vec<u64> = path.iter().map(|n| n["number"].as_u64().unwrap()).collect();
assert_eq!(numbers, vec![1, 2, 3], "the service's order, unmodified");
assert_eq!(path[2]["title"], "C");
}
#[test]
fn t022_blocked_state_reports_the_service_reason_verbatim() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Blocker", "todo");
write_issue(&store, 2, "Subject", "todo");
invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "blocks" }),
);
let r = invoke(
&config,
&store,
"issue_blocked_state",
json!({ "issue": "2" }),
);
assert!(!err(&r), "{}", text(&r));
assert_eq!(sc(&r)["is_blocked"], true);
assert_eq!(sc(&r)["blockers"][0]["number"], 1);
let expected = format!(
"blocked: {}",
crate::relationships::blocked_reason(&[crate::relationships::ChainNode::new(
1,
"Blocker".to_string(),
)])
);
assert_eq!(text(&r), expected);
assert_eq!(sc(&r)["reason"], expected.trim_start_matches("blocked: "));
}
#[test]
fn t022_only_related_is_not_blocked() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Other", "todo");
write_issue(&store, 2, "Subject", "todo");
invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "related" }),
);
let r = invoke(
&config,
&store,
"issue_blocked_state",
json!({ "issue": "2" }),
);
assert_eq!(sc(&r)["is_blocked"], false, "{}", text(&r));
assert_eq!(
sc(&r)["blockers"].as_array().unwrap().as_slice(),
[] as [serde_json::Value; 0]
);
assert_eq!(text(&r), "not blocked");
}
#[test]
fn t022_the_read_tools_are_genuinely_read_only() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Blocker", "todo");
write_issue(&store, 2, "Subject", "todo");
invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "blocks" }),
);
let before = snap(&store);
for issue in ["1", "2"] {
invoke(&config, &store, "issue_chain", json!({ "issue": issue }));
invoke(
&config,
&store,
"issue_blocked_state",
json!({ "issue": issue }),
);
invoke(
&config,
&store,
"list_relationships",
json!({ "issue": issue }),
);
}
assert_eq!(
snap(&store),
before,
"a read tool must not write a file or append History"
);
}
fn persisted_status(store: &Store) -> String {
let entry = fs::read_dir(store.issues_dir.join("alice"))
.unwrap()
.map(|e| e.unwrap().path())
.find(|p| p.to_string_lossy().contains("alpha"))
.expect("issue file");
fs::read_to_string(entry)
.unwrap()
.lines()
.find_map(|l| l.strip_prefix("status: "))
.unwrap()
.trim()
.trim_matches('"')
.to_string()
}
fn whole_store_bytes(store: &Store) -> Vec<(std::path::PathBuf, Vec<u8>)> {
let mut out: Vec<(std::path::PathBuf, Vec<u8>)> = Vec::new();
for entry in fs::read_dir(&store.issues_dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
for child in fs::read_dir(&path).unwrap() {
let child = child.unwrap().path();
out.push((child.clone(), fs::read(&child).unwrap()));
}
}
}
out.sort();
out
}
#[test]
fn t033_move_issue_can_leave_a_terminal_column_with_a_reason() {
let (_tmp, config, store) = mapped_repo();
write_issue(&store, 1, "Alpha", "shipped");
let before = whole_store_bytes(&store);
let refused = invoke(
&config,
&store,
"move_issue",
json!({ "issue": "1", "column": "wip" }),
);
assert_eq!(refused["isError"], true, "a terminal exit needs a reason");
let text = refused["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("invalid_transition"),
"the shared named diagnostic: {text}"
);
assert_eq!(whole_store_bytes(&store), before, "and it wrote nothing");
let ok = invoke(
&config,
&store,
"move_issue",
json!({ "issue": "1", "column": "wip", "reason": "reopening: wrong epic" }),
);
assert_eq!(ok["isError"], false, "{}", ok["content"][0]["text"]);
assert_eq!(persisted_status(&store), "wip");
let issue = fs::read_to_string(
fs::read_dir(store.issues_dir.join("alice"))
.unwrap()
.map(|e| e.unwrap().path())
.find(|p| p.to_string_lossy().contains("alpha"))
.unwrap(),
)
.unwrap();
assert!(
issue.contains("override: reopening: wrong epic"),
"and the reason is in the audit trail: {issue}"
);
}
#[test]
fn t033_update_issue_status_can_leave_a_terminal_column_with_a_reason() {
let (_tmp, config, store) = mapped_repo();
write_issue(&store, 1, "Alpha", "shipped");
let before = whole_store_bytes(&store);
let refused = invoke(
&config,
&store,
"update_issue",
json!({ "issue": "1", "status": "wip" }),
);
assert_eq!(refused["isError"], true);
assert!(
refused["content"][0]["text"]
.as_str()
.unwrap()
.contains("invalid_transition"),
"the same diagnostic as `move_issue`"
);
assert_eq!(whole_store_bytes(&store), before);
let ok = invoke(
&config,
&store,
"update_issue",
json!({ "issue": "1", "status": "wip", "reason": "reopening" }),
);
assert_eq!(ok["isError"], false, "{}", ok["content"][0]["text"]);
assert_eq!(persisted_status(&store), "wip");
}
#[test]
fn t033_a_blank_reason_is_not_a_reason_over_mcp_either() {
let (_tmp, config, store) = mapped_repo();
write_issue(&store, 1, "Alpha", "shipped");
let before = whole_store_bytes(&store);
let result = invoke(
&config,
&store,
"move_issue",
json!({ "issue": "1", "column": "wip", "reason": " " }),
);
assert_eq!(result["isError"], true);
assert_eq!(whole_store_bytes(&store), before);
}
#[test]
fn t033_reason_alone_is_not_an_update() {
let (_tmp, config, store) = mapped_repo();
write_issue(&store, 1, "Alpha", "triage");
let result = invoke(
&config,
&store,
"update_issue",
json!({ "issue": "1", "reason": "because" }),
);
assert_eq!(result["isError"], true);
assert!(
result["content"][0]["text"]
.as_str()
.unwrap()
.contains("no fields to update"),
"got: {}",
result["content"][0]["text"]
);
}
#[test]
fn t033_reads_carry_the_category_key_and_label() {
let (_tmp, config, store) = mapped_repo();
write_issue(&store, 1, "Alpha", "triage");
for tool in ["get_issue", "list_issues"] {
let args = if tool == "get_issue" {
json!({ "issue": "1" })
} else {
json!({ "all": true })
};
let result = invoke(&config, &store, tool, args);
assert_eq!(
result["isError"], false,
"{tool}: {}",
result["content"][0]["text"]
);
let structured = &result["structuredContent"];
let issue = if tool == "get_issue" {
structured.clone()
} else {
structured["issues"][0].clone()
};
assert_eq!(
issue["category"], "backlog",
"{tool} must carry the category key"
);
assert_eq!(
issue["category_label"], "Backlog",
"{tool} must carry the service's label"
);
}
}
#[test]
fn t033_an_unmapped_column_reads_as_null_not_a_default() {
let (_tmp, config, store) = mapped_repo();
write_issue(&store, 1, "Alpha", "not-a-column");
let result = invoke(&config, &store, "get_issue", json!({ "issue": "1" }));
let issue = result["structuredContent"].clone();
assert_eq!(issue["category"], Value::Null);
assert_eq!(issue["category_label"], "(unmapped)");
}
#[test]
fn t033_the_writes_carry_the_category_too() {
let (_tmp, config, store) = mapped_repo();
let created = invoke(
&config,
&store,
"create_issue",
json!({ "title": "Beta", "status": "wip" }),
);
let value = created["structuredContent"].clone();
assert_eq!(value["category"], "active", "created: {value}");
assert_eq!(value["category_label"], "In progress");
}
const TRIAGE: &str = "\n[triage]\nentry_status = \"triage\"\ndeclined_status = \"abandoned\"\n";
fn triage_repo() -> (TempDir, Config, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let text = format!(
"issues_dir = \"{}\"\n\n[kanban.default]\ncolumns = [\"triage\", \"wip\", \
\"review\", \"shipped\", \"abandoned\"]\ndone_column = \"shipped\"\n\
category_map = {{ triage = \"backlog\", wip = \"active\", review = \"active\", \
shipped = \"completed\", abandoned = \"canceled\" }}\n{TRIAGE}\n\
[layout]\nmove_on_change = false\n",
dir.display()
);
fs::write(&config_path, text).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let store = Store {
issues_dir: dir,
config: config.clone(),
};
(tmp, config, store)
}
fn write_triage_issue(store: &Store, number: u32, title: &str, entered_at: Option<&str>) {
let mut text = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: triage\nlabels: []\n\
priority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n"
);
if let Some(at) = entered_at {
text.push_str(&format!("entered_at: {at}\nsource: test\n"));
}
text.push_str("---\n\nBody.");
fs::write(
store
.issues_dir
.join(format!("2026-01-01-issue-{number}.md")),
text,
)
.unwrap();
}
fn triage_issue_path(store: &Store, number: u32) -> std::path::PathBuf {
store
.issues_dir
.join(format!("2026-01-01-issue-{number}.md"))
}
#[test]
fn t038_the_triage_schemas_are_strict_and_only_the_apply_requires_confirm() {
let by_name = |name: &str| -> &ToolDescriptor {
registry()
.iter()
.find(|t| t.name == name)
.unwrap_or_else(|| panic!("`{name}` is not registered"))
};
for name in [
"triage_list",
"triage_status",
"triage_preview",
"triage_apply",
] {
let tool = by_name(name);
assert_eq!(tool.input_schema["additionalProperties"], json!(false));
assert_eq!(tool.input_schema["type"], json!("object"));
}
assert_eq!(
by_name("triage_apply").input_schema["required"],
json!(["decision", "confirm"]),
"a write tool whose confirmation is optional is a write tool clients call by accident"
);
assert_eq!(
by_name("triage_preview").input_schema["required"],
json!(["decision"])
);
assert!(
by_name("triage_preview").input_schema["properties"]
.get("confirm")
.is_none(),
"the preview does not even advertise a confirmation"
);
assert_eq!(
by_name("triage_list").input_schema["properties"]["age"]["type"],
json!("boolean")
);
}
#[test]
fn t039_a_triage_preview_changes_nothing_and_names_every_item() {
let (_tmp, config, store) = triage_repo();
write_triage_issue(&store, 1, "Alpha", Some("2020-01-01T00:00:00Z"));
write_triage_issue(&store, 2, "Bravo", None);
let before = whole_store_bytes(&store);
let out = invoke(
&config,
&store,
"triage_preview",
json!({"select": "1,2", "decision": "decline", "reason": "no budget", "actor": "tester"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["applied"], json!(false));
assert_eq!(
sc(&out)["items"].as_array().map(Vec::len),
Some(2),
"one entry per selected issue"
);
assert_eq!(
sc(&out)["items"][0]["history"][0]["kind"],
json!("triage_declined")
);
assert_eq!(
before,
whole_store_bytes(&store),
"a preview is read-only: no status, no History, no file"
);
}
#[test]
fn t039_a_missing_reason_is_a_refused_item_naming_the_contracts_kind() {
let (_tmp, config, store) = triage_repo();
write_triage_issue(&store, 1, "Alpha", Some("2020-01-01T00:00:00Z"));
let out = invoke(
&config,
&store,
"triage_preview",
json!({"issue": 1, "decision": "decline", "actor": "tester"}),
);
assert!(!err(&out), "a refusal per item is a plan, not an error");
let item = sc(&out)["items"][0].clone();
assert_eq!(item["status"], "rejected");
assert!(
item["diagnostic"]["message"]
.as_str()
.unwrap_or_default()
.contains("missing_reason"),
"{item}"
);
}
#[test]
fn t039_a_triage_apply_needs_the_confirmation_and_then_writes_one_event() {
let (_tmp, config, store) = triage_repo();
write_triage_issue(&store, 1, "Alpha", Some("2020-01-01T00:00:00Z"));
let before = whole_store_bytes(&store);
let refused = invoke(
&config,
&store,
"triage_apply",
json!({"issue": 1, "decision": "decline", "reason": "no budget", "actor": "tester"}),
);
assert!(err(&refused), "an apply without `confirm` is refused");
assert!(
text(&refused).contains("confirm is required"),
"{}",
text(&refused)
);
assert_eq!(before, whole_store_bytes(&store), "and writes nothing");
let applied = invoke(
&config,
&store,
"triage_apply",
json!({
"issue": 1, "decision": "decline", "reason": "no budget",
"actor": "tester", "confirm": true,
}),
);
assert!(!err(&applied), "{}", text(&applied));
assert_eq!(sc(&applied)["applied"], json!(1));
let written = fs::read_to_string(triage_issue_path(&store, 1)).unwrap();
assert!(
written.lines().any(|l| l.trim() == "status: abandoned"),
"the configured declined column, not a column this handler chose:\n{written}"
);
assert_eq!(
written.matches("triage_declined").count(),
1,
"exactly one History event:\n{written}"
);
}
#[test]
fn t040_every_triage_tool_is_dispatched_with_a_minimal_call() {
let (_tmp, config, store) = triage_repo();
write_triage_issue(&store, 1, "Alpha", Some("2020-01-01T00:00:00Z"));
for (name, args) in [
("triage_list", json!({})),
("triage_status", json!({})),
(
"triage_preview",
json!({"issue": 1, "decision": "defer", "reason": "later"}),
),
] {
let out = invoke(&config, &store, name, args);
assert!(!err(&out), "`{name}`: {}", text(&out));
}
let applied = invoke(
&config,
&store,
"triage_apply",
json!({"issue": 1, "decision": "defer", "reason": "later", "confirm": true}),
);
assert!(!err(&applied), "triage_apply: {}", text(&applied));
}
#[test]
fn t039_a_target_the_decision_cannot_use_is_refused_before_the_plan() {
let (_tmp, config, store) = triage_repo();
write_triage_issue(&store, 1, "Alpha", Some("2020-01-01T00:00:00Z"));
let before = whole_store_bytes(&store);
for (decision, args) in [
("decline", json!({"to": "abandoned"})),
("defer", json!({"to": "abandoned"})),
("accept", json!({"of": "1"})),
] {
let mut request = json!({
"issue": 1, "decision": decision, "reason": "x", "actor": "tester",
"confirm": true,
});
if let Some(object) = args.as_object() {
for (key, value) in object {
request[key] = value.clone();
}
}
let out = invoke(&config, &store, "triage_apply", request);
assert!(
err(&out),
"`{decision}` with {args} must be refused, not silently ignored: {}",
text(&out)
);
}
assert_eq!(
before,
whole_store_bytes(&store),
"and a refused request writes nothing"
);
}
#[test]
fn t041_an_invalid_section_reports_the_validators_message_not_the_missing_one() {
let (_tmp, config, store) = triage_repo();
let mut invalid = config.clone();
invalid
.triage
.as_mut()
.expect("the fixture configures a section")
.entry_status = Some("no-such-column".to_string());
let store = Store {
issues_dir: store.issues_dir.clone(),
config: invalid.clone(),
};
let cli_err = cmd_triage::render_list(&store, false, false)
.expect_err("a column the board lacks is refused");
let cli_error = crate::error::render_error(&cli_err);
assert!(
cli_error.contains("no-such-column"),
"the validator names the offending column: {cli_error}"
);
assert!(
!cli_error.ends_with(NO_INBOX),
"a different problem, a different sentence"
);
let out = invoke(&invalid, &store, "triage_list", json!({}));
assert!(err(&out), "`triage_list` refuses an invalid section");
assert_eq!(
text(&out),
cli_error,
"the validator's message, not the missing-section one"
);
}
#[test]
fn t041_the_no_inbox_refusal_is_the_clis_own_sentence() {
let (_tmp, config, store) = make_repo();
let cli_error = cmd_triage::render_list(&store, false, false)
.expect_err("a project with no `[triage]` section has no inbox")
.to_string();
assert!(
cli_error.ends_with(NO_INBOX),
"the CLI says `{cli_error}`, and the MCP constant agrees only if it ends with it"
);
for name in ["triage_list", "triage_status"] {
let out = invoke(&config, &store, name, json!({}));
assert!(err(&out), "`{name}` must refuse");
assert_eq!(
text(&out),
cli_error,
"`{name}` carries the CLI's sentence verbatim, not a paraphrase"
);
}
let decision = invoke(
&config,
&store,
"triage_preview",
json!({"issue": 1, "decision": "defer", "reason": "later"}),
);
assert!(
err(&decision),
"and so does a decision through an inbox that does not exist"
);
}
fn project_repo(projects: &[(&str, &str)]) -> (TempDir, Store) {
let (tmp, _config, store) = make_repo();
let mut config = store.config.clone();
for (id, name) in projects {
config.projects.insert(
(*id).to_string(),
crate::config::ProjectConfiguration {
name: (*name).to_string(),
..Default::default()
},
);
}
let path = tmp.path().join("tix.toml");
config.save(&path).unwrap();
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let store = Store {
issues_dir: store.issues_dir.clone(),
config,
};
(tmp, store)
}
fn write_project_issue(
store: &Store,
number: u32,
title: &str,
status: &str,
projects: &[&str],
assignee: Option<&str>,
due: Option<&str>,
) {
let mut frontmatter = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\n\
priority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n"
);
if !projects.is_empty() {
frontmatter.push_str("projects:\n");
for id in projects {
frontmatter.push_str(&format!("- {id}\n"));
}
}
if let Some(assignee) = assignee {
frontmatter.push_str(&format!("assignee: \"{assignee}\"\n"));
}
if let Some(due) = due {
frontmatter.push_str(&format!("due_date: {due}\n"));
}
frontmatter.push_str("---\n\nbody\n");
let slug = crate::slug::slugify(title);
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::write(
store.issues_dir.join(format!("alice/2026-01-01-{slug}.md")),
frontmatter,
)
.unwrap();
}
fn write_archived_project_issue(store: &Store, number: u32, title: &str, projects: &[&str]) {
let mut frontmatter = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\n\
priority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n"
);
if !projects.is_empty() {
frontmatter.push_str("projects:\n");
for id in projects {
frontmatter.push_str(&format!("- {id}\n"));
}
}
frontmatter.push_str("---\n\nbody\n");
let slug = crate::slug::slugify(title);
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::write(
store
.issues_dir
.join(format!("alice/.2026-01-01-{slug}.md")),
frontmatter,
)
.unwrap();
}
fn association_events(store: &Store, number: u32) -> Vec<crate::model::HistoryEventKind> {
store
.resolve_issue(&number.to_string())
.unwrap()
.history
.events()
.iter()
.map(|event| event.kind)
.filter(|kind| *kind == crate::model::HistoryEventKind::ProjectAssociated)
.collect()
}
#[test]
fn t031_create_issue_associates_a_multi_value_project_set() {
let (_tmp, store) = project_repo(&[("p-a", "Platform"), ("p-b", "Docs")]);
let out = invoke(
&store.config,
&store,
"create_issue",
json!({"title": "Associated", "projects": "p-a,p-b"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["projects"], json!(["p-a", "p-b"]));
let out = invoke(
&store.config,
&store,
"create_issue",
json!({"title": "Named", "projects": "Platform,p-a"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["projects"], json!(["p-a"]));
let number = sc(&out)["number"].as_u64().unwrap() as u32;
assert_eq!(association_events(&store, number).len(), 1);
}
#[test]
fn t031_an_unknown_project_refuses_the_whole_create_and_writes_nothing() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
let before = snap(&store);
let out = invoke(
&store.config,
&store,
"create_issue",
json!({"title": "Half", "projects": "p-a,bogus"}),
);
assert!(err(&out), "an unknown reference refuses the create");
assert!(text(&out).contains("unknown_project"), "{}", text(&out));
assert_eq!(snap(&store), before, "a refused create writes nothing");
}
#[test]
fn t031_projects_beside_a_template_are_refused_with_the_clis_words() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
let before = snap(&store);
let out = invoke(
&store.config,
&store,
"create_issue",
json!({"template": "t-1", "projects": "p-a"}),
);
assert!(err(&out));
assert!(
text(&out).contains("--projects cannot be combined with --template yet"),
"{}",
text(&out)
);
assert_eq!(snap(&store), before, "refused before anything is written");
}
#[test]
fn t031_update_issue_adds_removes_and_clears_projects() {
let (_tmp, store) = project_repo(&[("p-a", "Platform"), ("p-b", "Docs")]);
write_issue(&store, 1, "Work", "todo");
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "add_project": ["p-a"]}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["projects"], json!(["p-a"]));
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "add_project": ["p-a"]}),
);
assert!(!err(&out));
assert!(
text(&out).contains("(no association change)"),
"{}",
text(&out)
);
assert_eq!(sc(&out)["projects"], json!(["p-a"]));
assert_eq!(
association_events(&store, 1).len(),
1,
"a no-op association appends no second History entry"
);
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "add_project": ["Docs", "p-b", "p-a"]}),
);
assert_eq!(sc(&out)["projects"], json!(["p-a", "p-b"]));
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "remove_project": ["p-a"]}),
);
assert_eq!(sc(&out)["projects"], json!(["p-b"]));
assert!(store.resolve_issue("1").is_ok());
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "projects": ""}),
);
assert!(!err(&out), "{}", text(&out));
assert!(sc(&out)["projects"].is_null(), "{}", text(&out));
assert!(
store.resolve_issue("1").is_ok(),
"the issue is never deleted"
);
}
#[test]
fn t031_a_project_entry_is_taken_verbatim_like_a_cli_occurrence() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
write_issue(&store, 1, "Work", "todo");
for entry in [" p-a ", "p-a ", ""] {
let before = snap(&store);
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "add_project": [entry]}),
);
assert!(err(&out), "`{entry}` must be refused");
assert!(text(&out).contains("unknown_project"), "{}", text(&out));
assert_eq!(snap(&store), before, "a refusal writes nothing");
}
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "add_project": []}),
);
assert!(err(&out));
assert!(text(&out).contains("no fields to update"), "{}", text(&out));
}
#[test]
fn t031_a_non_blank_projects_value_is_refused_like_the_cli() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
write_issue(&store, 1, "Work", "todo");
let before = snap(&store);
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "projects": "p-a"}),
);
assert!(err(&out));
assert!(text(&out).contains("only clears"), "{}", text(&out));
assert_eq!(snap(&store), before);
}
#[test]
fn t031_an_unknown_add_project_refuses_the_whole_update_byte_identically() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
write_issue(&store, 1, "Work", "todo");
let before = snap(&store);
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "title": "Renamed", "add_project": ["bogus"]}),
);
assert!(err(&out));
assert!(text(&out).contains("unknown_project"), "{}", text(&out));
assert_eq!(
snap(&store),
before,
"a refused update leaves every file byte-identical"
);
}
#[test]
fn t031_update_issue_reports_no_fields_to_update_without_project_args() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
write_issue(&store, 1, "Work", "todo");
let out = invoke(&store.config, &store, "update_issue", json!({"issue": 1}));
assert!(err(&out));
assert!(text(&out).contains("no fields to update"), "{}", text(&out));
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "add_project": []}),
);
assert!(err(&out));
assert!(text(&out).contains("no fields to update"), "{}", text(&out));
let protocol_error = call(
&store.config,
&store,
super::protocol::Id::Number(1),
"update_issue",
&json!({"issue": 1, "add_project": "p-a"}),
);
assert!(
protocol_error.is_err(),
"`add_project` is an array; a string must be a -32602, not a CSV list"
);
}
#[test]
fn t032_create_project_returns_the_allocated_id_and_refuses_a_duplicate_name() {
let (_tmp, store) = project_repo(&[]);
let out = invoke(
&store.config,
&store,
"create_project",
json!({"name": "Platform", "status": "active"}),
);
assert!(!err(&out), "{}", text(&out));
let id = sc(&out)["id"].as_str().unwrap().to_string();
assert!(
crate::config::ProjectId::new(&id).is_ok(),
"the registry allocated `{id}`, which must be a valid stable id"
);
assert_eq!(sc(&out)["name"], "Platform");
assert_eq!(sc(&out)["status"], "active");
assert_eq!(sc(&out)["member_count"], 0);
let listed = invoke(&store.config, &store, "list_projects", json!({}));
assert_eq!(sc(&listed)["projects"][0]["id"], id);
let out = invoke(
&store.config,
&store,
"create_project",
json!({"name": "platform"}),
);
assert!(err(&out));
assert!(text(&out).contains("duplicate_name"), "{}", text(&out));
let out = invoke(
&store.config,
&store,
"create_project",
json!({"name": "Dated", "start_date": "2026-02-01", "due_date": "2026-01-01"}),
);
assert!(err(&out));
assert!(text(&out).contains("invalid_dates"), "{}", text(&out));
}
#[test]
fn t032_update_project_renames_the_label_and_keeps_the_id() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
write_project_issue(&store, 1, "One", "todo", &["p-a"], None, None);
let out = invoke(
&store.config,
&store,
"update_project",
json!({"project": "p-a", "name": "Platform hardening"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["id"], "p-a", "the id survives a rename");
assert_eq!(sc(&out)["name"], "Platform hardening");
assert_eq!(sc(&out)["member_count"], 1);
let membership = invoke(
&store.config,
&store,
"project_membership",
json!({"project": "Platform hardening"}),
);
assert!(!err(&membership), "{}", text(&membership));
assert_eq!(sc(&membership)["member_count"], 1);
let out = invoke(
&store.config,
&store,
"update_project",
json!({"project": "p-a"}),
);
assert!(err(&out));
assert!(text(&out).contains("no fields to change"), "{}", text(&out));
}
#[test]
fn t032_list_projects_returns_ids_and_derived_member_counts() {
let (_tmp, store) = project_repo(&[("p-a", "Platform"), ("p-b", "Docs")]);
write_project_issue(&store, 1, "One", "todo", &["p-a", "p-b"], None, None);
write_project_issue(&store, 2, "Two", "todo", &["p-a"], None, None);
let out = invoke(&store.config, &store, "list_projects", json!({}));
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["projects"][0]["id"], "p-a");
assert_eq!(sc(&out)["projects"][0]["member_count"], 2);
assert_eq!(sc(&out)["projects"][1]["id"], "p-b");
assert_eq!(sc(&out)["projects"][1]["member_count"], 1);
assert!(text(&out).contains("p-a\tPlatform\t2"), "{}", text(&out));
}
#[test]
fn t032_the_project_reads_use_the_snapshot_their_cli_read_uses() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
write_project_issue(&store, 7, "Seven", "todo", &["p-a"], None, None);
write_project_issue(&store, 3, "Three", "todo", &["p-a"], None, None);
write_archived_project_issue(&store, 5, "Archived", &["p-a"]);
let shown = invoke(
&store.config,
&store,
"get_project",
json!({"project": "Platform"}),
);
assert!(!err(&shown), "{}", text(&shown));
assert_eq!(sc(&shown)["id"], "p-a");
assert_eq!(sc(&shown)["member_count"], 3);
assert_eq!(sc(&shown)["members"][0]["issue"], 3);
assert_eq!(sc(&shown)["members"][1]["issue"], 5);
assert_eq!(sc(&shown)["members"][2]["issue"], 7);
let membership = invoke(
&store.config,
&store,
"project_membership",
json!({"project": "p-a"}),
);
assert!(!err(&membership), "{}", text(&membership));
assert_eq!(sc(&membership)["project_id"], "p-a");
assert_eq!(sc(&membership)["member_count"], 2);
assert_eq!(sc(&membership)["members"][0]["issue"], 3);
assert_eq!(sc(&membership)["members"][1]["issue"], 7);
assert_eq!(sc(&membership)["members"][0], sc(&shown)["members"][0]);
assert_eq!(sc(&membership)["members"][1], sc(&shown)["members"][2]);
let again = invoke(
&store.config,
&store,
"project_membership",
json!({"project": "p-a"}),
);
assert_eq!(sc(&again), sc(&membership));
let health = invoke(
&store.config,
&store,
"project_health",
json!({"project": "p-a"}),
);
assert!(!err(&health), "{}", text(&health));
assert_eq!(sc(&health)["total"], 2, "the active set, not every file");
let filtered = invoke(
&store.config,
&store,
"list_issues",
json!({"project": "p-a", "all": true}),
);
assert!(!err(&filtered), "{}", text(&filtered));
let mut numbers: Vec<u64> = sc(&filtered)["issues"]
.as_array()
.unwrap()
.iter()
.map(|issue| issue["number"].as_u64().unwrap())
.collect();
numbers.sort_unstable();
assert_eq!(
numbers,
vec![3, 7],
"the filter matches the membership read"
);
}
#[test]
fn t032_project_health_returns_the_raw_counters() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
write_project_issue(&store, 1, "Done", "done", &["p-a"], Some("alice"), None);
write_project_issue(
&store,
2,
"Late",
"todo",
&["p-a"],
None,
Some("2020-01-01"),
);
write_project_issue(&store, 3, "Assigned", "todo", &["p-a"], Some("bob"), None);
let out = invoke(
&store.config,
&store,
"project_health",
json!({"project": "Platform"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["project_id"], "p-a");
assert_eq!(sc(&out)["done"], 1);
assert_eq!(sc(&out)["total"], 3);
assert_eq!(sc(&out)["overdue"], 1);
assert_eq!(sc(&out)["blocked"], 0);
assert_eq!(sc(&out)["unassigned"], 1);
assert_eq!(sc(&out)["missing_references"], json!([]));
}
#[test]
fn t032_project_health_equals_the_shared_read_path_by_value() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
write_project_issue(&store, 1, "Done", "done", &["p-a"], Some("alice"), None);
write_project_issue(
&store,
2,
"Late",
"todo",
&["p-a"],
None,
Some("2020-01-01"),
);
let issues = store.list_all().unwrap();
let (_index, reports) = crate::commands::view::project_reports(&store, &issues).unwrap();
let expected = reports
.get(&crate::config::ProjectId::new("p-a").unwrap())
.expect("the fixture registers `p-a`");
let out = invoke(
&store.config,
&store,
"project_health",
json!({"project": "p-a"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["done"], expected.progress.done);
assert_eq!(sc(&out)["total"], expected.progress.total);
assert_eq!(sc(&out)["overdue"], expected.health.overdue);
assert_eq!(sc(&out)["blocked"], expected.health.blocked);
assert_eq!(sc(&out)["unassigned"], expected.health.unassigned);
assert_eq!(
sc(&out)["missing_references"].as_array().unwrap().len(),
expected.health.missing_references.len()
);
assert_eq!(
text(&out),
format!(
"{} {}/{}{}",
crate::projects::project_display(&expected.project_id, &expected.name),
expected.progress.done,
expected.progress.total,
crate::commands::view::health_suffix(expected),
)
);
}
#[test]
fn t032_project_health_reports_a_members_missing_reference() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
write_project_issue(
&store,
1,
"Dangling",
"todo",
&["p-a", "p-gone"],
Some("alice"),
None,
);
let out = invoke(
&store.config,
&store,
"project_health",
json!({"project": "p-a"}),
);
assert!(!err(&out), "{}", text(&out));
let missing = sc(&out)["missing_references"].as_array().unwrap().clone();
assert_eq!(missing.len(), 1, "{missing:?}");
assert_eq!(missing[0]["issue"], 1);
assert_eq!(missing[0]["project_id"], "p-gone");
assert!(
missing[0]["reason"]
.as_str()
.unwrap()
.contains("not in the registry"),
"the service's own reason: {}",
missing[0]["reason"]
);
let issues = store.list_all().unwrap();
let service = crate::projects::validate_references(&store.config, &issues);
assert_eq!(service.len(), 1);
assert_eq!(service[0].project_id, "p-gone");
assert_eq!(service[0].reason, missing[0]["reason"]);
}
#[test]
fn t032_delete_project_reports_the_affected_issues_and_never_deletes_one() {
let (_tmp, store) = project_repo(&[("p-a", "Platform"), ("p-b", "Docs")]);
write_project_issue(&store, 1, "One", "todo", &["p-a"], None, None);
write_project_issue(&store, 2, "Two", "todo", &["p-b"], None, None);
let out = invoke(
&store.config,
&store,
"delete_project",
json!({"project": "Platform", "confirm": true}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["id"], "p-a");
assert_eq!(sc(&out)["name"], "Platform");
let affected = sc(&out)["affected"].as_array().unwrap();
assert_eq!(affected.len(), 1);
assert_eq!(affected[0]["issue"], 1);
assert!(affected[0]["display"].as_str().unwrap().starts_with("#1 "));
let issue = store.resolve_issue("1").unwrap();
assert_eq!(issue.projects, Some(vec!["p-a".to_string()]));
let listed = invoke(&store.config, &store, "list_projects", json!({}));
let ids: Vec<&str> = sc(&listed)["projects"]
.as_array()
.unwrap()
.iter()
.filter_map(|project| project["id"].as_str())
.collect();
assert_eq!(ids, vec!["p-b"], "only the registry entry was removed");
}
#[test]
fn t032_delete_project_without_confirm_is_refused_and_removes_nothing() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
let out = invoke(
&store.config,
&store,
"delete_project",
json!({"project": "p-a"}),
);
assert!(err(&out));
assert!(text(&out).contains("confirm"), "{}", text(&out));
let listed = invoke(&store.config, &store, "list_projects", json!({}));
assert_eq!(sc(&listed)["projects"].as_array().unwrap().len(), 1);
}
#[test]
fn t032_the_project_read_tools_are_read_only() {
let (tmp, store) = project_repo(&[("p-a", "Platform")]);
write_project_issue(&store, 1, "One", "todo", &["p-a"], None, None);
let files_before = snap(&store);
let registry_before = fs::read_to_string(tmp.path().join("tix.toml")).unwrap();
for (name, args) in [
("list_projects", json!({})),
("get_project", json!({"project": "p-a"})),
("project_membership", json!({"project": "p-a"})),
("project_health", json!({"project": "p-a"})),
] {
let out = invoke(&store.config, &store, name, args);
assert!(!err(&out), "{name}: {}", text(&out));
}
assert_eq!(
snap(&store),
files_before,
"a read tool writes no issue file"
);
assert_eq!(
fs::read_to_string(tmp.path().join("tix.toml")).unwrap(),
registry_before,
"and does not rewrite the registry"
);
assert_eq!(
association_events(&store, 1),
[] as [crate::model::HistoryEventKind; 0]
);
}
#[test]
fn t032_associate_issues_reports_each_selector_independently() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
write_project_issue(&store, 1, "One", "todo", &[], None, None);
write_project_issue(&store, 2, "Two", "todo", &["p-a"], None, None);
let out = invoke(
&store.config,
&store,
"project_associate_issues",
json!({"project": "Platform", "issues": "1,2,99"}),
);
assert!(!err(&out), "{}", text(&out));
let outcomes = sc(&out)["outcomes"].as_array().unwrap();
assert_eq!(
outcomes.len(),
3,
"one outcome per selector, in input order"
);
assert_eq!(outcomes[0]["selector"], "1");
assert_eq!(outcomes[0]["changed"], true);
assert_eq!(
outcomes[1]["changed"], false,
"already associated is a no-op"
);
assert!(
outcomes[2]["error"].as_str().is_some(),
"an unresolvable selector is that entry's error, not the run's"
);
assert_eq!(sc(&out)["changed"], 1);
assert_eq!(sc(&out)["failed"], 1);
assert_eq!(
store.resolve_issue("1").unwrap().projects,
Some(vec!["p-a".to_string()]),
"the accepted selector inside a partly-failing batch was still written"
);
let before = snap(&store);
let out = invoke(
&store.config,
&store,
"project_associate_issues",
json!({"project": "bogus", "issues": "1"}),
);
assert!(err(&out));
assert!(text(&out).contains("unknown_project"), "{}", text(&out));
assert_eq!(snap(&store), before);
}
#[test]
fn t032_dissociate_issues_uses_a_live_id_or_the_verbatim_reference() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
write_project_issue(&store, 1, "One", "todo", &["p-a", "p-stale"], None, None);
let out = invoke(
&store.config,
&store,
"project_dissociate_issues",
json!({"project": "Platform", "issues": "1"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(
store.resolve_issue("1").unwrap().projects,
Some(vec!["p-stale".to_string()])
);
let out = invoke(
&store.config,
&store,
"project_dissociate_issues",
json!({"project": "p-stale", "issues": "1"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(store.resolve_issue("1").unwrap().projects, None);
let out = invoke(
&store.config,
&store,
"project_dissociate_issues",
json!({"project": "p-stale", "issues": "1"}),
);
assert!(!err(&out));
assert_eq!(sc(&out)["changed"], 0);
assert!(
store.resolve_issue("1").is_ok(),
"no issue is ever deleted (C-004)"
);
}
#[test]
fn t034_list_issues_filters_by_the_services_membership_set() {
let (_tmp, store) = project_repo(&[("p-a", "Platform"), ("p-b", "Docs")]);
write_project_issue(&store, 1, "In", "todo", &["p-a"], None, None);
write_project_issue(&store, 2, "Out", "todo", &["p-b"], None, None);
let out = invoke(
&store.config,
&store,
"list_issues",
json!({"project": "Platform", "all": true}),
);
assert!(!err(&out), "{}", text(&out));
let issues = sc(&out)["issues"].as_array().unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0]["number"], 1);
let membership = invoke(
&store.config,
&store,
"project_membership",
json!({"project": "p-a"}),
);
assert_eq!(sc(&membership)["member_count"], 1);
assert_eq!(sc(&membership)["members"][0]["issue"], 1);
let out = invoke(
&store.config,
&store,
"list_issues",
json!({"project": "bogus"}),
);
assert!(err(&out));
assert!(text(&out).contains("unknown_project"), "{}", text(&out));
}
#[test]
fn t033_every_project_tool_is_declared_and_dispatched() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
let declared_tools = list();
let declared: Vec<&str> = declared_tools
.iter()
.filter_map(|tool| tool["name"].as_str())
.collect();
for name in [
"list_projects",
"get_project",
"create_project",
"update_project",
"delete_project",
"project_membership",
"project_health",
"project_associate_issues",
"project_dissociate_issues",
] {
assert!(
declared.contains(&name),
"`{name}` must be declared in the registry: {declared:?}"
);
assert!(
dispatch(&store.config, &store, name, &json!({})).is_some(),
"`{name}` must have a dispatch arm, not merely a descriptor"
);
}
}
fn epic_repo(epics: &[(&str, &str, Option<i64>, Option<&str>)]) -> (TempDir, Store) {
let (tmp, _config, store) = make_repo();
let mut config = store.config.clone();
for (id, name, weight, parent) in epics {
config.epics.insert(
(*id).to_string(),
crate::config::EpicConfiguration {
name: (*name).to_string(),
weight: *weight,
parent: parent.map(|p| p.to_string()),
..Default::default()
},
);
}
let path = tmp.path().join("tix.toml");
config.save(&path).unwrap();
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let store = Store {
issues_dir: store.issues_dir.clone(),
config,
};
(tmp, store)
}
fn write_epic_issue(store: &Store, number: u32, title: &str, status: &str, epics: &[&str]) {
let mut frontmatter = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\n\
priority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n"
);
if !epics.is_empty() {
frontmatter.push_str("epics:\n");
for id in epics {
frontmatter.push_str(&format!("- {id}\n"));
}
}
frontmatter.push_str("---\n\nbody\n");
let slug = crate::slug::slugify(title);
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::write(
store.issues_dir.join(format!("alice/2026-01-01-{slug}.md")),
frontmatter,
)
.unwrap();
}
fn epic_events(store: &Store, number: u32) -> Vec<crate::model::HistoryEventKind> {
store
.resolve_issue(&number.to_string())
.unwrap()
.history
.events()
.iter()
.map(|event| event.kind)
.filter(|kind| {
matches!(
kind,
crate::model::HistoryEventKind::EpicReferenced
| crate::model::HistoryEventKind::EpicDereferenced
)
})
.collect()
}
#[test]
fn t033_create_issue_references_a_multi_value_epic_set() {
let (_tmp, store) = epic_repo(&[
("e-aaa111", "Alpha", None, None),
("e-bbb222", "Beta", None, None),
]);
let out = invoke(
&store.config,
&store,
"create_issue",
json!({"title": "Referenced", "epics": "e-aaa111,e-bbb222"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["epics"], json!(["e-aaa111", "e-bbb222"]));
let out = invoke(
&store.config,
&store,
"create_issue",
json!({"title": "Named", "epics": "Alpha,e-aaa111"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["epics"], json!(["e-aaa111"]));
let number = sc(&out)["number"].as_u64().unwrap() as u32;
assert_eq!(epic_events(&store, number).len(), 1);
}
#[test]
fn t033_an_unknown_epic_refuses_the_whole_create_and_writes_nothing() {
let (_tmp, store) = epic_repo(&[("e-aaa111", "Alpha", None, None)]);
let before = snap(&store);
let out = invoke(
&store.config,
&store,
"create_issue",
json!({"title": "Half", "epics": "e-aaa111,bogus"}),
);
assert!(err(&out), "an unknown reference refuses the create");
assert!(text(&out).contains("unknown_epic"), "{}", text(&out));
assert_eq!(snap(&store), before, "a refused create writes nothing");
}
#[test]
fn t033_update_issue_adds_removes_and_clears_epics() {
let (_tmp, store) = epic_repo(&[
("e-aaa111", "Alpha", None, None),
("e-bbb222", "Beta", None, None),
]);
write_issue(&store, 1, "Work", "todo");
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "add_epic": ["e-aaa111"]}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["epics"], json!(["e-aaa111"]));
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "add_epic": ["e-aaa111"]}),
);
assert!(!err(&out));
assert!(
text(&out).contains("(no association change)"),
"{}",
text(&out)
);
assert_eq!(epic_events(&store, 1).len(), 1);
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "add_epic": ["Beta", "e-bbb222", "e-aaa111"]}),
);
assert_eq!(sc(&out)["epics"], json!(["e-aaa111", "e-bbb222"]));
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "remove_epic": ["e-aaa111"]}),
);
assert_eq!(sc(&out)["epics"], json!(["e-bbb222"]));
assert!(store.resolve_issue("1").is_ok());
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "epics": ""}),
);
assert!(!err(&out), "{}", text(&out));
assert!(sc(&out)["epics"].is_null(), "{}", text(&out));
assert!(
store.resolve_issue("1").is_ok(),
"the issue is never deleted"
);
}
#[test]
fn t033_an_unknown_add_epic_refuses_the_whole_update_byte_identically() {
let (_tmp, store) = epic_repo(&[("e-aaa111", "Alpha", None, None)]);
write_issue(&store, 1, "Work", "todo");
let before = snap(&store);
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "title": "Renamed", "add_epic": ["bogus"]}),
);
assert!(err(&out));
assert!(text(&out).contains("unknown_epic"), "{}", text(&out));
assert_eq!(
snap(&store),
before,
"a refused update leaves every file byte-identical"
);
}
#[test]
fn t033_a_non_blank_epics_value_is_refused_like_the_cli() {
let (_tmp, store) = epic_repo(&[("e-aaa111", "Alpha", None, None)]);
write_issue(&store, 1, "Work", "todo");
let before = snap(&store);
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "epics": "e-bbb222"}),
);
assert!(err(&out));
assert!(text(&out).contains("only clears"), "{}", text(&out));
assert_eq!(snap(&store), before);
}
#[test]
fn t033_epics_beside_a_template_are_refused_with_the_clis_words() {
let (_tmp, store) = epic_repo(&[("e-aaa111", "Alpha", None, None)]);
let before = snap(&store);
let out = invoke(
&store.config,
&store,
"create_issue",
json!({"template": "t-1", "epics": "e-aaa111"}),
);
assert!(err(&out));
assert!(
text(&out).contains("--epics cannot be combined with --template yet"),
"{}",
text(&out)
);
assert_eq!(snap(&store), before, "refused before anything is written");
}
#[test]
fn t034_create_epic_returns_the_allocated_id() {
let (_tmp, store) = epic_repo(&[]);
let out = invoke(
&store.config,
&store,
"create_epic",
json!({"name": "Platform", "weight": 3, "projects": "p-1"}),
);
assert!(!err(&out), "{}", text(&out));
let id = sc(&out)["id"].as_str().unwrap().to_string();
assert!(id.starts_with("e-"), "the registry allocated `{id}`");
assert!(text(&out).contains("Created"));
let listed = invoke(&store.config, &store, "list_epics", json!({}));
assert_eq!(sc(&listed)["epics"][0]["id"], json!(id));
assert_eq!(sc(&listed)["epics"][0]["weight"], 3);
let shown = invoke(
&store.config,
&store,
"get_epic",
json!({"epic": "Platform"}),
);
assert!(!err(&shown), "{}", text(&shown));
assert_eq!(sc(&shown)["id"], json!(id));
assert!(text(&shown).contains("name: Platform"), "{}", text(&shown));
}
#[test]
fn t034_list_epics_returns_ids_child_and_member_counts() {
let (_tmp, store) = epic_repo(&[
("e-root01", "Root", None, None),
("e-mid002", "Mid", None, Some("e-root01")),
]);
write_epic_issue(&store, 1, "One", "todo", &["e-root01"]);
write_epic_issue(&store, 2, "Two", "todo", &["e-root01", "e-mid002"]);
let out = invoke(&store.config, &store, "list_epics", json!({}));
assert!(!err(&out), "{}", text(&out));
let epics = sc(&out)["epics"].as_array().unwrap();
assert_eq!(epics.len(), 2);
assert_eq!(epics[0]["id"], "e-root01");
assert_eq!(epics[0]["member_count"], 2);
assert_eq!(epics[0]["child_count"], 1);
assert_eq!(epics[1]["id"], "e-mid002");
assert_eq!(epics[1]["member_count"], 1);
assert_eq!(epics[1]["child_count"], 0);
let listing = text(&out);
let rows: Vec<&str> = listing.lines().collect();
assert_eq!(rows[0], "e-root01\tRoot\t2\t1");
}
#[test]
fn t034_epic_hierarchy_navigates_a_three_deep_chain() {
let (_tmp, store) = epic_repo(&[
("e-root01", "Root", None, None),
("e-mid002", "Mid", None, Some("e-root01")),
("e-leaf03", "Leaf", None, Some("e-mid002")),
]);
let out = invoke(
&store.config,
&store,
"epic_hierarchy",
json!({"epic": "e-leaf03"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["epic"], "e-leaf03");
assert_eq!(sc(&out)["parent"]["id"], "e-mid002");
assert_eq!(sc(&out)["depth"], 2);
let ancestors: Vec<&str> = sc(&out)["ancestors"]
.as_array()
.unwrap()
.iter()
.map(|a| a["id"].as_str().unwrap())
.collect();
assert_eq!(ancestors, vec!["e-root01", "e-mid002"], "root-first");
assert_eq!(sc(&out)["children"].as_array().unwrap().len(), 0);
assert!(sc(&out)["unresolved"].is_null());
assert!(text(&out).contains("depth: 2"), "{}", text(&out));
}
#[test]
fn t034_epic_health_returns_the_expected_counters() {
let (_tmp, store) = epic_repo(&[("e-aaa111", "Alpha", Some(5), None)]);
write_epic_issue(&store, 1, "Done", "done", &["e-aaa111"]);
write_epic_issue(&store, 2, "Open", "todo", &["e-aaa111"]);
let out = invoke(
&store.config,
&store,
"epic_health",
json!({"epic": "Alpha"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["epic_id"], "e-aaa111");
assert_eq!(sc(&out)["done"], 1);
assert_eq!(sc(&out)["total"], 2);
assert_eq!(sc(&out)["progress"], json!({"done": 1, "total": 2}));
assert_eq!(sc(&out)["child_count"], 0);
assert_eq!(sc(&out)["blocked"], 0);
assert_eq!(sc(&out)["weighted_progress"]["own_weight"], 5);
assert_eq!(sc(&out)["missing_references"].as_array().unwrap().len(), 0);
assert_eq!(epic_events(&store, 1).len(), 0);
}
#[test]
fn t034_missing_references_are_reported_exactly_as_the_service_does() {
let (_tmp, store) = epic_repo(&[("e-aaa111", "Alpha", None, None)]);
write_epic_issue(&store, 1, "One", "todo", &["e-aaa111", "e-gone99"]);
let listed = invoke(&store.config, &store, "list_epics", json!({}));
assert!(!err(&listed), "{}", text(&listed));
let missing = sc(&listed)["missing_references"].as_array().unwrap();
assert_eq!(missing.len(), 1);
assert_eq!(missing[0]["holder"], "issue #1");
assert_eq!(missing[0]["kind"], "epic");
assert_eq!(missing[0]["diagnostic_kind"], "missing_epic_reference");
assert_eq!(missing[0]["target"], "e-gone99");
let (_tmp, store) = epic_repo(&[("e-bbb222", "Beta", None, None)]);
let mut config = store.config.clone();
if let Some(record) = config.epics.get_mut("e-bbb222") {
record.projects = vec!["p-gone42".to_string()];
record.milestone = Some("Q9".to_string());
record.cycle = Some("Never".to_string());
}
let path = _tmp.path().join("tix.toml");
config.save(&path).unwrap();
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let store = Store {
issues_dir: store.issues_dir.clone(),
config,
};
let out = invoke(
&store.config,
&store,
"epic_health",
json!({"epic": "e-bbb222"}),
);
assert!(!err(&out), "{}", text(&out));
let mut kinds: Vec<&str> = sc(&out)["missing_references"]
.as_array()
.unwrap()
.iter()
.map(|m| m["diagnostic_kind"].as_str().unwrap())
.collect();
kinds.sort_unstable();
assert_eq!(
kinds,
vec![
"missing_cycle_reference",
"missing_milestone_reference",
"missing_project_reference"
]
);
}
#[test]
fn t034_epic_membership_lists_the_ordered_members() {
let (_tmp, store) = epic_repo(&[("e-aaa111", "Alpha", None, None)]);
write_epic_issue(&store, 2, "Two", "todo", &["e-aaa111"]);
write_epic_issue(&store, 1, "One", "todo", &["e-aaa111"]);
let out = invoke(
&store.config,
&store,
"epic_membership",
json!({"epic": "Alpha"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["epic_id"], "e-aaa111");
assert_eq!(sc(&out)["member_count"], 2);
let members: Vec<u64> = sc(&out)["members"]
.as_array()
.unwrap()
.iter()
.map(|m| m["issue"].as_u64().unwrap())
.collect();
assert_eq!(members, vec![1, 2], "ordered by issue number");
}
#[test]
fn t034_delete_epic_reports_the_impact_and_deletes_neither() {
let (_tmp, store) = epic_repo(&[
("e-aaa111", "Alpha", None, None),
("e-bbb222", "Beta", None, Some("e-aaa111")),
]);
write_epic_issue(&store, 1, "One", "todo", &["e-aaa111"]);
let before_issue = snap(&store);
let out = invoke(
&store.config,
&store,
"delete_epic",
json!({"epic": "Alpha", "confirm": true}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["id"], "e-aaa111");
let affected: Vec<u64> = sc(&out)["affected"]
.as_array()
.unwrap()
.iter()
.map(|m| m["issue"].as_u64().unwrap())
.collect();
assert_eq!(affected, vec![1]);
assert_eq!(sc(&out)["child_epics"][0]["id"], "e-bbb222");
assert!(
text(&out).contains("Note: 1 issue still references this epic"),
"{}",
text(&out)
);
assert_eq!(snap(&store), before_issue);
let still = invoke(&store.config, &store, "list_epics", json!({}));
let ids: Vec<&str> = sc(&still)["epics"]
.as_array()
.unwrap()
.iter()
.map(|e| e["id"].as_str().unwrap())
.collect();
assert_eq!(ids, vec!["e-bbb222"], "only the registry entry was removed");
let child = invoke(
&store.config,
&store,
"epic_hierarchy",
json!({"epic": "e-bbb222"}),
);
assert!(!err(&child), "{}", text(&child));
assert_eq!(sc(&child)["unresolved"]["recorded_parent"], "e-aaa111");
}
#[test]
fn t034_an_epic_created_in_the_session_resolves_immediately() {
let (_tmp, store) = epic_repo(&[]);
let created = invoke(
&store.config,
&store,
"create_epic",
json!({"name": "Session"}),
);
assert!(!err(&created), "{}", text(&created));
let id = sc(&created)["id"].as_str().unwrap().to_string();
let shown = invoke(&store.config, &store, "get_epic", json!({"epic": id}));
assert!(!err(&shown), "{}", text(&shown));
let listed = invoke(&store.config, &store, "list_epics", json!({}));
assert_eq!(sc(&listed)["epics"].as_array().unwrap().len(), 1);
}
#[test]
fn t034_epic_delete_without_confirm_is_refused_and_writes_nothing() {
let (_tmp, store) = epic_repo(&[("e-aaa111", "Alpha", None, None)]);
let before = snap(&store);
let out = invoke(
&store.config,
&store,
"delete_epic",
json!({"epic": "Alpha"}),
);
assert!(err(&out), "a delete without `confirm: true` is refused");
assert!(text(&out).contains("confirm"), "{}", text(&out));
assert_eq!(snap(&store), before);
}
#[test]
fn t035_every_epic_tool_is_declared_and_dispatched() {
let (_tmp, store) = epic_repo(&[]);
let declared_tools = list();
let declared: Vec<&str> = declared_tools
.iter()
.filter_map(|tool| tool["name"].as_str())
.collect();
for name in [
"list_epics",
"get_epic",
"create_epic",
"update_epic",
"delete_epic",
"epic_hierarchy",
"epic_membership",
"epic_health",
"epic_associate_issues",
"epic_dissociate_issues",
] {
assert!(
declared.contains(&name),
"`{name}` must be declared: {declared:?}"
);
assert!(
dispatch(&store.config, &store, name, &json!({})).is_some(),
"`{name}` must have a dispatch arm, not merely a descriptor"
);
}
}
#[test]
fn t034_a_task_labelled_issue_stays_an_ordinary_issue() {
let (_tmp, store) = epic_repo(&[("e-aaa111", "Alpha", None, None)]);
let out = invoke(
&store.config,
&store,
"create_issue",
json!({"title": "Taskish", "labels": "task", "epics": "e-aaa111"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["labels"], json!(["task"]));
assert_eq!(sc(&out)["epics"], json!(["e-aaa111"]));
assert!(sc(&out).get("task").is_none(), "no task entity is exposed");
assert!(sc(&out).get("work_item").is_none());
}
fn write_time_issue(store: &Store, number: u32, entries: &str) {
let contents = format!(
"---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"todo\"\nlabels: []\n\
priority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n{entries}---\n\nbody"
);
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::write(
store
.issues_dir
.join(format!("alice/2026-01-01-time-{number}.md")),
contents,
)
.unwrap();
}
fn time_entry_item(id: &str, minutes: i64, date: Option<&str>, author: &str) -> String {
let mut out = format!("- id: {id}\n duration_minutes: {minutes}\n");
if let Some(date) = date {
out.push_str(&format!(" date: \"{date}\"\n"));
}
out.push_str(&format!(" author: \"{author}\"\n"));
out
}
fn time_entries_block(items: &[String]) -> String {
format!("time_entries:\n{}", items.concat())
}
#[test]
fn t029_the_time_schemas_are_strict_and_only_the_delete_requires_confirm() {
let by_name = |name: &str| -> &ToolDescriptor {
registry()
.iter()
.find(|t| t.name == name)
.unwrap_or_else(|| panic!("`{name}` is not registered"))
};
for name in [
"time_show",
"time_history",
"time_total",
"time_add",
"time_edit",
"time_delete",
] {
let tool = by_name(name);
assert_eq!(tool.input_schema["type"], json!("object"), "{name}");
assert_eq!(
tool.input_schema["additionalProperties"],
json!(false),
"{name}"
);
let verb = name.trim_start_matches("time_").replace('_', " ");
assert!(
tool.description.contains(&format!("`tix time {verb}")),
"`{name}` must name the CLI verb it mirrors"
);
}
assert_eq!(
by_name("time_delete").input_schema["required"],
json!(["issue", "entry", "confirm"]),
"a destructive tool whose confirmation is optional is a tool clients call by accident"
);
assert_eq!(
by_name("time_add").input_schema["required"],
json!(["issue", "minutes", "author"])
);
assert_eq!(
by_name("time_edit").input_schema["required"],
json!(["issue", "entry"]),
"an edit names only the fields it changes, so no field is required"
);
for name in ["time_show", "time_history"] {
assert_eq!(by_name(name).input_schema["required"], json!(["issue"]));
}
assert!(
by_name("time_total").input_schema.get("required").is_none(),
"a total with no argument is the whole board's total"
);
assert_eq!(
by_name("time_total").input_schema["properties"]["by"]["enum"],
json!(["all", "issue", "assignee", "date"])
);
for name in [
"time_show",
"time_history",
"time_total",
"time_add",
"time_edit",
"time_delete",
] {
let properties = by_name(name).input_schema["properties"]
.as_object()
.cloned()
.unwrap_or_default();
for forbidden in [
"timer", "start", "stop", "timezone", "tz", "overlap", "billing", "rate", "currency",
"amount", "invoice", "payroll",
] {
assert!(
!properties.contains_key(forbidden),
"`{name}` must not declare `{forbidden}` (C-009)"
);
}
}
}
#[test]
fn t029_an_undeclared_time_argument_is_refused_by_name() {
let (_tmp, config, store) = make_repo();
for (tool, args, key) in [
(
"time_add",
json!({"issue": 1, "minutes": 5, "author": "ada", "timer": true}),
"timer",
),
(
"time_edit",
json!({"issue": 1, "entry": "e-1", "timezone": "Europe/Berlin"}),
"timezone",
),
("time_total", json!({"billing": "hourly"}), "billing"),
("time_show", json!({"issue": 1, "start": "now"}), "start"),
(
"time_delete",
json!({"issue": 1, "entry": "e-1", "confirm": true, "rate": 30}),
"rate",
),
(
"time_history",
json!({"issue": 1, "invoice": "x"}),
"invoice",
),
] {
let out = invoke(&config, &store, tool, args);
assert!(err(&out), "`{tool}` must refuse an undeclared argument");
assert!(
text(&out).contains(&format!("`{key}`")),
"`{tool}` must name the offending key: {}",
text(&out)
);
assert!(
text(&out).contains("no timer"),
"the refusal must say what the model is: {}",
text(&out)
);
}
}
#[test]
fn t029_a_recorded_zero_is_not_an_absent_value() {
assert_eq!(time_minutes_value(None).unwrap(), Value::Null);
assert_eq!(
time_minutes_value(Some(0)).unwrap(),
json!(0),
"a zero that was recorded is a number, not an absence"
);
assert_eq!(time_minutes_value(Some(90)).unwrap(), json!(90));
let message = time_minutes_value(Some(i128::MAX)).unwrap_err();
assert!(message.contains(&i128::MAX.to_string()), "{message}");
}
#[test]
fn t029_preserved_entry_metadata_is_nested_and_never_shadows_a_typed_field() {
let mut extensions = crate::time_tracking::TimeEntryExtensions::default();
extensions.insert("note", serde_yaml::Value::String("shadow".into()));
extensions.insert("custom", serde_yaml::Value::Bool(true));
let entry = TimeEntry {
id: "e-1".to_string(),
duration_minutes: Some(30),
date: Some("2026-03-01".to_string()),
author: "ada".to_string(),
note: Some("real".to_string()),
extensions,
};
let value = time_entry_value(3, &entry);
assert_eq!(value["note"], json!("real"));
assert_eq!(
value["extensions"]["note"],
json!("shadow"),
"a preserved key keeps its own place instead of shadowing the typed field"
);
assert_eq!(value["extensions"]["custom"], json!(true));
let mut keys: Vec<&str> = value
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(
keys,
[
"author",
"date",
"duration_minutes",
"extensions",
"id",
"issue",
"note",
],
"the entry object is exactly the declared fields plus a nested extensions map"
);
}
#[test]
fn t029_a_hand_written_document_that_collides_is_refused_by_name() {
let (_tmp, config, store) = make_repo();
write_time_issue(
&store,
1,
"time_entries:\n- id: e-1\n duration_minutes: 30\n author: \"ada\"\n \
note: \"real\"\n note: \"shadow\"\n",
);
let before = whole_store_bytes(&store);
let out = invoke(&config, &store, "time_show", json!({"issue": 1}));
assert!(err(&out), "a colliding document is refused, not repaired");
assert!(
text(&out).contains("time_entries[0]") && text(&out).contains("note"),
"the refusal names the entry and the field: {}",
text(&out)
);
assert_eq!(
whole_store_bytes(&store),
before,
"a refused read writes nothing"
);
}
#[test]
fn t031_nothing_and_unknown_are_answers_and_neither_is_zero() {
let (_tmp, config, store) = make_repo();
write_time_issue(&store, 1, "");
let out = invoke(&config, &store, "time_total", json!({"issue": 1}));
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["state"], json!("nothing"));
assert_eq!(sc(&out)["total_minutes"], Value::Null);
assert_eq!(sc(&out)["entry_count"], json!(0));
assert!(text(&out).contains("total_minutes: -"));
write_time_issue(
&store,
2,
&time_entries_block(&[time_entry_item("e-bad", -5, Some("2026-03-01"), "ada")]),
);
let out = invoke(&config, &store, "time_total", json!({"issue": 2}));
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["state"], json!("unknown"));
assert_eq!(sc(&out)["total_minutes"], Value::Null);
assert_eq!(sc(&out)["malformed_count"], json!(1));
assert_eq!(sc(&out)["is_partial"], json!(true));
assert!(text(&out).contains("total_minutes: -"));
assert!(
sc(&out)["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|finding| finding["kind"] == json!("invalid_duration")
&& finding["entry_id"] == json!("e-bad")),
"the malformed entry is named: {}",
sc(&out)["diagnostics"]
);
let out = invoke(&config, &store, "time_show", json!({"issue": 1}));
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["state"], json!("nothing"));
}
#[test]
fn t029_a_refused_scope_refuses_instead_of_reporting_a_total() {
let (_tmp, config, store) = make_repo();
write_time_issue(&store, 1, "");
let before = whole_store_bytes(&store);
let out = invoke(&config, &store, "time_total", json!({"from": "not-a-date"}));
assert!(err(&out), "an invalid bound is a refusal, not a total");
assert!(
text(&out).contains("E-tix-time") && text(&out).contains("kind: invalid_date"),
"{}",
text(&out)
);
assert!(
text(&out).contains("help:"),
"a refusal carries a remedy: {}",
text(&out)
);
assert_eq!(
whole_store_bytes(&store),
before,
"a refused read writes nothing"
);
let out = invoke(&config, &store, "time_total", json!({"by": "bogus"}));
assert!(err(&out));
assert!(
text(&out).contains("unknown --by dimension") && text(&out).contains("`assignee`"),
"{}",
text(&out)
);
}
#[test]
fn t032_the_time_tools_are_declared_and_dispatched() {
let (_tmp, config, store) = make_repo();
let declared_tools = list();
let declared: Vec<&str> = declared_tools
.iter()
.filter_map(|tool| tool["name"].as_str())
.collect();
for name in [
"time_show",
"time_history",
"time_total",
"time_add",
"time_edit",
"time_delete",
] {
assert!(declared.contains(&name), "`{name}` must be declared");
assert!(
dispatch(&config, &store, name, &json!({})).is_some(),
"`{name}` must have a dispatch arm, not merely a descriptor"
);
}
}
#[test]
fn t030_a_time_mutation_refuses_a_blank_reason_and_a_negative_duration() {
let (_tmp, config, store) = make_repo();
write_time_issue(
&store,
1,
&time_entries_block(&[time_entry_item("e-1", 30, None, "ada")]),
);
let before = whole_store_bytes(&store);
let out = invoke(
&config,
&store,
"time_edit",
json!({"issue": 1, "entry": "e-1", "minutes": 45}),
);
assert!(err(&out));
assert!(text(&out).contains("requires `--reason`"), "{}", text(&out));
let out = invoke(
&config,
&store,
"time_add",
json!({"issue": 1, "minutes": -5, "author": "ada"}),
);
assert!(err(&out));
assert!(
text(&out).contains("duration_minutes"),
"the shared validator's own field name: {}",
text(&out)
);
let out = invoke(
&config,
&store,
"time_edit",
json!({"issue": 1, "entry": "e-1", "date": "2026-03-01", "clear_date": true, "reason": "x"}),
);
assert!(err(&out));
assert!(text(&out).contains("clear_date"), "{}", text(&out));
assert_eq!(
whole_store_bytes(&store),
before,
"none of the three refusals may write"
);
}
#[test]
fn t030_a_time_delete_without_confirmation_refuses_before_reading_and_writes_nothing() {
let (_tmp, config, store) = make_repo();
write_time_issue(
&store,
1,
&time_entries_block(&[time_entry_item("e-1", 30, None, "ada")]),
);
let before = whole_store_bytes(&store);
let out = invoke(
&config,
&store,
"time_delete",
json!({"issue": 1, "entry": "e-1", "reason": "no"}),
);
assert!(err(&out));
assert!(
text(&out).contains(cmd_time::TIME_DELETE_CONFIRMATION),
"the CLI's own sentence: {}",
text(&out)
);
let out = invoke(
&config,
&store,
"time_delete",
json!({"issue": 1, "entry": "e-1", "reason": "no", "confirm": false}),
);
assert!(err(&out), "a false confirmation is not a confirmation");
assert_eq!(
whole_store_bytes(&store),
before,
"an unconfirmed delete must not touch the store"
);
}
#[test]
fn t030_a_time_mutation_carries_the_services_outcome_and_appends_one_event() {
let (_tmp, config, store) = make_repo();
write_time_issue(&store, 1, "");
let out = invoke(
&config,
&store,
"time_add",
json!({"issue": 1, "minutes": 90, "author": "ada", "date": "2026-03-01", "note": "kickoff"}),
);
assert!(!err(&out), "{}", text(&out));
let id = sc(&out)["entry_id"].as_str().unwrap().to_string();
assert!(id.starts_with("e-"), "{id}");
assert_eq!(sc(&out)["status"], json!("created"));
assert_eq!(sc(&out)["changed"], json!(true));
assert_eq!(sc(&out)["before"], Value::Null);
assert_eq!(sc(&out)["after"]["duration_minutes"], json!(90));
assert_eq!(
sc(&out)["history"]["kind"],
json!("time_entry_created"),
"exactly one attributable event, returned with the outcome"
);
assert!(text(&out).starts_with(&format!(
"Added {id} to #1: 90m on 2026-03-01 by ada: kickoff"
)));
let out = invoke(&config, &store, "time_show", json!({"issue": 1}));
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["entries"][0]["id"], json!(id));
assert_eq!(sc(&out)["total_minutes"], json!(90));
assert_eq!(sc(&out)["state"], json!("recorded"));
let out = invoke(
&config,
&store,
"time_edit",
json!({"issue": 1, "entry": id, "minutes": 45, "reason": "overcounted"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["status"], json!("updated"));
assert_eq!(sc(&out)["before"]["duration_minutes"], json!(90));
assert_eq!(sc(&out)["after"]["duration_minutes"], json!(45));
assert_eq!(sc(&out)["after"]["date"], json!("2026-03-01"));
assert_eq!(sc(&out)["history"]["kind"], json!("time_entry_updated"));
let before = whole_store_bytes(&store);
let out = invoke(
&config,
&store,
"time_edit",
json!({"issue": 1, "entry": id, "minutes": 45, "reason": "again"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["status"], json!("unchanged"));
assert_eq!(sc(&out)["changed"], json!(false));
assert_eq!(sc(&out)["history"], Value::Null);
assert_eq!(whole_store_bytes(&store), before, "a no-op writes nothing");
let out = invoke(
&config,
&store,
"time_delete",
json!({"issue": 1, "entry": id, "reason": "duplicate", "confirm": true}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["status"], json!("deleted"));
assert_eq!(sc(&out)["after"], Value::Null);
assert_eq!(sc(&out)["history"]["kind"], json!("time_entry_deleted"));
let out = invoke(&config, &store, "time_history", json!({"issue": 1}));
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["current"], json!(0));
assert_eq!(sc(&out)["deleted_count"], json!(1));
assert_eq!(sc(&out)["corrected_count"], json!(1));
assert_eq!(sc(&out)["issues"][0]["deletions"][0]["entry_id"], json!(id));
let out = invoke(&config, &store, "time_show", json!({"issue": 1}));
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["state"], json!("nothing"));
assert_eq!(sc(&out)["entries"], json!([]));
}
#[test]
fn t031_a_breakdown_answers_from_the_shared_groups() {
let (_tmp, config, store) = make_repo();
write_time_issue(
&store,
1,
&time_entries_block(&[
time_entry_item("e-1", 90, Some("2026-03-01"), "ada"),
time_entry_item("e-2", 30, None, "bob"),
]),
);
write_time_issue(
&store,
2,
&time_entries_block(&[time_entry_item("e-3", 15, Some("2026-03-02"), "ada")]),
);
let out = invoke(&config, &store, "time_total", json!({"by": "assignee"}));
assert!(!err(&out), "{}", text(&out));
let groups = sc(&out)["groups"].as_array().unwrap();
let labels: Vec<&str> = groups
.iter()
.map(|group| group["group"].as_str().unwrap())
.collect();
assert_eq!(labels, ["@ada", "@bob"]);
let ada = &groups[0];
assert_eq!(ada["total_minutes"], json!(105));
assert_eq!(
ada["scope"],
json!("all"),
"a group carries the scope's own filter; its identity is the group label"
);
assert_eq!(sc(&out)["total_minutes"], json!(135));
assert_eq!(sc(&out)["undated_count"], json!(0));
assert_eq!(groups[1]["undated_count"], json!(0));
assert!(text(&out).starts_with("group: all\nscope: all\nstate: recorded\ntotal_minutes: 135"));
assert!(text(&out).contains("\n\ngroup: @ada\n"));
}