use std::fs;
use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use tempfile::TempDir;
use tix::bulk::{
IssuePatch, PatchValue, RelationshipOp, Selection, SelectionScope, SelectionSource,
scope_metadata,
};
use tix::config::{BoardConfiguration, Config, KanbanSection};
use tix::model::{Issue, Priority, Status};
use tix::storage::Store;
fn store() -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
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: vec!["triage".into(), "wip".into(), "shipped".into()],
done_column: Some("shipped".to_string()),
category_map: IndexMap::from([
("triage".to_string(), "backlog".to_string()),
("wip".to_string(), "active".to_string()),
("shipped".to_string(), "completed".to_string()),
]),
..Default::default()
},
)]),
},
..Config::default()
};
config.save(&tmp.path().join("tix.toml")).unwrap();
let config = Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
let store = Store {
issues_dir: config.issues_dir.clone(),
config,
};
for (i, column) in ["triage", "wip", "shipped"].iter().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 snapshot(dir: &Path) -> Vec<(String, Vec<u8>)> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(cur) = stack.pop() {
for entry in fs::read_dir(&cur).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
} else {
let rel = path
.strip_prefix(dir)
.unwrap()
.to_string_lossy()
.to_string();
out.push((rel, fs::read(&path).unwrap()));
}
}
}
out.sort();
out
}
fn write_extra_issue(store: &Store, name: &str, number: u32, title: &str) {
let existing = fs::read_dir(&store.issues_dir)
.unwrap()
.map(|e| e.unwrap().path())
.find(|p| p.is_dir())
.expect("the fixture created at least one issue, so a segment exists");
let dir = existing;
fs::write(
dir.join(name),
format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: triage\nlabels: []\n\
priority: medium\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n\
---\n\nbody\n"
),
)
.unwrap();
}
fn titles(selection: &Selection) -> Vec<String> {
selection.issues().iter().map(|i| i.title.clone()).collect()
}
#[test]
fn t001_explicit_selectors_resolve_through_the_existing_grammar() {
let (_tmp, store) = store();
let selection = Selection::resolve(
&store,
&["1".to_string(), "2".to_string()],
None,
SelectionSource::Explicit,
SelectionScope::Active,
)
.unwrap();
assert_eq!(titles(&selection), vec!["Issue1", "Issue2"]);
assert_eq!(selection.numbers(), vec![1, 2]);
assert_eq!(selection.source(), SelectionSource::Explicit);
assert_eq!(selection.scope(), SelectionScope::Active);
}
#[test]
fn t001_a_number_a_fragment_and_a_title_all_resolve() {
let (_tmp, store) = store();
let by_number = Selection::resolve(
&store,
&["2".to_string()],
None,
SelectionSource::Explicit,
SelectionScope::Active,
)
.unwrap();
assert_eq!(titles(&by_number), vec!["Issue2"]);
let issue = store.get(tix::storage::IssueSelector::Number(2)).unwrap();
let by_filename = Selection::resolve(
&store,
std::slice::from_ref(&issue.filename),
None,
SelectionSource::Explicit,
SelectionScope::Active,
)
.unwrap();
assert_eq!(titles(&by_filename), vec!["Issue2"], "a filename fragment");
let by_title = Selection::resolve(
&store,
&["Issue2".to_string()],
None,
SelectionSource::Explicit,
SelectionScope::Active,
)
.unwrap();
assert_eq!(titles(&by_title), vec!["Issue2"], "a title fragment");
}
#[test]
fn t001_not_found_is_the_existing_message_and_not_a_doubled_one() {
let (_tmp, store) = store();
let from_parse = Selection::resolve(
&store,
&["no-such-issue".to_string()],
None,
SelectionSource::Explicit,
SelectionScope::Active,
)
.unwrap_err()
.to_string();
assert!(
from_parse.contains("no-such-issue"),
"the message must name what the user typed: {from_parse}"
);
assert_eq!(
from_parse.matches("not found").count(),
1,
"the prefix appears once, not nested: {from_parse}"
);
let from_get = Selection::resolve(
&store,
&["9999".to_string()],
None,
SelectionSource::Explicit,
SelectionScope::Active,
)
.unwrap_err()
.to_string();
assert!(
from_get.contains("9999"),
"the message must name what the user typed: {from_get}"
);
assert_eq!(
from_get.matches("not found").count(),
1,
"and the prefix still appears once: {from_get}"
);
}
#[test]
fn t001_an_ambiguous_fragment_is_refused() {
let (_tmp, store) = store();
write_extra_issue(&store, "2026-01-01-shared-alpha.md", 90, "Shared alpha");
write_extra_issue(&store, "2026-01-01-shared-beta.md", 91, "Shared beta");
let result = Selection::resolve(
&store,
&["shared".to_string()],
None,
SelectionSource::Explicit,
SelectionScope::Active,
);
assert!(
result.is_err(),
"an ambiguous fragment must be refused, not resolved to one of them"
);
}
#[test]
fn t002_a_filter_query_selects_through_the_existing_token_matcher() {
let (_tmp, store) = store();
let selection = Selection::resolve(
&store,
&[],
Some("status:shipped"),
SelectionSource::Filter,
SelectionScope::Active,
)
.unwrap();
assert_eq!(titles(&selection), vec!["Issue3"]);
assert_eq!(selection.query(), Some("status:shipped"));
}
#[test]
fn t002_a_search_query_uses_the_substring_matcher() {
let (_tmp, store) = store();
let selection = Selection::resolve(
&store,
&[],
Some("Issue2"),
SelectionSource::Search,
SelectionScope::Active,
)
.unwrap();
assert_eq!(titles(&selection), vec!["Issue2"]);
let filtered_bare = Selection::resolve(
&store,
&[],
Some("Issue2"),
SelectionSource::Filter,
SelectionScope::Active,
)
.unwrap();
assert_eq!(
titles(&filtered_bare),
vec!["Issue2"],
"a filter's bare term is a substring match, like a search's"
);
let search_token = Selection::resolve(
&store,
&[],
Some("status:shipped"),
SelectionSource::Search,
SelectionScope::Active,
)
.unwrap();
assert!(
search_token.is_empty(),
"a search looks for the literal text, not a criterion"
);
let filter_token = Selection::resolve(
&store,
&[],
Some("status:shipped"),
SelectionSource::Filter,
SelectionScope::Active,
)
.unwrap();
assert_eq!(titles(&filter_token), vec!["Issue3"]);
}
#[test]
fn t002_selectors_and_a_query_are_unioned_with_selectors_first() {
let (_tmp, store) = store();
let selection = Selection::resolve(
&store,
&["1".to_string()],
Some("status:shipped"),
SelectionSource::Filter,
SelectionScope::Active,
)
.unwrap();
assert_eq!(
titles(&selection),
vec!["Issue1", "Issue3"],
"explicit selectors lead, then the query's issues"
);
}
#[test]
fn t002_an_empty_result_is_a_selection_not_an_error() {
let (_tmp, store) = store();
let selection = Selection::resolve(
&store,
&[],
Some("status:no-such-column"),
SelectionSource::Filter,
SelectionScope::Active,
)
.unwrap();
assert!(selection.is_empty());
assert_eq!(selection.len(), 0);
}
#[test]
fn t005_duplicate_selectors_resolve_to_one_issue_and_are_reported() {
let (_tmp, store) = store();
let issue = store.get(tix::storage::IssueSelector::Number(1)).unwrap();
let selection = Selection::resolve(
&store,
&[
"1".to_string(),
"1".to_string(),
issue.filename.clone(),
"Issue1".to_string(),
],
None,
SelectionSource::Explicit,
SelectionScope::Active,
)
.unwrap();
assert_eq!(selection.len(), 1, "one issue, not four");
assert_eq!(selection.numbers(), vec![1]);
assert_eq!(selection.duplicates().len(), 3, "each is reported");
assert_eq!(selection.selectors(), &["1".to_string()]);
}
#[test]
fn t005_deduplication_is_by_issue_number_not_by_the_text_typed() {
let (_tmp, store) = store();
let issue = store.get(tix::storage::IssueSelector::Number(1)).unwrap();
let selection = Selection::resolve(
&store,
&["1".to_string(), issue.filename.clone()],
None,
SelectionSource::Explicit,
SelectionScope::Active,
)
.unwrap();
assert_eq!(selection.len(), 1, "same issue, two spellings");
assert_eq!(selection.duplicates(), &[issue.filename]);
}
#[test]
fn t005_order_is_the_order_the_user_typed() {
let (_tmp, store) = store();
let selection = Selection::resolve(
&store,
&["3".to_string(), "1".to_string()],
None,
SelectionSource::Explicit,
SelectionScope::Active,
)
.unwrap();
assert_eq!(titles(&selection), vec!["Issue3", "Issue1"]);
}
#[test]
fn t005_the_snapshot_does_not_grow_after_it_is_taken() {
let (_tmp, store) = store();
let selection = Selection::resolve(
&store,
&["1".to_string()],
None,
SelectionSource::Explicit,
SelectionScope::Active,
)
.unwrap();
assert_eq!(selection.len(), 1);
write_extra_issue(&store, "2026-01-01-late.md", 4, "Late arrival");
assert_eq!(selection.len(), 1, "the snapshot is a value, not a query");
assert_eq!(titles(&selection), vec!["Issue1"]);
}
#[test]
fn t005_the_fingerprint_is_membership_and_is_order_insensitive() {
let (_tmp, store) = store();
let a = Selection::resolve(
&store,
&["1".to_string(), "2".to_string()],
None,
SelectionSource::Explicit,
SelectionScope::Active,
)
.unwrap();
let b = Selection::resolve(
&store,
&["2".to_string(), "1".to_string()],
None,
SelectionSource::Explicit,
SelectionScope::Active,
)
.unwrap();
assert_eq!(a.fingerprint(), vec![1, 2]);
assert!(a.matches_fingerprint(&b.fingerprint()));
let c = Selection::resolve(
&store,
&["1".to_string()],
None,
SelectionSource::Explicit,
SelectionScope::Active,
)
.unwrap();
assert!(
!a.matches_fingerprint(&c.fingerprint()),
"a subset is not the same"
);
}
#[test]
fn t005_archived_scope_is_explicit_and_read_only() {
let (_tmp, _store) = store();
let active = scope_metadata(SelectionScope::Active, 3);
assert!(active.can_write);
assert!(!active.delegation_pending);
assert_eq!(active.to_string(), "active (3 selected)");
for scope in [
SelectionScope::ActiveAndArchived,
SelectionScope::ArchivedOnly,
] {
let meta = scope_metadata(scope, 0);
assert!(!meta.can_write, "{scope:?} must not be writable");
assert!(meta.delegation_pending, "{scope:?} must say it is pending");
assert!(
meta.to_string().contains("read-only"),
"and the message must say why: {meta}"
);
}
}
#[test]
fn t005_the_scope_vocabulary_is_three_words_and_closed() {
assert_eq!(
SelectionScope::parse("active").unwrap(),
SelectionScope::Active
);
assert_eq!(
SelectionScope::parse("ACTIVE").unwrap(),
SelectionScope::Active,
"case-insensitive, because the user types it"
);
assert_eq!(
SelectionScope::parse("all").unwrap(),
SelectionScope::ActiveAndArchived
);
assert_eq!(
SelectionScope::parse("archived").unwrap(),
SelectionScope::ArchivedOnly
);
let err = SelectionScope::parse("everything").unwrap_err().to_string();
assert!(err.contains("unknown scope"), "{err}");
for word in ["active", "active+archived", "archived"] {
assert!(err.contains(word), "'{word}' missing from: {err}");
}
}
#[test]
fn t003_absent_is_not_clear() {
let unchanged: PatchValue<String> = PatchValue::Unchanged;
let clear: PatchValue<String> = PatchValue::Clear;
assert!(unchanged.is_unchanged());
assert!(!clear.is_unchanged());
assert!(clear.is_clear());
assert_eq!(unchanged.as_set(), None);
assert_eq!(clear.as_set(), None);
assert_eq!(
PatchValue::from(None::<String>),
PatchValue::Unchanged,
"`None` is absent, not empty"
);
assert_eq!(
PatchValue::from(Some("high".to_string())),
PatchValue::Set("high".to_string())
);
}
#[test]
fn t003_an_empty_patch_names_nothing_and_says_so() {
let patch = IssuePatch::default();
assert!(patch.is_empty());
let problems = patch.structural_problems();
assert_eq!(problems.len(), 1);
assert_eq!(problems[0].field, "patch");
for name in IssuePatch::field_names() {
assert!(
problems[0].message.contains(name),
"'{name}' missing from: {}",
problems[0].message
);
}
assert!(patch.require_wellformed().is_err());
}
#[test]
fn t003_a_patch_is_a_whitelist_with_no_escape_hatch() {
assert_eq!(
IssuePatch::field_names(),
&[
"status",
"priority",
"assignee",
"labels",
"start-date",
"due-date",
"estimate",
"cycles",
"milestones",
]
);
let patch = IssuePatch {
priority: PatchValue::Set(Priority::High),
..IssuePatch::default()
};
assert!(!patch.is_empty(), "one field is enough");
assert!(
patch.structural_problems().is_empty(),
"one field is well-formed"
);
}
#[test]
fn t003_a_field_patch_and_a_pair_in_one_patch_is_refused_rather_than_ordered() {
let both = IssuePatch {
status: PatchValue::Set("wip".to_string()),
relationship: Some(RelationshipOp::link(
"1",
"2",
tix::relationships::RelationKind::Blocks,
)),
..IssuePatch::default()
};
let problems = both.structural_problems();
assert_eq!(problems.len(), 1, "one problem, not two");
let message = &problems[0].message;
assert!(message.contains("field changes"), "{message}");
assert!(message.contains("a relationship operation"), "{message}");
let archive_and_pair = IssuePatch {
archive: Some(tix::bulk::ArchiveRequest::archive()),
relationship: Some(RelationshipOp::unlink(
"1",
"2",
tix::relationships::RelationKind::Blocks,
)),
..IssuePatch::default()
};
assert!(!archive_and_pair.structural_problems().is_empty());
for alone in [
IssuePatch {
status: PatchValue::Set("wip".to_string()),
..IssuePatch::default()
},
IssuePatch {
relationship: Some(RelationshipOp::link(
"1",
"2",
tix::relationships::RelationKind::Blocks,
)),
..IssuePatch::default()
},
IssuePatch {
archive: Some(tix::bulk::ArchiveRequest::archive()),
..IssuePatch::default()
},
] {
assert!(
alone.structural_problems().is_empty(),
"a single kind is well-formed: {alone:?}"
);
}
}
#[test]
fn t004_a_pair_carries_an_explicit_operation_and_both_endpoints() {
let link = RelationshipOp::link("3", "1", tix::relationships::RelationKind::Blocks);
assert!(link.link);
assert_eq!(link.verb(), "link");
assert_eq!((link.from.as_str(), link.to.as_str()), ("3", "1"));
let unlink = RelationshipOp::unlink("1", "3", tix::relationships::RelationKind::Blocks);
assert!(!unlink.link);
assert_eq!(unlink.verb(), "unlink");
assert_eq!((unlink.from.as_str(), unlink.to.as_str()), ("1", "3"));
}
#[test]
fn t004_an_archive_request_carries_a_direction_and_no_semantics_of_its_own() {
let archive = tix::bulk::ArchiveRequest::archive();
let restore = tix::bulk::ArchiveRequest::restore("triage", None);
assert!(archive.archive);
assert!(!restore.archive);
}
#[test]
fn t006_building_a_selection_and_a_patch_writes_nothing() {
let (tmp, store) = store();
let before = snapshot(tmp.path());
let selection = Selection::resolve(
&store,
&["1".to_string(), "2".to_string()],
Some("status:triage"),
SelectionSource::Filter,
SelectionScope::Active,
)
.unwrap();
let patch = IssuePatch {
status: PatchValue::Set("wip".to_string()),
priority: PatchValue::Set(Priority::High),
labels: PatchValue::Clear,
relationship: Some(RelationshipOp::link(
"1",
"2",
tix::relationships::RelationKind::Blocks,
)),
..IssuePatch::default()
};
let _ = (
selection.issues(),
selection.numbers(),
selection.selectors(),
selection.duplicates(),
selection.fingerprint(),
selection.query(),
selection.source(),
selection.scope(),
);
let _ = (
patch.is_empty(),
patch.fields().count(),
patch.structural_problems(),
IssuePatch::field_names(),
patch.relationship.as_ref().map(|r| r.verb()),
patch.archive.map(|a| a.archive),
);
assert_eq!(
snapshot(tmp.path()),
before,
"C-005: selection and patch construction are read-only — no file, no \
History entry, no tix.toml write"
);
}
#[test]
fn t006_both_phases_agree_on_what_an_issue_is() {
let (_tmp, store) = store();
let selection = Selection::resolve(
&store,
&["1".to_string()],
None,
SelectionSource::Explicit,
SelectionScope::Active,
)
.unwrap();
let issue: &Issue = &selection.issues()[0];
assert_eq!(issue.number, 1);
assert_eq!(issue.status.display(), "triage");
let owned = selection.into_issues();
assert_eq!(owned.len(), 1);
}
#[test]
fn t006_the_scope_of_a_selection_is_reported_for_the_plan_header() {
let (_tmp, store) = store();
let _ = &store;
let selection = Selection::resolve(
&store,
&["1".to_string()],
None,
SelectionSource::Explicit,
SelectionScope::Active,
)
.unwrap();
let meta = scope_metadata(selection.scope(), selection.len());
assert_eq!(meta.selected, 1);
assert!(meta.can_write);
assert_eq!(meta.to_string(), "active (1 selected)");
}
#[test]
fn t006_the_module_surface_is_what_the_contract_declares() {
let (_tmp, store) = store();
let selection = Selection::resolve(
&store,
&["1".to_string()],
None,
SelectionSource::Explicit,
SelectionScope::Active,
)
.unwrap();
let path = PathBuf::from(file!());
assert!(
path.ends_with("tests/bulk_selection.rs"),
"this file is the selection test target: {}",
path.display()
);
assert!(!selection.issues().is_empty());
let copy = selection.clone();
assert_eq!(copy, selection);
}