use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use indexmap::IndexMap;
use tempfile::TempDir;
use tix::bulk::pair::{PairOutcome, PairProblem, RequestedPair, apply_pair, resolve_pair};
use tix::bulk::{
Confirmation, IssuePatch, PatchValue, RelationshipOp, Selection, SelectionScope,
SelectionSource,
};
use tix::config::{BoardConfiguration, Config, KanbanSection};
use tix::model::{Issue, Status};
use tix::relationships::RelationKind;
use tix::storage::Store;
const MAP: [(&str, &str); 5] = [
("triage", "backlog"),
("wip", "active"),
("review", "active"),
("shipped", "completed"),
("abandoned", "canceled"),
];
fn store() -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let store = build(&tmp);
for (i, column) in MAP.iter().map(|(c, _)| *c).enumerate() {
tix::commands::create::create(
&store.config,
&store,
&format!("Issue{}", i + 1),
None,
Some(Status::new(column)),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
}
(tmp, store)
}
fn build(tmp: &TempDir) -> Store {
let dir = tmp.path().join("docs").join("issues");
fs::create_dir_all(&dir).unwrap();
let config = Config {
issues_dir: dir.clone(),
kanban: KanbanSection {
configurations: IndexMap::from([(
"default".to_string(),
BoardConfiguration {
columns: MAP.iter().map(|(c, _)| (*c).to_string()).collect(),
done_column: Some("shipped".to_string()),
category_map: MAP
.iter()
.map(|(c, k)| ((*c).to_string(), (*k).to_string()))
.collect(),
..Default::default()
},
)]),
},
..Config::default()
};
config.save(&tmp.path().join("tix.toml")).unwrap();
let config = Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
Store {
issues_dir: config.issues_dir.clone(),
config,
}
}
fn snapshot(dir: &Path) -> Vec<(String, Vec<u8>)> {
let raw = raw_snapshot(dir);
let mut issue_ids = BTreeMap::new();
for (_, bytes) in &raw {
let text = String::from_utf8_lossy(bytes);
let number = text
.lines()
.find_map(|line| line.strip_prefix("number: "))
.and_then(|value| value.trim().parse::<u32>().ok());
let id = text
.lines()
.find_map(|line| line.strip_prefix("id: "))
.map(str::trim)
.filter(|id| tix::id::is_issue_id(id));
if let (Some(number), Some(id)) = (number, id) {
issue_ids.insert(id.to_string(), format!("ID{number}"));
}
}
let mut out: Vec<(String, Vec<u8>)> = raw
.into_iter()
.map(|(path, bytes)| {
let text = String::from_utf8_lossy(&bytes).into_owned();
let path = replace_issue_ids(&path, &issue_ids);
let text = replace_issue_ids(&text, &issue_ids);
(path, normalise_volatile(&text).into_bytes())
})
.collect();
out.sort();
out
}
fn replace_issue_ids(text: &str, ids: &BTreeMap<String, String>) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(at) = rest.find(|c: char| c.is_ascii_alphanumeric()) {
out.push_str(&rest[..at]);
rest = &rest[at..];
let end = rest
.find(|c: char| !c.is_ascii_alphanumeric())
.unwrap_or(rest.len());
let token = &rest[..end];
out.push_str(ids.get(token).map_or(token, String::as_str));
rest = &rest[end..];
}
out.push_str(rest);
out
}
fn normalise_volatile(text: &str) -> String {
let ids = normalise_rel_ids(text);
normalise_timestamps(&ids)
}
fn normalise_rel_ids(text: &str) -> String {
let mut seen: Vec<String> = Vec::new();
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(at) = rest.find("rel_") {
out.push_str(&rest[..at]);
out.push_str("rel_");
rest = &rest[at + 4..];
let end = rest
.find(|c: char| !c.is_ascii_alphanumeric())
.unwrap_or(rest.len());
let id = &rest[..end];
if id.is_empty() {
continue;
}
match seen.iter().position(|s| s == id) {
Some(i) => out.push_str(&i.to_string()),
None => {
seen.push(id.to_string());
out.push_str(&(seen.len() - 1).to_string());
}
}
rest = &rest[end..];
}
out.push_str(rest);
out
}
const SEP: &str = " \u{2014} ";
fn normalise_timestamps(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(at) = rest.find(SEP) {
let line_start = rest[..at].rfind('\n').map(|i| i + 1).unwrap_or(0);
let candidate = &rest[line_start..at];
if candidate.starts_with("- ") && candidate[2..].contains(':') {
out.push_str(&rest[..line_start]);
out.push_str("- <TS>");
rest = &rest[at..];
continue;
}
out.push_str(&rest[..at + SEP.len()]);
rest = &rest[at + SEP.len()..];
}
out.push_str(rest);
out
}
fn raw_snapshot(dir: &Path) -> Vec<(String, Vec<u8>)> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(cur) = stack.pop() {
for entry in fs::read_dir(&cur).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
} else {
out.push((
path.strip_prefix(dir)
.unwrap()
.to_string_lossy()
.to_string(),
fs::read(&path).unwrap(),
));
}
}
}
out.sort();
out
}
fn select(store: &Store, selectors: &[&str]) -> Selection {
Selection::resolve(
store,
&selectors
.iter()
.map(|s| (*s).to_string())
.collect::<Vec<_>>(),
None,
SelectionSource::Explicit,
SelectionScope::Active,
)
.unwrap()
}
fn link_op(from: &str, to: &str) -> RelationshipOp {
RelationshipOp::link(from, to, RelationKind::Blocks)
}
fn unlink_op(from: &str, to: &str) -> RelationshipOp {
RelationshipOp::unlink(from, to, RelationKind::Blocks)
}
fn pair_plan(store: &Store, op: RelationshipOp) -> tix::bulk::BatchPlan {
let mut plan = tix::bulk::plan::build_plan(
&store.config,
&store.issues_dir,
select(store, &["1"]),
IssuePatch {
relationship: Some(op),
..IssuePatch::default()
},
|op: &RelationshipOp| tix::bulk::pair::plan_for(store, op),
)
.unwrap();
plan.confirmation = Some(Confirmation::issue(&plan));
plan
}
fn read(store: &Store, number: u32) -> Issue {
store
.get(tix::storage::IssueSelector::Number(number))
.unwrap()
}
#[test]
fn t017_a_valid_pair_resolves_both_ends_with_an_identity() {
let (_tmp, store) = store();
let op = link_op("1", "2");
let resolution = resolve_pair(&store, &op);
assert!(resolution.diagnostic.is_none(), "{resolution:?}");
let edge = resolution.edge.expect("a canonical edge");
assert_eq!(edge.from, 1);
assert_eq!(edge.to, 2);
assert_eq!(edge.kind, RelationKind::Blocks);
assert_eq!(resolution.requested, RequestedPair::from(&op));
assert_eq!(resolution.requested.verb(), "link");
}
#[test]
fn t017_the_two_spellings_of_an_edge_are_one_canonical_edge() {
let (_tmp, store) = store();
let forward = resolve_pair(&store, &link_op("1", "2"));
let inverse = resolve_pair(
&store,
&RelationshipOp::link("2", "1", RelationKind::BlockedBy),
);
let (a, b) = (forward.edge.unwrap(), inverse.edge.unwrap());
assert_eq!(a, b, "1 blocks 2 and 2 blocked-by 1 are one edge");
}
#[test]
fn t017_resolving_a_pair_writes_nothing() {
let (tmp, store) = store();
let before = raw_snapshot(tmp.path());
for op in [
link_op("1", "2"),
unlink_op("1", "2"),
link_op("1", "no-such-issue"),
link_op("1", "1"),
] {
let _ = resolve_pair(&store, &op);
let _ = tix::bulk::pair::plan_for(&store, &op);
}
assert_eq!(
raw_snapshot(tmp.path()),
before,
"C-005: resolving a pair reads and writes nothing — and mints no id"
);
}
#[test]
fn t018_a_bulk_pair_writes_exactly_what_a_tix_link_writes() {
let bulk_tmp = TempDir::new().unwrap();
let bulk = build(&bulk_tmp);
seed(&bulk);
let cli_tmp = TempDir::new().unwrap();
let cli = build(&cli_tmp);
seed(&cli);
let plan = pair_plan(&bulk, link_op("1", "2"));
let outcome = apply_pair(&bulk, &plan).unwrap();
assert!(outcome.is_applied(), "{outcome:?}");
assert!(matches!(
outcome,
PairOutcome::Applied { ref from, ref to, .. } if *from == 1 && *to == 2
));
cli.link("1", "2", RelationKind::Blocks).unwrap();
assert_eq!(
snapshot(&bulk_tmp.path().join("docs")),
snapshot(&cli_tmp.path().join("docs")),
"a bulk pair and `tix link` must produce the same bytes, or one of them is \\
not the same operation"
);
}
#[test]
fn t018_the_outcome_names_both_ends_and_the_files_written() {
let (_tmp, store) = store();
let plan = pair_plan(&store, link_op("3", "1"));
let outcome = apply_pair(&store, &plan).unwrap();
let PairOutcome::Applied {
from,
to,
identity,
files,
} = &outcome
else {
panic!("applied, got {outcome:?}");
};
assert_eq!((from.as_number().unwrap(), to.as_number().unwrap()), (3, 1));
assert!(!identity.is_empty());
assert_eq!(files.len(), 1, "a create writes the owner file");
assert!(
files[0].contains("issue3"),
"the canonical owner is the `from` end: {:?}",
files[0]
);
let line = outcome.as_line();
for fragment in ["#3", "#1", identity.as_str()] {
assert!(line.contains(fragment), "{fragment} missing from: {line}");
}
}
#[test]
fn t019_the_diagnostic_vocabulary_is_four_names_and_closed() {
let names: Vec<&str> = [
PairProblem::Missing,
PairProblem::Ambiguous,
PairProblem::SelfLink,
PairProblem::Refused,
]
.iter()
.map(|p| p.as_str())
.collect();
assert_eq!(
names,
vec!["missing_target", "ambiguous_target", "self_link", "refused",]
);
let mut sorted = names.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), 4, "the names must be distinct");
}
#[test]
fn t019_a_missing_an_ambiguous_and_a_self_target_are_three_names() {
let (_tmp, store) = store();
write_issue(&store, 90, "shared-alpha", "Shared alpha");
write_issue(&store, 91, "shared-beta", "Shared beta");
let missing = resolve_pair(&store, &link_op("1", "no-such-issue"));
assert_eq!(
missing.diagnostic.as_ref().map(|d| d.problem),
Some(PairProblem::Missing),
"{missing:?}"
);
assert_eq!(missing.diagnostic.as_ref().unwrap().endpoint, "to");
let ambiguous = resolve_pair(&store, &link_op("1", "shared"));
assert_eq!(
ambiguous.diagnostic.as_ref().map(|d| d.problem),
Some(PairProblem::Ambiguous),
"{ambiguous:?}"
);
assert_eq!(ambiguous.diagnostic.as_ref().unwrap().endpoint, "to");
let bad_from = resolve_pair(&store, &link_op("no-such-issue", "1"));
assert_eq!(
bad_from.diagnostic.as_ref().map(|d| d.problem),
Some(PairProblem::Missing),
"{bad_from:?}"
);
assert_eq!(
bad_from.diagnostic.as_ref().unwrap().endpoint,
"from",
"the failure is attributed to the end that failed"
);
assert_eq!(
bad_from.to_number,
Some(tix::id::canonical_ref(&read(&store, 1))),
"and the other end still resolved"
);
let self_link = resolve_pair(&store, &link_op("1", "1"));
assert_eq!(
self_link.diagnostic.as_ref().map(|d| d.problem),
Some(PairProblem::SelfLink),
"{self_link:?}"
);
assert_eq!(self_link.diagnostic.as_ref().unwrap().endpoint, "");
}
#[test]
fn t019_the_missing_message_is_the_existing_selector_text() {
let (_tmp, store) = store();
let resolution = resolve_pair(&store, &link_op("1", "no-such-issue"));
let message = &resolution.diagnostic.as_ref().unwrap().message;
assert!(message.contains("no-such-issue"), "{message}");
assert_eq!(
message.matches("not found").count(),
1,
"the existing prefix, once: {message}"
);
assert!(
!message.starts_with("not found: not found"),
"and never doubled: {message}"
);
}
#[test]
fn t019_a_refused_pair_leaves_both_endpoints_unchanged() {
let (tmp, store) = store();
let before = snapshot(&store.issues_dir);
for op in [
link_op("1", "no-such-issue"),
link_op("no-such-issue", "1"),
link_op("1", "1"),
] {
let outcome = apply_pair(&store, &pair_plan(&store, op)).unwrap();
assert!(
matches!(outcome, PairOutcome::Refused(_)),
"must be refused: {outcome:?}"
);
assert_eq!(
snapshot(&store.issues_dir),
before,
"and both endpoints are byte-identical"
);
}
let _ = tmp;
}
#[test]
fn t019_a_self_link_says_self_and_carries_the_proposed_identity() {
let (_tmp, store) = store();
let resolution = resolve_pair(&store, &link_op("1", "1"));
let diagnostic = resolution.diagnostic.as_ref().unwrap();
assert!(
diagnostic.message.contains("itself"),
"{}",
diagnostic.message
);
assert!(
diagnostic.message.contains("blocks"),
"{}",
diagnostic.message
);
assert_eq!(
resolution.from_number,
Some(tix::id::canonical_ref(&read(&store, 1)))
);
assert_eq!(
resolution.to_number,
Some(tix::id::canonical_ref(&read(&store, 1)))
);
assert!(
resolution.edge.is_none(),
"and there is no edge, because a cycle of length one is not one"
);
}
#[test]
fn t019_a_store_refusal_is_reported_not_re_derived() {
let (_tmp, store) = store();
let plan = pair_plan(&store, link_op("1", "2"));
assert!(apply_pair(&store, &plan).unwrap().is_applied());
let resolution = resolve_pair(&store, &link_op("1", "2"));
assert!(
resolution.diagnostic.is_none(),
"the selectors are fine: {resolution:?}"
);
let plan = pair_plan(&store, link_op("1", "2"));
let outcome = apply_pair(&store, &plan).unwrap();
assert!(matches!(outcome, PairOutcome::NoOp { .. }), "{outcome:?}");
}
#[test]
fn t020_a_repeated_link_is_a_no_op_with_no_duplicate_event() {
let (_tmp, store) = store();
let first = apply_pair(&store, &pair_plan(&store, link_op("1", "2"))).unwrap();
assert!(first.is_applied());
let events_after_first = read(&store, 1).history.events().len();
assert_eq!(events_after_first, 1, "one relationship_added");
let second = apply_pair(&store, &pair_plan(&store, link_op("1", "2"))).unwrap();
assert!(
matches!(second, PairOutcome::NoOp { .. }),
"a retry is a no-op, never silent and never a second write: {second:?}"
);
assert_eq!(
read(&store, 1).history.events().len(),
1,
"and no duplicate History event"
);
let PairOutcome::NoOp { identity } = &second else {
panic!("a no-op");
};
assert!(!identity.is_empty());
assert!(
read(&store, 1)
.relationships
.iter()
.any(|r| &r.id == identity)
);
}
#[test]
fn t020_a_repeated_unlink_is_a_no_op() {
let (_tmp, store) = store();
apply_pair(&store, &pair_plan(&store, link_op("1", "2"))).unwrap();
let unlink = apply_pair(&store, &pair_plan(&store, unlink_op("1", "2"))).unwrap();
assert!(unlink.is_applied(), "{unlink:?}");
let after = apply_pair(&store, &pair_plan(&store, unlink_op("1", "2"))).unwrap();
assert!(matches!(after, PairOutcome::NoOp { .. }), "{after:?}");
let removed = read(&store, 1)
.history
.events()
.iter()
.filter(|e| e.kind.as_str() == "relationship_removed")
.count();
assert_eq!(removed, 1, "one removal event, not two");
}
#[test]
fn t020_a_link_unlink_link_round_trip_restores_the_state_and_records_all_three() {
let (tmp, store) = store();
let first = apply_pair(&store, &pair_plan(&store, link_op("1", "2"))).unwrap();
let PairOutcome::Applied {
identity: first_id, ..
} = &first
else {
panic!("applied");
};
apply_pair(&store, &pair_plan(&store, unlink_op("1", "2"))).unwrap();
let third = apply_pair(&store, &pair_plan(&store, link_op("1", "2"))).unwrap();
let PairOutcome::Applied {
identity: third_id, ..
} = &third
else {
panic!("applied");
};
let third_line = third.as_line();
assert!(
third_line.contains("issue1"),
"the report names the file the row landed in: {third_line}"
);
let issue = read(&store, 1);
assert_eq!(issue.relationships.len(), 1, "exactly one row");
let rel = &issue.relationships[0];
let from = tix::id::canonical_ref(&read(&store, 1));
let to = tix::id::canonical_ref(&read(&store, 2));
assert_eq!(
(rel.from, rel.to, rel.kind),
(from, to, RelationKind::Blocks)
);
assert_ne!(first_id, third_id, "a re-link mints a new id");
assert_eq!(rel.id, *third_id);
let kinds: Vec<&str> = issue
.history
.events()
.iter()
.map(|e| e.kind.as_str())
.collect();
assert_eq!(
kinds,
vec![
"relationship_added",
"relationship_removed",
"relationship_added"
],
"History is a record of decisions, so a round trip records all three"
);
let after_first_link = normalise_rel_ids(
&issue
.relationships
.iter()
.map(|r| r.id.clone())
.collect::<Vec<_>>()
.join(","),
);
assert!(
!after_first_link.is_empty(),
"the relationship list is readable after the round trip"
);
let _ = tmp;
}
#[test]
fn t020_an_unlink_reports_the_files_the_store_wrote() {
let (_tmp, store) = store();
apply_pair(&store, &pair_plan(&store, link_op("1", "2"))).unwrap();
let outcome = apply_pair(&store, &pair_plan(&store, unlink_op("1", "2"))).unwrap();
let PairOutcome::Applied { files, .. } = &outcome else {
panic!("applied, got {outcome:?}");
};
assert!(!files.is_empty(), "the removal named the file it wrote");
for file in files {
assert!(
store.issues_dir.join(file).is_file(),
"the reported file is real: {file}"
);
}
}
#[test]
fn t021_a_duplicate_selected_endpoint_does_not_double_apply() {
let (tmp, store) = store();
let before = snapshot(&store.issues_dir);
let mut plan = tix::bulk::plan::build_plan(
&store.config,
&store.issues_dir,
select(&store, &["1", "1", "2"]),
IssuePatch {
relationship: Some(link_op("1", "2")),
..IssuePatch::default()
},
|op: &RelationshipOp| tix::bulk::pair::plan_for(&store, op),
)
.unwrap();
plan.confirmation = Some(Confirmation::issue(&plan));
let outcome = apply_pair(&store, &plan).unwrap();
assert!(outcome.is_applied());
let after = snapshot(&store.issues_dir);
let changed = after
.iter()
.filter(|(path, bytes)| {
before
.iter()
.find(|(p, _)| p == path)
.map(|(_, b)| b != bytes)
.unwrap_or(true)
})
.count();
assert_eq!(changed, 1, "the owner file only: {after:?}");
assert_eq!(read(&store, 1).relationships.len(), 1);
let _ = tmp;
}
#[test]
fn t021_a_pair_outcome_always_names_the_pair_not_one_end() {
let (_tmp, store) = store();
for op in [link_op("1", "2"), unlink_op("1", "2"), link_op("1", "9")] {
let plan = pair_plan(&store, op.clone());
let line = match apply_pair(&store, &plan).unwrap() {
PairOutcome::Applied { from, to, .. } => {
assert!(
from != to,
"an applied pair has two distinct ends: {from} -> {to}"
);
format!("{from} {to}")
}
other => other.as_line(),
};
assert!(!line.is_empty(), "{op:?} produced no line");
}
}
#[test]
fn t021_an_unconfirmed_pair_plan_is_refused_before_any_write() {
let (tmp, store) = store();
let before = snapshot(tmp.path());
let plan = tix::bulk::plan::build_plan(
&store.config,
&store.issues_dir,
select(&store, &["1"]),
IssuePatch {
relationship: Some(link_op("1", "2")),
..IssuePatch::default()
},
|op: &RelationshipOp| tix::bulk::pair::plan_for(&store, op),
)
.unwrap();
assert!(plan.confirmation.is_none());
let err = apply_pair(&store, &plan).unwrap_err();
assert!(err.to_string().contains("confirmation"), "{err}");
assert_eq!(snapshot(tmp.path()), before);
}
#[test]
fn t021_a_mixed_pair_and_field_plan_is_refused_by_both_paths() {
let (_tmp, store) = store();
let before = snapshot(&store.issues_dir);
let err = tix::bulk::plan::build_plan(
&store.config,
&store.issues_dir,
select(&store, &["1"]),
IssuePatch {
relationship: Some(link_op("1", "2")),
status: PatchValue::Set("wip".to_string()),
..IssuePatch::default()
},
|op: &RelationshipOp| tix::bulk::pair::plan_for(&store, op),
)
.unwrap_err()
.to_string();
assert!(
err.contains("one of field changes") && err.contains("relationship operation"),
"the refusal must name both kinds: {err}"
);
assert!(!tix::bulk::pair::owns(&IssuePatch {
relationship: Some(link_op("1", "2")),
status: PatchValue::Set("wip".to_string()),
..IssuePatch::default()
}));
assert_eq!(snapshot(&store.issues_dir), before, "nothing written");
}
fn seed(store: &Store) {
for i in 1..=5 {
tix::commands::create::create(
&store.config,
store,
&format!("Issue{i}"),
None,
Some(Status::new(MAP[i - 1].0)),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
}
}
fn write_issue(store: &Store, number: u32, slug: &str, title: &str) {
let template = read(store, 1);
let path = store.issues_dir.join(&template.filename);
let dir = path.parent().expect("a segment").to_path_buf();
let text = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: triage\nlabels: []\n\
priority: medium\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n\
relationships: []\nfiles: []\n---\n\nbody\n"
);
let written = dir.join(format!("2026-01-01-{slug}.md"));
assert_ne!(&written, &path, "the extra issue must be a separate file");
fs::write(&written, text).unwrap();
assert_eq!(read(store, number).title, title);
}
#[test]
#[cfg(unix)]
fn t019_a_failed_unlink_write_rolls_back_within_the_pair_transaction() {
use std::os::unix::fs::PermissionsExt;
let (tmp, store) = store();
let _ = tmp;
store.link("1", "2", RelationKind::Blocks).unwrap();
let row = read(&store, 1).relationships[0].clone();
let mut duplicate = read(&store, 2);
duplicate.relationships.push(row);
store.update(&duplicate.filename, &duplicate).unwrap();
let before = raw_snapshot(&store.issues_dir);
let second = read(&store, 2).filename.clone();
let second_dir = store
.issues_dir
.join(Path::new(&second))
.parent()
.unwrap()
.to_path_buf();
let mut permissions = fs::metadata(&second_dir).unwrap().permissions();
permissions.set_mode(0o555);
fs::set_permissions(&second_dir, permissions).unwrap();
let plan = pair_plan(&store, unlink_op("1", "2"));
let outcome = apply_pair(&store, &plan);
let mut permissions = fs::metadata(&second_dir).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&second_dir, permissions).unwrap();
assert!(
matches!(outcome, Ok(PairOutcome::Refused(_))),
"a failed unlink is reported as a refusal: {outcome:?}"
);
assert_eq!(
raw_snapshot(&store.issues_dir),
before,
"the first file's removal must be rolled back when the second fails"
);
}