use std::fs;
use std::path::Path;
use serde_json::{Value, json};
use tempfile::TempDir;
use tix::bulk::metadata;
use tix::bulk::{IssuePatch, PatchValue, Selection, SelectionScope, SelectionSource};
use tix::config::Config;
use tix::mcp::{protocol, tools};
use tix::model::Issue;
use tix::storage::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(),
..Config::default()
};
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 = tix::slug::slugify(title);
let segment = store
.list()
.unwrap()
.into_iter()
.next()
.and_then(|i| i.filename.split('/').next().map(str::to_string))
.unwrap_or_else(|| "alice".to_string());
fs::create_dir_all(store.issues_dir.join(&segment)).unwrap();
fs::write(
store
.issues_dir
.join(format!("{segment}/2026-01-01-{slug}.md")),
contents,
)
.unwrap();
}
fn seed_with_terminal_column() -> (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 dir_display = dir.display();
let toml = format!(
"issues_dir = \"{dir_display}\"\n\n[kanban.default]\ncolumns = [\"todo\", \"in progress\", \"in review\", \"done\", \"abandoned\"]\ndone_column = \"done\"\ncategory_map = {{ todo = \"unstarted\", \"in progress\" = \"active\", \"in review\" = \"active\", done = \"completed\", abandoned = \"canceled\" }}\n"
);
fs::write(&config_path, toml).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let store = Store {
issues_dir: dir,
config: config.clone(),
};
for (number, title, status) in [
(1u32, "Alpha", "todo"),
(2, "Beta", "todo"),
(3, "Gamma", "in progress"),
(4, "Delta", "in review"),
] {
write_issue(&store, number, title, status);
}
(tmp, config, store)
}
fn seed() -> (TempDir, Config, Store) {
let (tmp, config, store) = make_repo();
for (number, title, status) in [
(1u32, "Alpha", "todo"),
(2, "Beta", "todo"),
(3, "Gamma", "in progress"),
(4, "Delta", "in review"),
] {
write_issue(&store, number, title, status);
}
(tmp, config, store)
}
fn call(config: &Config, store: &Store, name: &str, args: Value) -> Value {
tools::call(config, store, protocol::Id::Number(1), name, &args)
.expect("a well-formed call must not fail at the protocol level")
}
fn is_error(value: &Value) -> bool {
value.get("isError").and_then(Value::as_bool) == Some(true)
}
fn content_text(value: &Value) -> String {
value
.get("content")
.and_then(Value::as_array)
.map(|rows| {
rows.iter()
.filter_map(|r| r.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default()
}
fn change<'a>(item: &'a Value, field: &str) -> &'a Value {
item["changes"]
.as_array()
.expect("every item carries a change list")
.iter()
.find(|c| c["field"] == json!(field))
.unwrap_or(&Value::Null)
}
fn structured(value: &Value) -> &Value {
value
.get("structuredContent")
.expect("every batch tool returns a structured payload")
}
fn tree_snapshot(dir: &Path) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
for entry in fs::read_dir(¤t).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_to_string(&path).unwrap()));
}
}
}
out.sort();
out
}
fn statuses(store: &Store) -> Vec<(u32, String)> {
let mut rows: Vec<(u32, String)> = store
.list()
.unwrap()
.into_iter()
.map(|i| {
(i.number, i.status.to_string())
})
.collect();
rows.sort();
rows
}
#[test]
fn the_whitelist_and_the_flag_struct_agree_in_both_directions() {
assert_eq!(
metadata::disagreements_with_flags(),
Vec::<String>::new(),
"the whitelist and PatchFlags describe the same fields"
);
}
#[test]
fn the_whitelist_carries_exactly_the_nine_contract_fields() {
assert_eq!(
metadata::field_names(),
vec![
"status",
"priority",
"assignee",
"labels",
"start-date",
"due-date",
"estimate",
"cycles",
"milestones"
]
);
for absent in ["title", "body", "number", "created", "updated", "commit"] {
assert!(
!metadata::is_supported_field(absent),
"`{absent}` must not be a batch field"
);
}
}
#[test]
fn only_the_clearable_fields_are_marked_clearable() {
let clearable = metadata::clearable_field_names();
assert!(
!clearable.contains(&"status"),
"status is not clearable: a batch that empties a column has no target column"
);
for name in [
"priority",
"assignee",
"labels",
"estimate",
"cycles",
"milestones",
] {
assert!(clearable.contains(&name), "`{name}` is clearable");
}
for name in tix::commands::bulk::PatchFlags::clearable() {
assert!(
clearable.contains(name),
"`{name}` is clearable in PatchFlags, so it is clearable in the projection"
);
}
}
#[test]
fn the_outcome_vocabulary_is_exactly_the_four_shared_words() {
assert_eq!(
metadata::OUTCOME_STATUSES,
&["applied", "no-op", "rejected", "failed"]
);
let applied = tix::bulk::ItemOutcome::Applied {
filename: "a.md".into(),
};
let noop = tix::bulk::ItemOutcome::NoOp;
let rejected = tix::bulk::ItemOutcome::Rejected("why".into());
let failed = tix::bulk::ItemOutcome::Failed {
item: tix::bulk::plan::ItemDiagnostic {
issue: 1,
field: "status",
message: "why".into(),
},
error: "boom".into(),
};
for (outcome, expected) in [
(applied, "applied"),
(noop, "no-op"),
(rejected, "rejected"),
(failed, "failed"),
] {
assert_eq!(
outcome.status(),
expected,
"each outcome reports its own word, not another word from the same list"
);
assert!(
metadata::OUTCOME_STATUSES.contains(&outcome.status()),
"and that word is in the shared vocabulary"
);
}
let (_tmp, _config, store) = seed();
let plan = tix::commands::bulk::build(
&store,
Selection::from_issues(
store.list().unwrap(),
SelectionSource::Explicit,
SelectionScope::Active,
),
tix::commands::bulk::PatchFlags {
status: Some("in review".into()),
..Default::default()
}
.to_patch()
.unwrap(),
)
.unwrap();
for item in &plan.items {
assert!(
metadata::OUTCOME_STATUSES.contains(&item.status()),
"`{}` is in the shared vocabulary",
item.status()
);
}
let refused = tix::bulk::plan::ItemPlan {
number: 9,
title: "Refused".into(),
changes: Default::default(),
path_change: None,
history: Vec::new(),
diagnostic: Some(tix::bulk::plan::ItemDiagnostic {
issue: 9,
field: "status",
message: "no".into(),
}),
wip: None,
};
assert_eq!(
refused.status(),
"rejected",
"a plan-time diagnostic is `rejected`"
);
let clean = tix::bulk::plan::ItemPlan {
number: 9,
title: "Clean".into(),
changes: Default::default(),
path_change: None,
history: Vec::new(),
diagnostic: None,
wip: None,
};
assert_ne!(
clean.status(),
refused.status(),
"and a plan with no diagnostic does not report the same word"
);
}
#[test]
fn every_understood_operation_is_available_and_performable() {
for op in metadata::BulkOperation::ALL {
assert_eq!(
op.availability(),
"available",
"{} is available",
op.as_str()
);
assert!(op.is_applicable(), "{} is performable", op.as_str());
}
}
#[test]
fn an_unsupported_operation_or_field_names_one_of_the_seven_diagnostics() {
let unknown_op = metadata::check_operation("frobnicate")
.unwrap_err()
.to_string();
assert!(
unknown_op.contains(metadata::UNSUPPORTED_OPERATION),
"an unknown operation names the diagnostic: {unknown_op}"
);
assert!(
unknown_op.contains("edit, link, unlink, archive"),
"and lists the operations that exist: {unknown_op}"
);
let unknown_field = metadata::check_field("title").unwrap_err().to_string();
assert!(
unknown_field.contains(metadata::UNSUPPORTED_OPERATION),
"an unlisted field reuses the same diagnostic: {unknown_field}"
);
assert!(
unknown_field.contains("labels") && !unknown_field.contains("title,"),
"and lists the whitelist without offering the field back: {unknown_field}"
);
assert!(
metadata::check_operation("archive").is_ok(),
"a well-formed archive request passes the request boundary"
);
assert!(
metadata::check_applicable(metadata::BulkOperation::Archive).is_ok(),
"and the apply-time gate admits it: archive is performable"
);
assert!(metadata::check_operation("edit").is_ok());
assert!(metadata::check_applicable(metadata::BulkOperation::Edit).is_ok());
assert!(metadata::check_field("labels").is_ok());
}
#[test]
fn the_capability_payload_is_the_one_the_schema_was_generated_from() {
let caps = metadata::capabilities();
assert_eq!(caps.operations.len(), 4);
assert_eq!(caps.fields.len(), 9);
assert!(caps.preview_is_read_only, "C-004");
assert!(caps.apply_requires_confirmation, "C-004");
assert_eq!(caps.selection_grammar.len(), 3, "C-003: no new grammar");
let link = caps
.operations
.iter()
.find(|o| o.name == "link")
.expect("link is listed");
assert_eq!(link.requires, vec!["target", "kind"]);
let edit = caps
.operations
.iter()
.find(|o| o.name == "edit")
.expect("edit is listed");
assert!(edit.requires.is_empty());
}
#[test]
fn t041_the_projection_states_the_progress_and_scale_answer() {
let caps = metadata::capabilities();
assert!(
caps.progress.is_some(),
"the projection states how a large selection behaves"
);
let progress = caps.progress.as_ref().unwrap();
assert_eq!(progress.preview_selection_limit, 10_000);
assert!(
progress.reports_bounded_progress,
"and says whether bounded progress is reported"
);
assert!(
!progress.fallback.is_empty(),
"and what a client sees when the limit is exceeded"
);
}
#[test]
fn t041_ten_thousand_items_plan_in_memory_with_no_vacuous_pass() {
let (_tmp, _config, store) = seed();
let templates: Vec<Issue> = store.list().unwrap();
let issues: Vec<Issue> = (0..10_000u32)
.map(|i| {
let mut issue = templates[(i as usize) % templates.len()].clone();
issue.number = i + 1;
issue.title = format!("Issue{}", i + 1);
issue
})
.collect();
let selection = Selection::from_issues(issues, SelectionSource::Filter, SelectionScope::Active);
let started = std::time::Instant::now();
let plan = tix::commands::bulk::build(
&store,
selection,
IssuePatch {
status: PatchValue::Set("in review".to_string()),
..IssuePatch::default()
},
)
.unwrap();
let elapsed = started.elapsed();
assert_eq!(plan.items.len(), 10_000, "every item was planned");
let statuses: Vec<&str> = plan.items.iter().map(|i| i.status()).collect();
assert_eq!(statuses.iter().filter(|s| **s == "applied").count(), 7_500);
assert_eq!(
statuses.iter().filter(|s| **s == "no-op").count(),
2_500,
"the clones already in `in review` are no-ops"
);
assert_eq!(
statuses.iter().filter(|s| **s == "rejected").count(),
0,
"`in review` -> `in review` is a no-op, not a refusal"
);
assert!(
elapsed.as_secs_f64() < 2.0,
"a 10,000-item preview excluding file I/O took {:.3}s, over the 2s budget",
elapsed.as_secs_f64()
);
}
#[test]
fn the_three_batch_tools_are_registered_with_strict_schemas() {
let list = tools::list();
for name in ["bulk_preview", "bulk_apply", "bulk_capabilities"] {
let descriptor = list
.iter()
.find(|t| t.get("name").and_then(Value::as_str) == Some(name))
.unwrap_or_else(|| panic!("{name} is registered"));
let schema = &descriptor["inputSchema"];
assert_eq!(
schema["additionalProperties"],
json!(false),
"{name} refuses an unlisted argument, which is what makes the whitelist \
the only way in"
);
assert!(
schema["properties"]["title"].is_null(),
"{name} does not advertise a `title` argument: C-008 makes an unlisted \
field an escalation trigger and a schema entry is an offer"
);
}
let apply = list
.iter()
.find(|t| t["name"] == json!("bulk_apply"))
.unwrap();
for name in metadata::field_names() {
assert!(
!apply["inputSchema"]["properties"][name].is_null(),
"bulk_apply advertises `{name}`"
);
}
}
#[test]
fn the_apply_schema_requires_confirmation_and_the_preview_does_not() {
let list = tools::list();
let apply = list
.iter()
.find(|t| t["name"] == json!("bulk_apply"))
.unwrap();
let preview = list
.iter()
.find(|t| t["name"] == json!("bulk_preview"))
.unwrap();
let apply_required: Vec<&str> = apply["inputSchema"]["required"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(
apply_required.contains(&"confirm"),
"an apply requires `confirm`; an optional boolean is a boolean a client can \
forget, and a forgotten boolean on a write tool is what C-004 prevents"
);
let preview_required: Vec<&str> = preview["inputSchema"]["required"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(
!preview_required.contains(&"confirm"),
"a preview takes no confirmation: it writes nothing, so asking for one would \
teach a client that `confirm` is a formality"
);
assert!(
!preview_required.contains(&"select"),
"naming `select` as required would make a filter-only request a protocol error \
instead of the shared builder's own message"
);
}
#[test]
fn t036_an_apply_without_confirm_writes_nothing_and_says_why() {
let (_tmp, config, store) = seed();
let before = tree_snapshot(&store.issues_dir);
let result = call(
&config,
&store,
"bulk_apply",
json!({ "operation": "edit", "select": "1", "status": "in review" }),
);
assert!(is_error(&result), "a missing confirmation is refused");
let text = content_text(&result);
assert!(
text.contains("confirm") && text.contains("bulk_preview"),
"the refusal names the missing argument and the read-only way to see the \
plan: {text}"
);
assert_eq!(
tree_snapshot(&store.issues_dir),
before,
"and wrote nothing"
);
assert_eq!(statuses(&store)[0].1, "todo");
}
#[test]
fn t036_a_preview_returns_the_plan_and_writes_nothing() {
let (_tmp, config, store) = seed();
let before = tree_snapshot(&store.issues_dir);
let result = call(
&config,
&store,
"bulk_preview",
json!({ "operation": "edit", "select": "1,2", "status": "in review" }),
);
assert!(!is_error(&result), "{}", content_text(&result));
let payload = structured(&result);
assert_eq!(
payload["applied"],
json!(false),
"a preview applies nothing"
);
let selection = payload["selection"].as_array().unwrap();
assert_eq!(selection.len(), 2);
assert_eq!(
selection[0]["number"],
json!(1),
"the selection keeps its order"
);
assert_eq!(selection[1]["number"], json!(2));
for item in payload["items"].as_array().unwrap() {
assert_eq!(
item["status"],
json!("applied"),
"each item's shared status"
);
let change = change(item, "status");
assert_eq!(change["from"], json!("todo"), "and its before/after values");
assert_eq!(change["to"], json!("in review"));
}
assert_eq!(
tree_snapshot(&store.issues_dir),
before,
"C-004: a preview leaves the tree byte-identical"
);
}
#[test]
fn t036_a_confirmed_apply_writes_and_reports_one_status_per_item() {
let (_tmp, config, store) = seed();
let result = call(
&config,
&store,
"bulk_apply",
json!({ "operation": "edit", "select": "1,2", "status": "in review", "confirm": true }),
);
assert!(!is_error(&result), "{}", content_text(&result));
let payload = structured(&result);
let outcomes = payload["outcomes"].as_array().unwrap();
assert_eq!(outcomes.len(), 2);
assert_eq!(outcomes[0]["number"], json!(1));
assert_eq!(outcomes[1]["number"], json!(2));
for outcome in outcomes {
assert_eq!(outcome["status"], json!("applied"));
}
assert_eq!(payload["applied"], json!(2));
assert_eq!(
statuses(&store)
.into_iter()
.map(|(_, s)| s)
.collect::<Vec<_>>(),
vec!["in review", "in review", "in progress", "in review"]
);
}
#[test]
fn t036_a_mixed_batch_reports_each_item_and_keeps_the_successes() {
let (_tmp, config, store) = seed();
let result = call(
&config,
&store,
"bulk_apply",
json!({
"operation": "edit",
"select": "1,2",
"labels": "bug",
"estimate": "",
"confirm": true
}),
);
assert!(!is_error(&result), "{}", content_text(&result));
let payload = structured(&result);
assert_eq!(payload["applied"], json!(2));
for issue in store.list().unwrap() {
if issue.number == 1 || issue.number == 2 {
assert_eq!(
issue.labels,
vec!["bug".to_string()],
"#{} relabelled",
issue.number
);
assert_eq!(
issue.estimate, None,
"#{}'s estimate is untouched: a blank value is absent, not an empty \
string written over the old one",
issue.number
);
}
}
}
#[test]
fn t036_a_rejected_item_does_not_roll_back_the_applied_ones() {
let (_tmp, config, store) = seed_with_terminal_column();
let moved = call(
&config,
&store,
"move_issue",
json!({ "issue": 4, "column": "abandoned", "reason": "seeded for the mixed case" }),
);
assert!(!is_error(&moved), "{}", content_text(&moved));
let result = call(
&config,
&store,
"bulk_apply",
json!({ "operation": "edit", "select": "4,1", "status": "done", "confirm": true }),
);
let payload = structured(&result);
let outcomes = payload["outcomes"].as_array().unwrap();
assert_eq!(outcomes.len(), 2, "NFR-002: one status per selected issue");
let by_number = |n: u64| {
outcomes
.iter()
.find(|o| o["number"] == json!(n))
.unwrap_or_else(|| panic!("#{n} has an outcome"))
};
assert_eq!(by_number(1)["status"], json!("applied"), "#1 moved");
assert_eq!(
by_number(4)["status"],
json!("rejected"),
"#4 is in a terminal column and cannot move without a reason"
);
assert_eq!(
statuses(&store)
.into_iter()
.find(|(n, _)| *n == 1)
.map(|(_, s)| s),
Some("done".to_string()),
"C-005: the successful item stands"
);
assert_eq!(
statuses(&store)
.into_iter()
.find(|(n, _)| *n == 4)
.map(|(_, s)| s),
Some("abandoned".to_string()),
"and the refused one is untouched"
);
}
#[test]
fn t036_a_retry_is_idempotent_and_reports_the_no_op() {
let (_tmp, config, store) = seed();
let args =
json!({ "operation": "edit", "select": "1", "status": "in review", "confirm": true });
let first = call(&config, &store, "bulk_apply", args.clone());
assert_eq!(structured(&first)["applied"], json!(1));
let after_first = tree_snapshot(&store.issues_dir);
let second = call(&config, &store, "bulk_apply", args);
let payload = structured(&second);
assert_eq!(
payload["outcomes"][0]["status"],
json!("no-op"),
"the retry is reported as a no-op, not as a second write"
);
assert_eq!(payload["applied"], json!(0));
assert_eq!(
tree_snapshot(&store.issues_dir),
after_first,
"and nothing was written the second time"
);
}
#[test]
fn t036_a_pair_needs_a_target_and_a_kind_and_reports_each_item() {
let (_tmp, config, store) = seed();
let missing = call(
&config,
&store,
"bulk_preview",
json!({ "operation": "link", "select": "1,2" }),
);
assert!(is_error(&missing), "a pair with no target is refused");
assert!(content_text(&missing).contains("target"));
let bad_kind = call(
&config,
&store,
"bulk_preview",
json!({ "operation": "link", "select": "1", "target": "3", "type": "best-friends" }),
);
assert!(is_error(&bad_kind), "an unknown kind is refused");
let text = content_text(&bad_kind);
assert!(
text.contains("blocks") && text.contains("related"),
"and the refusal is the **service's** message, listing its own vocabulary: {text}"
);
let result = call(
&config,
&store,
"bulk_apply",
json!({
"operation": "link",
"select": "1,2",
"target": "3",
"type": "related",
"confirm": true
}),
);
assert!(!is_error(&result), "{}", content_text(&result));
let payload = structured(&result);
let outcomes = payload["outcomes"].as_array().unwrap();
assert_eq!(outcomes.len(), 2, "one outcome per selected issue");
for outcome in outcomes {
assert_eq!(outcome["status"], json!("applied"), "{outcome}");
let files = outcome["files"]
.as_array()
.expect("a pair names the files it wrote");
assert_eq!(
files.len(),
1,
"a link writes the from-issue's file: {outcome}"
);
let title = outcome["title"].as_str().unwrap().to_lowercase();
assert!(
files[0].as_str().unwrap().contains(&title),
"and it is {}'s file, not another issue's: {outcome}",
outcome["title"]
);
}
let unlink = call(
&config,
&store,
"bulk_apply",
json!({
"operation": "unlink", "select": "1", "target": "3", "type": "related",
"confirm": true
}),
);
let unlinked = structured(&unlink)["outcomes"][0]["files"]
.as_array()
.expect("an unlink names the files it wrote");
assert!(
!unlinked.is_empty(),
"an unlink names at least the file it wrote: {unlink}"
);
assert!(
unlinked
.iter()
.any(|f| f.as_str().unwrap().contains("alpha")),
"and it is Alpha's file: {unlink}"
);
}
#[test]
fn t036_a_pair_retry_is_a_no_op_and_an_unlink_removes_the_edge() {
let (_tmp, config, store) = seed();
let link = json!({
"operation": "link", "select": "1", "target": "3", "type": "related", "confirm": true
});
assert_eq!(
structured(&call(&config, &store, "bulk_apply", link.clone()))["applied"],
json!(1)
);
let again = call(&config, &store, "bulk_apply", link);
assert_eq!(
structured(&again)["outcomes"][0]["status"],
json!("no-op"),
"C-006: re-linking is reported, not repeated"
);
let unlink = call(
&config,
&store,
"bulk_apply",
json!({
"operation": "unlink", "select": "1", "target": "3", "type": "related",
"confirm": true
}),
);
assert_eq!(
structured(&unlink)["outcomes"][0]["status"],
json!("applied")
);
let after = call(
&config,
&store,
"bulk_apply",
json!({
"operation": "unlink", "select": "1", "target": "3", "type": "related",
"confirm": true
}),
);
assert_eq!(
structured(&after)["outcomes"][0]["status"],
json!("no-op"),
"and unlinking an absent edge is a reported no-op too"
);
}
#[test]
fn t036_an_archive_apply_delegates_to_the_store_and_names_each_issue() {
let (_tmp, config, store) = seed();
let before = tree_snapshot(&store.issues_dir);
let result = call(
&config,
&store,
"bulk_apply",
json!({ "operation": "archive", "select": "1,2", "confirm": true }),
);
let payload = structured(&result);
assert_eq!(payload["applied"], json!(2));
assert_eq!(payload["no_op"], json!(0));
assert_eq!(payload["rejected"], json!(0));
let outcomes = payload["outcomes"].as_array().unwrap();
assert_eq!(outcomes.len(), 2, "one outcome per selected issue");
for (outcome, number) in outcomes.iter().zip([1u64, 2]) {
assert_eq!(outcome["status"], json!("applied"), "{outcome}");
let line = outcome["line"].as_str().unwrap_or_default();
assert!(
line.contains(&format!("#{number}")) && line.contains("applied"),
"the outcome names the issue it archived: {line}"
);
}
let text = content_text(&result);
assert!(text.contains("2 of 2 applied"), "{text}");
let after = tree_snapshot(&store.issues_dir);
let dot = after
.iter()
.filter(|(path, _)| {
Path::new(path)
.file_name()
.is_some_and(|b| b.to_string_lossy().starts_with('.'))
})
.count();
assert_eq!(dot, 2, "two files gained the archived marker: {after:?}");
assert_eq!(
after.len(),
before.len(),
"rename in place, no add or remove"
);
let again = call(
&config,
&store,
"bulk_apply",
json!({ "operation": "archive", "select": "1,2", "confirm": true }),
);
let payload = structured(&again);
assert_eq!(payload["no_op"], json!(2));
assert_eq!(payload["applied"], json!(0));
assert_eq!(
tree_snapshot(&store.issues_dir),
after,
"a no-op writes no bytes"
);
}
#[test]
fn t036_a_selection_grammar_error_is_the_shared_ones_message() {
let (_tmp, config, store) = seed();
for args in [
json!({ "operation": "edit", "status": "in review" }),
json!({ "operation": "edit", "select": "1", "filter": "status:todo", "status": "in review" }),
] {
let result = call(&config, &store, "bulk_preview", args);
assert!(is_error(&result), "zero or two grammars is refused");
let text = content_text(&result);
assert!(
text.contains("--select") && text.contains("--filter") && text.contains("--search"),
"and the refusal is the **shared** builder's message, not a schema's: {text}"
);
}
}
#[test]
fn t036_a_filter_selection_resolves_through_the_existing_grammar() {
let (_tmp, config, store) = seed();
let result = call(
&config,
&store,
"bulk_preview",
json!({ "operation": "edit", "filter": "status:todo", "status": "in review" }),
);
assert!(!is_error(&result), "{}", content_text(&result));
let payload = structured(&result);
assert_eq!(
payload["selection"].as_array().unwrap().len(),
2,
"status:todo is the two issues in `todo`"
);
}
#[test]
fn t036_a_cleared_field_is_reported_as_a_change_to_empty() {
let (_tmp, config, store) = seed();
call(
&config,
&store,
"update_issue",
json!({ "issue": 1, "labels": "bug" }),
);
let result = call(
&config,
&store,
"bulk_preview",
json!({ "operation": "edit", "select": "1", "clear": "labels" }),
);
assert!(!is_error(&result), "{}", content_text(&result));
let change = change(&structured(&result)["items"][0], "labels");
assert_eq!(
change["from"],
json!("bug"),
"and the before value is the current one"
);
assert_eq!(
change["to"],
json!(""),
"a `Clear`'s `to` is the empty string on every surface, so a client reading \
`to` unconditionally gets a string"
);
}
#[test]
fn t036_a_second_clear_is_refused_rather_than_appended() {
let (_tmp, config, store) = seed();
for field in ["title", "status"] {
let result = call(
&config,
&store,
"bulk_preview",
json!({ "operation": "edit", "select": "1", "clear": field }),
);
assert!(is_error(&result), "clearing `{field}` is refused");
}
let ok = call(
&config,
&store,
"bulk_preview",
json!({ "operation": "edit", "select": "1", "clear": "labels" }),
);
assert!(!is_error(&ok), "{}", content_text(&ok));
assert_eq!(
change(&structured(&ok)["items"][0], "labels")["to"],
json!("")
);
}
#[test]
fn t036_clearing_a_field_the_contract_does_not_own_is_refused() {
let (_tmp, config, store) = seed();
for field in ["title", "status"] {
let result = call(
&config,
&store,
"bulk_preview",
json!({ "operation": "edit", "select": "1", "clear": field }),
);
assert!(is_error(&result), "clearing `{field}` is refused");
let text = content_text(&result);
assert!(
text.contains(metadata::UNSUPPORTED_OPERATION) || text.contains("clearable"),
"and the refusal names the diagnostic or the list: {text}"
);
}
}
#[test]
fn t038_capabilities_reports_the_projection_verbatim() {
let (_tmp, config, store) = seed();
let result = call(&config, &store, "bulk_capabilities", json!({}));
assert!(!is_error(&result));
let payload = structured(&result);
let caps = metadata::capabilities();
assert_eq!(
payload["fields"].as_array().unwrap().len(),
caps.fields.len()
);
assert_eq!(
payload["outcome_statuses"],
json!(caps.outcome_statuses),
"the payload is the projection, not a prose summary of it"
);
let text = content_text(&result);
for name in metadata::field_names() {
assert!(text.contains(name), "the text names `{name}`");
}
assert!(
text.contains("archive — archive each selected issue") && text.contains("[available]"),
"and every operation, archive included, is reported as available: {text}"
);
assert!(
!text.contains("supported not applicable"),
"no operation is understood-but-unavailable any more: {text}"
);
}
#[test]
fn t038_every_registered_tool_dispatches_and_the_ones_that_existed_still_do() {
let (_tmp, config, store) = seed();
for (name, args) in [
("create_issue", json!({ "title": "Fresh" })),
("list_issues", json!({})),
("get_issue", json!({ "issue": 1 })),
("board_status", json!({})),
("search_issues", json!({ "query": "Alpha" })),
("bulk_capabilities", json!({})),
] {
let result = call(&config, &store, name, args);
assert!(
!is_error(&result),
"{name} still answers: {}",
content_text(&result)
);
}
}
#[test]
fn t042_the_cli_and_mcp_report_the_same_selection_and_the_same_statuses() {
let (_tmp, config, store) = seed();
let mcp = call(
&config,
&store,
"bulk_apply",
json!({
"operation": "edit", "select": "1,2,3", "status": "in review",
"confirm": true
}),
);
let payload = structured(&mcp);
let mcp_statuses: Vec<&str> = payload["outcomes"]
.as_array()
.unwrap()
.iter()
.map(|o| o["status"].as_str().unwrap())
.collect();
let cli_statuses: Vec<&str> = ["applied", "applied", "applied"].to_vec();
assert_eq!(
mcp_statuses, cli_statuses,
"NFR-003: the same three words, from the same shared method, on both surfaces"
);
let mcp = call(
&config,
&store,
"bulk_preview",
json!({ "operation": "edit", "select": "1,2,3", "status": "in review" }),
);
let payload = structured(&mcp);
let mcp_selection: Vec<u64> = payload["selection"]
.as_array()
.unwrap()
.iter()
.map(|i| i["number"].as_u64().unwrap())
.collect();
let mcp_statuses: Vec<&str> = payload["items"]
.as_array()
.unwrap()
.iter()
.map(|i| i["status"].as_str().unwrap())
.collect();
let request = tix::commands::bulk::SelectionRequest::new(
&["1".to_string(), "2".to_string(), "3".to_string()],
None,
None,
SelectionScope::Active,
)
.unwrap();
let plan = tix::commands::bulk::build(
&store,
tix::commands::bulk::resolve(&store, &request).unwrap(),
tix::commands::bulk::PatchFlags {
status: Some("in review".into()),
..Default::default()
}
.to_patch()
.unwrap(),
)
.unwrap();
let cli_selection: Vec<u64> = plan
.selection
.issues()
.iter()
.map(|i| i.number as u64)
.collect();
let cli_statuses: Vec<&str> = plan.items.iter().map(|i| i.status()).collect();
assert_eq!(mcp_selection, cli_selection, "same selection, same order");
assert_eq!(mcp_statuses, cli_statuses, "same per-item status words");
assert_eq!(
content_text(&mcp),
tix::commands::bulk::render_plan(&plan, tix::commands::bulk::Mode::Preview)
);
}
#[test]
fn t042_a_cancelled_preview_leaves_the_tree_byte_identical() {
let (_tmp, config, store) = seed();
let before = tree_snapshot(&store.issues_dir);
let preview = call(
&config,
&store,
"bulk_preview",
json!({ "operation": "edit", "select": "1,2,3", "status": "done" }),
);
assert!(!is_error(&preview));
assert_eq!(
tree_snapshot(&store.issues_dir),
before,
"a preview is byte-identical"
);
let refused = call(
&config,
&store,
"bulk_preview",
json!({ "operation": "edit", "select": "1" }),
);
assert!(is_error(&refused), "a patch with no field set is refused");
assert_eq!(tree_snapshot(&store.issues_dir), before);
let applied = call(
&config,
&store,
"bulk_apply",
json!({ "operation": "edit", "select": "1", "status": "done", "confirm": true }),
);
assert!(!is_error(&applied), "{}", content_text(&applied));
assert_ne!(
tree_snapshot(&store.issues_dir),
before,
"the control: a confirmed apply does change the tree"
);
}
#[test]
fn t042_the_tui_and_mcp_read_the_same_plan_value() {
let (_tmp, config, store) = seed();
let mcp = call(
&config,
&store,
"bulk_preview",
json!({ "operation": "edit", "select": "1", "labels": "bug, wip" }),
);
let mcp_change = change(&structured(&mcp)["items"][0], "labels").clone();
let mut modal = tix::ui::bulk::BulkModal::new(tix::ui::bulk::BatchAction::Edit);
modal.selection.toggle(1).unwrap();
modal
.set_row(
modal
.row_names()
.iter()
.position(|n| *n == "labels")
.unwrap(),
"bug, wip".to_string(),
)
.unwrap();
let tui_patch = modal.patch().unwrap();
assert_eq!(
tui_patch.labels,
PatchValue::Set(vec!["bug".to_string(), "wip".to_string()]),
"the TUI row and the MCP argument produce the same typed list"
);
let tui_plan = tix::commands::bulk::build(
&store,
tix::commands::bulk::resolve(
&store,
&tix::commands::bulk::SelectionRequest::new(
&["1".to_string()],
None,
None,
SelectionScope::Active,
)
.unwrap(),
)
.unwrap(),
tui_patch,
)
.unwrap();
let (from, to) = tui_plan.items[0].changes["labels"].as_from_to();
assert_eq!(mcp_change["to"], json!(to), "and the same proposed value");
assert_eq!(mcp_change["from"], json!(from));
assert_eq!(
mcp_change["field"],
json!("labels"),
"named by the contract's field"
);
let _ = config;
}
#[test]
fn t039_a_reassignment_moves_the_file_and_records_one_history_event() {
let (_tmp, config, store) = seed();
let before = store
.list()
.unwrap()
.into_iter()
.find(|i| i.number == 1)
.map(|i| i.filename)
.unwrap();
let result = call(
&config,
&store,
"bulk_apply",
json!({ "operation": "edit", "select": "1", "assignee": "bob", "confirm": true }),
);
assert!(!is_error(&result), "{}", content_text(&result));
let after = store
.list()
.unwrap()
.into_iter()
.find(|i| i.number == 1)
.map(|i| i.filename)
.unwrap();
assert_ne!(after, before, "the file moved into the new segment");
assert!(
after.contains("bob"),
"into the assignee's segment: {after}"
);
assert!(
!store.issues_dir.join(&before).exists(),
"and the old path is gone — C-001: one canonical file per issue"
);
}