#[allow(clippy::wildcard_imports)]
use super::*;
use crate::config::{EpicConfiguration, ProjectConfiguration};
use crate::model::{Priority, today};
use crate::relationships::DiagnosticKind;
use std::fs;
use tempfile::TempDir;
#[test]
fn write_file_new_refuses_an_existing_target() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("issue.md");
write_file_new(dir.path(), &target, b"first").unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "first");
let err = write_file_new(dir.path(), &target, b"second").unwrap_err();
let TixError::Io(io_err) = err else {
panic!("expected an I/O error, so the caller can retry on a name collision")
};
assert_eq!(
io_err.kind(),
std::io::ErrorKind::AlreadyExists,
"the error must be AlreadyExists, so the caller can retry on a name \\
collision rather than treating it as a hard failure"
);
assert_eq!(
fs::read_to_string(&target).unwrap(),
"first",
"the existing file must be byte-identical — not truncated, not replaced"
);
}
#[test]
fn a_clobbered_name_is_retried_rather_than_failing_or_overwriting() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let store = Store::open(&crate::config::Config {
issues_dir: dir.to_path_buf(),
..Default::default()
})
.unwrap();
let segment_dir = dir.join("seg");
fs::create_dir_all(&segment_dir).unwrap();
for name in ["2026-01-01-race.md", "2026-01-01-race-2.md"] {
fs::write(segment_dir.join(name), "planted").unwrap();
}
let (filename, path) = store
.write_new_issue_file("seg", "2026-01-01-race", b"mine")
.unwrap();
assert_eq!(filename, "seg/2026-01-01-race-3.md");
assert_eq!(fs::read_to_string(&path).unwrap(), "mine");
assert_eq!(
fs::read_to_string(segment_dir.join("2026-01-01-race.md")).unwrap(),
"planted"
);
assert_eq!(
fs::read_to_string(segment_dir.join("2026-01-01-race-2.md")).unwrap(),
"planted"
);
}
#[test]
fn no_temp_files_are_left_behind_on_success_or_collision() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("issue.md");
write_file_new(dir.path(), &target, b"first").unwrap();
let _ = write_file_new(dir.path(), &target, b"second");
let leftovers: Vec<String> = fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n.starts_with(".tix-tmp-"))
.collect();
assert!(
leftovers.is_empty(),
"temp files must not survive, found {leftovers:?}"
);
}
fn make_store() -> (tempfile::TempDir, Store) {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let mut config = Config {
issues_dir: dir.clone(),
..Config::default()
};
config.layout.directory = "{assignee}".to_string();
config.layout.move_on_change = false;
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let store = Store {
issues_dir: dir,
config,
};
(tmp, store)
}
fn write_issue(store: &Store, name: &str, contents: &str) {
fs::write(store.issues_dir.join(name), contents).unwrap();
}
fn sample_frontmatter(number: u32, title: &str, status: &str) -> String {
format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
)
}
#[test]
fn list_never_opens_an_archived_file_but_list_all_does() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-active.md",
&sample_frontmatter(1, "Active", "todo"),
);
fs::write(
store.issues_dir.join("alice/.2026-01-02-broken.md"),
"this file has no frontmatter at all\n",
)
.unwrap();
let active = store
.list()
.expect("the default walk must not read the archived file");
assert_eq!(active.len(), 1);
assert_eq!(active[0].number, 1);
assert!(!active[0].archived);
let all = store.list_all();
assert!(
matches!(all, Err(TixError::Parse(_))),
"list_all() reads it, so the broken file is only invisible to list(): {all:?}"
);
}
#[test]
fn archive_renames_in_place_and_restore_is_the_inverse() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-x.md",
&sample_frontmatter(5, "X", "todo"),
);
let archived = store.archive("5").unwrap();
assert_eq!(archived.filename, "alice/.2026-01-01-x.md");
assert!(archived.archived);
assert!(store.issues_dir.join("alice/.2026-01-01-x.md").is_file());
assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
assert!(
store.list().unwrap().is_empty(),
"hidden from the default view"
);
let all = store.list_all().unwrap();
assert_eq!(all.len(), 1);
assert!(all[0].archived);
assert_eq!(all[0].number, 5, "the number is untouched");
assert_eq!(
store.get(IssueSelector::Number(5)).unwrap().filename,
"alice/.2026-01-01-x.md"
);
assert!(store.get(IssueSelector::Slug(slugify("X"))).is_ok());
let restored = store.restore("5").unwrap();
assert_eq!(restored.filename, "alice/2026-01-01-x.md");
assert!(!restored.archived);
assert_eq!(store.list().unwrap().len(), 1, "visible again");
}
#[test]
fn archive_appends_one_event_and_leaves_the_frontmatter_untouched() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-x.md",
&sample_frontmatter(5, "X", "todo"),
);
let before = store
.get(IssueSelector::Filename("alice/2026-01-01-x.md".into()))
.unwrap();
let archived = store.archive("5").unwrap();
assert_eq!(archived.history.len(), 1, "exactly one History event");
assert_eq!(
archived.history.events()[0].kind,
HistoryEventKind::Archived
);
let after = store.get(IssueSelector::Number(5)).unwrap();
assert_eq!(after.number, before.number);
assert_eq!(after.title, before.title);
assert_eq!(after.status, before.status);
assert_eq!(after.labels, before.labels);
assert_eq!(after.priority, before.priority);
assert_eq!(after.assignee, before.assignee);
assert_eq!(after.created, before.created);
assert_eq!(
after.updated, before.updated,
"archiving must not bump `updated`: it is a frontmatter field"
);
let raw = read_issue_file(&store, "alice/.2026-01-01-x.md");
let fm = raw.split("\n---").next().unwrap();
assert!(
!fm.contains("archived"),
"the archived flag must never reach the frontmatter: {fm}"
);
}
#[test]
fn restore_appends_one_restored_event() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-x.md",
&sample_frontmatter(5, "X", "todo"),
);
store.archive("5").unwrap();
let restored = store.restore("5").unwrap();
assert_eq!(restored.history.len(), 2, "archive + restore");
assert_eq!(
restored.history.events()[1].kind,
HistoryEventKind::Restored
);
}
#[test]
fn archiving_the_highest_number_does_not_free_it_for_reuse() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
write_issue(
&store,
"alice/2026-01-01-b.md",
&sample_frontmatter(7, "B", "todo"),
);
store.archive("7").unwrap();
let mut fresh = Issue::parse(&sample_frontmatter(0, "New", "todo")).unwrap();
store.create(&mut fresh, "body", None).unwrap();
assert_eq!(
fresh.number, 8,
"an archived issue keeps its number: `create` must not reuse #7"
);
}
#[test]
fn redundant_archive_and_restore_are_refused_by_name() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-x.md",
&sample_frontmatter(5, "X", "todo"),
);
let err = store.restore("5").unwrap_err();
assert!(
err.to_string().contains("not_archived"),
"an active issue cannot be restored: {err}"
);
assert!(
store.issues_dir.join("alice/2026-01-01-x.md").is_file(),
"the refused restore writes nothing"
);
store.archive("5").unwrap();
let err = store.archive("5").unwrap_err();
assert!(
err.to_string().contains("already_archived"),
"an archived issue cannot be re-archived: {err}"
);
assert!(
store.issues_dir.join("alice/.2026-01-01-x.md").is_file(),
"the refused archive writes nothing"
);
}
#[test]
fn archive_never_clobbers_an_occupied_target_name() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-x.md",
&sample_frontmatter(5, "X", "todo"),
);
write_issue(
&store,
"alice/.2026-01-01-x.md",
&sample_frontmatter(6, "Other", "todo"),
);
let err = store.archive("5").unwrap_err();
assert!(err.to_string().contains("already exists"), "got: {err}");
assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
assert!(store.issues_dir.join("alice/.2026-01-01-x.md").is_file());
}
#[test]
fn archive_and_restore_a_legacy_flat_file_in_place() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-legacy.md",
&sample_frontmatter(3, "Legacy", "todo"),
);
let archived = store.archive("3").unwrap();
assert_eq!(archived.filename, ".2026-01-01-legacy.md");
assert!(store.issues_dir.join(".2026-01-01-legacy.md").is_file());
assert_eq!(store.list().unwrap(), [] as [crate::model::Issue; 0]);
assert_eq!(store.list_all().unwrap().len(), 1);
let restored = store.restore("3").unwrap();
assert_eq!(restored.filename, "2026-01-01-legacy.md");
assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
}
#[test]
fn list_returns_sorted_by_filename() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-03-01-c.md",
&sample_frontmatter(3, "C", "todo"),
);
write_issue(
&store,
"2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
write_issue(
&store,
"2026-02-01-b.md",
&sample_frontmatter(2, "B", "todo"),
);
let issues = store.list().unwrap();
assert_eq!(issues.len(), 3);
assert_eq!(issues[0].number, 1);
assert_eq!(issues[1].number, 2);
assert_eq!(issues[2].number, 3);
assert_eq!(issues[0].title, "A");
}
#[test]
fn is_issue_rel_path_matches_what_scan_walks() {
for path in [
"a.md",
"alice/b.md",
".hidden.md",
"alice/.hidden.md",
"deep/a/b.md",
] {
assert!(
is_issue_rel_path(Path::new(path)),
"{path} is an issue file `scan` reads"
);
}
for path in [
"notes.txt",
"alice/notes.txt",
"alice",
".tix-tmp-x/a.md",
"foo-assets/a.md",
"alice/foo-assets/a.md",
] {
assert!(
!is_issue_rel_path(Path::new(path)),
"{path} is not a path `scan` indexes"
);
}
}
#[test]
fn scan_above_the_parallel_threshold_is_sorted_and_complete() {
let (tmp, store) = make_store();
let _ = tmp;
let n = Store::PARALLEL_SCAN_THRESHOLD + 44;
for i in (1..=n).rev() {
write_issue(
&store,
&format!("2026-01-01-{i:04}.md"),
&sample_frontmatter(i as u32, &format!("T{i}"), "todo"),
);
}
let issues = store.list().unwrap();
assert_eq!(
issues.len(),
n,
"every file is read, none dropped or doubled"
);
let names: Vec<&str> = issues.iter().map(|i| i.filename.as_str()).collect();
let mut sorted = names.clone();
sorted.sort_unstable();
assert_eq!(names, sorted, "sorted by filename as `scan` promises");
assert_eq!(issues[0].number, 1, "content came from the first file");
assert_eq!(issues[n - 1].number, n as u32, "and from the last");
}
#[test]
fn read_issue_at_sets_filename_and_archived_like_scan() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
write_issue(
&store,
"alice/.2026-01-02-b.md",
&sample_frontmatter(2, "B", "todo"),
);
let active = store
.read_issue_at(Path::new("alice/2026-01-01-a.md"))
.unwrap();
assert_eq!(active.number, 1);
assert_eq!(active.filename, "alice/2026-01-01-a.md");
assert!(!active.archived);
let archived = store
.read_issue_at(Path::new("alice/.2026-01-02-b.md"))
.unwrap();
assert_eq!(archived.filename, "alice/.2026-01-02-b.md");
assert!(
archived.archived,
"the leading dot marks it archived, as `scan` does"
);
write_issue(&store, "flat.md", &sample_frontmatter(3, "F", "todo"));
assert_eq!(
store.read_issue_at(Path::new("flat.md")).unwrap().filename,
"flat.md"
);
assert!(matches!(
store.read_issue_at(Path::new("alice/gone.md")),
Err(TixError::NotFound(_))
));
}
#[test]
fn list_ignores_non_md_files() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
write_issue(&store, "readme.txt", "not an issue");
write_issue(&store, "2026-01-01-sub", "no extension");
let issues = store.list().unwrap();
assert_eq!(issues.len(), 1);
}
#[test]
fn list_errors_on_malformed_frontmatter() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(&store, "2026-01-01-bad.md", "no frontmatter here\n");
let err = store.list().unwrap_err();
assert!(matches!(err, TixError::Parse(_)), "got {err:?}");
}
#[test]
fn get_by_number() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-x.md",
&sample_frontmatter(5, "X", "todo"),
);
let issue = store.get(IssueSelector::Number(5)).unwrap();
assert_eq!(issue.title, "X");
}
#[test]
fn get_by_filename() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-y.md",
&sample_frontmatter(2, "Y", "done"),
);
let issue = store
.get(IssueSelector::Filename("2026-01-01-y.md".into()))
.unwrap();
assert_eq!(issue.status.display(), "done");
}
#[test]
fn get_by_slug() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-z.md",
&sample_frontmatter(3, "Z", "todo"),
);
let issue = store.get(IssueSelector::Slug("z".into())).unwrap();
assert_eq!(issue.number, 3);
}
#[test]
fn get_not_found() {
let (tmp, store) = make_store();
let _ = tmp;
assert!(store.get(IssueSelector::Number(99)).is_err());
}
#[test]
fn create_assigns_number_from_live_issues_and_leaves_config_untouched() {
let (tmp, store) = make_store();
let _ = tmp;
assert_eq!(store.config.state.next_number, 0);
let mut issue = Issue {
projects: None,
id: None,
number: 0,
title: "First".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
let path = store.create(&mut issue, "Hello world", None).unwrap();
assert!(path.exists());
assert_eq!(
issue.number, 1,
"an empty store starts at 1, not 0 — #0 is indistinguishable from a \
sentinel or a failed read (tix #1376)"
);
let config_path = tmp.path().join("tix.toml");
let on_disk = std::fs::read_to_string(&config_path).unwrap();
assert!(
!on_disk.contains("next_number"),
"create must not persist an issue-number counter:\n{on_disk}"
);
let reloaded = Config::load_with_warning(&config_path, |_| {}).unwrap();
assert_eq!(reloaded.state.next_number, 0, "state is never written");
}
#[test]
fn create_resolves_max_live_number_plus_one() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-a.md",
&sample_frontmatter(0, "A", "todo"),
);
write_issue(
&store,
"2026-01-01-b.md",
&sample_frontmatter(1, "B", "todo"),
);
write_issue(
&store,
"2026-01-01-c.md",
&sample_frontmatter(2, "C", "todo"),
);
let mut issue = Issue {
projects: None,
id: None,
number: 0,
title: "D".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
store.create(&mut issue, "", None).unwrap();
assert_eq!(issue.number, 3);
}
#[test]
fn create_reuses_number_of_deleted_highest_issue() {
let (_tmp, store) = make_store();
let mut issue_a = Issue {
projects: None,
id: None,
number: 0,
title: "A".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
store.create(&mut issue_a, "", None).unwrap();
let filename = issue_a.filename.clone();
assert_eq!(issue_a.number, 1, "the first issue in a fresh store is #1");
store.delete(&filename).unwrap();
let mut issue_b = Issue {
projects: None,
id: None,
number: 0,
title: "B".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-02".into(),
type_: None,
updated: "2026-01-02".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
store.create(&mut issue_b, "", None).unwrap();
assert_eq!(
issue_b.number, 1,
"an emptied store resumes at the same floor as a fresh one: 1"
);
}
#[test]
fn create_with_custom_filename_stem() {
let (tmp, store) = make_store();
let _ = tmp;
let mut issue = Issue {
projects: None,
id: None,
number: 0,
title: "T".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
let path = store
.create(&mut issue, "body", Some("custom-stem"))
.unwrap();
assert!(path.to_string_lossy().contains("custom-stem.md"));
}
#[test]
fn create_ensures_issues_dir_exists() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("nested").join("issues");
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,
};
let mut issue = Issue {
projects: None,
id: None,
number: 0,
title: "T".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
store.create(&mut issue, "", None).unwrap();
assert_eq!(issue.number, 1, "the first issue in a fresh store is #1");
}
#[test]
fn update_replaces_content_atomically() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-u.md",
&sample_frontmatter(1, "U", "todo"),
);
let mut issue = store
.get(IssueSelector::Filename("2026-01-01-u.md".into()))
.unwrap();
issue.title = "Updated".into();
issue.bump_updated();
store.update("2026-01-01-u.md", &issue).unwrap();
let reloaded = store
.get(IssueSelector::Filename("2026-01-01-u.md".into()))
.unwrap();
assert_eq!(reloaded.title, "Updated");
}
#[test]
fn update_not_found() {
let (tmp, store) = make_store();
let _ = tmp;
let issue = Issue {
projects: None,
id: None,
number: 1,
title: "T".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
assert!(store.update("nope.md", &issue).is_err());
}
#[test]
fn delete_removes_file() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-d.md",
&sample_frontmatter(1, "D", "todo"),
);
assert!(store.issues_dir.join("2026-01-01-d.md").exists());
store.delete("2026-01-01-d.md").unwrap();
assert!(!store.issues_dir.join("2026-01-01-d.md").exists());
}
#[test]
fn delete_not_found() {
let (tmp, store) = make_store();
let _ = tmp;
assert!(store.delete("nope.md").is_err());
}
#[test]
fn stray_tmp_file_not_treated_as_issue() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
fs::write(store.issues_dir.join(".tix-tmp-9999-0"), "orphaned data").unwrap();
let issues = store.list().unwrap();
assert_eq!(issues.len(), 1);
}
fn seam_store() -> (tempfile::TempDir, Store) {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let mut config = Config {
issues_dir: dir.clone(),
kanban: crate::config::KanbanSection {
configurations: indexmap::IndexMap::from([(
"default".to_string(),
crate::config::BoardConfiguration {
columns: vec![
"triage".into(),
"todo".into(),
"doing".into(),
"done".into(),
],
done_column: Some("done".to_string()),
category_map: indexmap::IndexMap::from([
("triage".to_string(), "backlog".to_string()),
("todo".to_string(), "unstarted".to_string()),
("doing".to_string(), "active".to_string()),
("done".to_string(), "completed".to_string()),
]),
..Default::default()
},
)]),
},
..Config::default()
};
config.layout.move_on_change = false;
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
(
tmp,
Store {
issues_dir: dir,
config,
},
)
}
fn seam_issue(status: &str) -> (tempfile::TempDir, Store, String) {
let (tmp, store) = seam_store();
write_issue(
&store,
"2026-01-01-seam.md",
&format!(
"---\nnumber: 1\ntitle: \"Seam\"\nstatus: {status}\nlabels: []\n\
priority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody."
),
);
let before = read_issue_file(&store, "2026-01-01-seam.md");
(tmp, store, before)
}
fn assert_untouched(store: &Store, before: &str, what: &str) {
let after = read_issue_file(store, "2026-01-01-seam.md");
assert_eq!(after, before, "{what}: the file must be byte-identical");
}
#[test]
fn t015_accepted_transition_writes_status_bumps_updated_and_appends_one_decision() {
let (_tmp, store, _before) = seam_issue("todo");
let outcome = store
.set_status("2026-01-01-seam.md", StatusChange::to("doing"))
.unwrap();
assert!(outcome.written, "an accepted, changing move writes");
let decision = outcome
.decision
.clone()
.expect("an accepted transition appends exactly one decision");
assert_eq!(decision.from, "todo");
assert_eq!(decision.to, "doing");
assert_eq!(decision.from_category, "unstarted");
assert_eq!(decision.to_category, "active");
assert!(
decision.override_reason.is_none(),
"no override was supplied"
);
assert_eq!(
outcome.issue.status.display(),
"doing",
"Issue.status holds the COLUMN name, never a category (C-001)"
);
assert_eq!(outcome.issue.updated, today(), "`updated` is bumped");
let reloaded = store
.get(IssueSelector::Filename("2026-01-01-seam.md".into()))
.unwrap();
assert_eq!(reloaded.status.display(), "doing");
let events = reloaded.history.events();
assert_eq!(
events.len(),
1,
"exactly one History entry, and it is the decision: {events:?}"
);
assert_eq!(events[0].kind, HistoryEventKind::TransitionAccepted);
assert_eq!(
crate::model::TransitionDecision::from_event(events[0])
.unwrap()
.to,
"doing",
"and the decision round-trips out of the file with its fields intact"
);
}
#[test]
fn t015_unknown_column_is_refused_by_name_and_writes_nothing() {
let (_tmp, store, before) = seam_issue("todo");
let err = store
.set_status("2026-01-01-seam.md", StatusChange::to("nope"))
.unwrap_err();
assert!(
matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "unknown_column"),
"the shared named diagnostic, contract §6: {err:?}"
);
assert!(
err.to_string()
.contains("valid columns: triage, todo, doing, done"),
"in BOARD order, not hash order: {err}"
);
assert_untouched(&store, &before, "unknown column");
}
#[test]
fn t015_terminal_exit_is_refused_and_writes_nothing() {
let (_tmp, store, before) = seam_issue("done");
let err = store
.set_status("2026-01-01-seam.md", StatusChange::to("todo"))
.unwrap_err();
assert!(
matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
"{err:?}"
);
assert_untouched(&store, &before, "terminal exit without a reason");
}
#[test]
fn t015_adjacency_restricted_backwards_move_is_refused_and_writes_nothing() {
let (_tmp, store, before) = seam_issue("doing");
let err = store
.set_status("2026-01-01-seam.md", StatusChange::to("triage"))
.unwrap_err();
assert!(
matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
"{err:?}"
);
assert!(
err.to_string().contains("category policy"),
"the message names the rule that refused it: {err}"
);
assert_untouched(&store, &before, "adjacency refusal");
}
#[test]
fn t015_terminal_exit_with_a_reason_is_accepted_and_the_reason_is_recorded() {
let (_tmp, store, before) = seam_issue("done");
let outcome = store
.set_status(
"2026-01-01-seam.md",
StatusChange::to("todo").with_override(Some("reopening: wrong epic")),
)
.unwrap();
assert!(outcome.written);
let decision = outcome.decision.expect("one decision");
assert_eq!(
decision.override_reason.as_deref(),
Some("reopening: wrong epic"),
"an override with no recorded reason is not an audit record"
);
assert_ne!(
read_issue_file(&store, "2026-01-01-seam.md"),
before,
"sanity: an accepted move must have changed the file at all"
);
}
#[test]
fn t015_a_blank_reason_is_not_an_override() {
let (_tmp, store, before) = seam_issue("done");
let err = store
.set_status(
"2026-01-01-seam.md",
StatusChange::to("todo").with_override(Some(" ")),
)
.unwrap_err();
assert!(
matches!(&err, TixError::Workflow(_)),
"a blank reason is not a reason: {err:?}"
);
assert_untouched(&store, &before, "blank override reason");
}
#[test]
fn t015_same_column_is_accepted_and_writes_nothing() {
let (_tmp, store, before) = seam_issue("doing");
let outcome = store
.set_status("2026-01-01-seam.md", StatusChange::to("doing"))
.unwrap();
assert!(!outcome.written, "a same-column re-apply is a no-op");
assert!(
outcome.decision.is_none(),
"and appends no decision, contract §3.2"
);
assert_untouched(&store, &before, "same column");
let reloaded = store
.get(IssueSelector::Filename("2026-01-01-seam.md".into()))
.unwrap();
assert_eq!(
reloaded.updated, "2026-01-01",
"the fixture's original date"
);
}
#[test]
fn t015_no_new_frontmatter_key_was_introduced() {
let (_tmp, store, before) = seam_issue("todo");
store
.set_status("2026-01-01-seam.md", StatusChange::to("doing"))
.unwrap();
let after = read_issue_file(&store, "2026-01-01-seam.md");
let keys = |text: &str| -> Vec<String> {
text.split("---\n")
.nth(1)
.unwrap()
.lines()
.filter_map(|l| l.split_once(':').map(|(k, _)| k.trim().to_string()))
.filter(|k| !k.is_empty())
.collect()
};
assert_eq!(
keys(&after),
keys(&before),
"the seam must not add or remove a top-level frontmatter key"
);
}
#[test]
fn t015_a_hand_edited_inconsistent_file_is_read_and_not_repaired() {
let (_tmp, store, before) = seam_issue("doing");
let edited = before.replace("status: doing", "status: triage");
write_issue(&store, "2026-01-01-seam.md", &edited);
let after_edit = read_issue_file(&store, "2026-01-01-seam.md");
let issue = store
.get(IssueSelector::Filename("2026-01-01-seam.md".into()))
.unwrap();
assert_eq!(
issue.status.display(),
"triage",
"a hand-edited status is read, not rejected at load"
);
assert_eq!(
read_issue_file(&store, "2026-01-01-seam.md"),
after_edit,
"reading must not repair it"
);
store
.set_status("2026-01-01-seam.md", StatusChange::to("doing"))
.unwrap();
let moved = store
.get(IssueSelector::Filename("2026-01-01-seam.md".into()))
.unwrap();
assert_eq!(moved.status.display(), "doing");
assert_eq!(
moved.history.events().len(),
1,
"only the new decision; nothing was written to explain the old one"
);
}
#[test]
fn t015_every_rejection_kind_is_one_of_the_contract_vocabulary() {
let allowed = [
"missing_completion_category",
"unknown_category",
"mapping_for_unknown_column",
"unmapped_column_default",
"invalid_transition",
"unknown_column",
"direct_edit_policy_violation",
];
let cases: Vec<(&str, &str)> = vec![
("todo", "nope"), ("done", "todo"), ("doing", "triage"), ];
for (from, to) in cases {
let (_tmp, store, _before) = seam_issue(from);
let err = store
.set_status("2026-01-01-seam.md", StatusChange::to(to))
.unwrap_err();
let TixError::Workflow(d) = &err else {
panic!("{from} -> {to} must refuse through the workflow variant: {err:?}");
};
assert!(
allowed.contains(&d.kind.as_str()),
"`{}` is not in the contract's vocabulary",
d.kind.as_str()
);
}
}
#[test]
fn t015_a_legacy_board_gains_no_policy_but_keeps_completion() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-legacy.md",
&sample_frontmatter(1, "Legacy", "done"),
);
let moved = store
.set_status("2026-01-01-legacy.md", StatusChange::to("todo"))
.unwrap();
assert!(moved.written, "a legacy board imposes no policy");
assert_eq!(moved.issue.status.display(), "todo");
let resolution =
crate::workflow::resolution(&store.config, store.config.default_config(), "default");
assert!(
!resolution.explicit_policy,
"precondition: this board declared no category_map"
);
assert!(
resolution.has_completion(),
"but it still has a done column"
);
assert!(
!crate::workflow::is_completed(&moved.issue, &resolution),
"and `todo` is not complete"
);
}
#[test]
fn t015_a_mapped_board_keeps_the_commit_coupling() {
let (_tmp, store, _before) = seam_issue("doing");
store
.set_status(
"2026-01-01-seam.md",
StatusChange::to("done").with_commit(Some("abc123")),
)
.unwrap();
let at_done = store
.get(IssueSelector::Filename("2026-01-01-seam.md".into()))
.unwrap();
assert_eq!(at_done.commit.as_deref(), Some("abc123"));
store
.set_status(
"2026-01-01-seam.md",
StatusChange::to("todo").with_override(Some("not actually finished")),
)
.unwrap();
let off_done = store
.get(IssueSelector::Filename("2026-01-01-seam.md".into()))
.unwrap();
assert_eq!(
off_done.commit, None,
"leaving completion clears the recorded commit"
);
}
#[test]
fn t015_a_decision_round_trips_through_a_file_unchanged() {
let (_tmp, store, _before) = seam_issue("todo");
store
.set_status("2026-01-01-seam.md", StatusChange::to("doing"))
.unwrap();
let first = store
.get(IssueSelector::Filename("2026-01-01-seam.md".into()))
.unwrap();
let text = read_issue_file(&store, "2026-01-01-seam.md");
let parsed = Issue::parse(&text).unwrap();
let events = parsed.history.events();
let decision = crate::model::TransitionDecision::from_event(events[0]).unwrap();
assert_eq!(decision.to, "doing");
assert_eq!(decision.from_category, "unstarted");
assert_eq!(decision.to_category, "active");
assert_eq!(
parsed.to_file_contents(),
text,
"a parsed decision must re-render to the same bytes"
);
assert_eq!(parsed.history.events().len(), first.history.events().len());
}
#[test]
fn t015_two_transitions_append_two_decisions_in_order() {
let (_tmp, store, _before) = seam_issue("triage");
for to in ["todo", "doing"] {
store
.set_status("2026-01-01-seam.md", StatusChange::to(to))
.unwrap();
}
let after = store
.get(IssueSelector::Filename("2026-01-01-seam.md".into()))
.unwrap();
let events = after.history.events();
assert_eq!(events.len(), 2, "one per accepted transition");
let tos: Vec<String> = events
.iter()
.map(|e| crate::model::TransitionDecision::from_event(e).unwrap().to)
.collect();
assert_eq!(tos, vec!["todo", "doing"], "append order, not sorted");
}
#[test]
fn set_status_changes_status_and_bumps_updated() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-s.md",
&sample_frontmatter(1, "S", "todo"),
);
let outcome = store
.set_status("2026-01-01-s.md", StatusChange::to("in progress"))
.unwrap();
let issue = &outcome.issue;
assert_eq!(issue.status.display(), "in progress");
assert_eq!(issue.updated, today());
assert_eq!(issue.title, "S");
assert_eq!(issue.body, "Body text.");
}
#[test]
fn set_status_preserves_file_path() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-f.md",
&sample_frontmatter(1, "F", "todo"),
);
let _ = store
.set_status("2026-01-01-f.md", StatusChange::to("done"))
.unwrap();
assert!(store.issues_dir.join("2026-01-01-f.md").exists());
}
fn read_issue_file(store: &Store, filename: &str) -> String {
std::fs::read_to_string(store.issues_dir.join(filename)).unwrap()
}
fn bare_frontmatter(number: u32, title: &str) -> String {
format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
)
}
fn legacy_links_frontmatter(number: u32, title: &str, links: &str) -> String {
format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\nlinks: {links}\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
)
}
fn related_pair() -> (TempDir, Store, Issue, Issue) {
let (tmp, store) = make_store();
write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
let a = store.get(IssueSelector::Number(1)).unwrap();
let b = store.get(IssueSelector::Number(2)).unwrap();
(tmp, store, a, b)
}
#[test]
fn t009_link_records_a_single_directed_edge() {
let (_tmp, store, a, b) = related_pair();
let outcome = store
.link(
&a.number.to_string(),
&b.number.to_string(),
RelationKind::Blocks,
)
.unwrap();
assert!(outcome.changed);
let a_after = store.get(IssueSelector::Number(1)).unwrap();
let b_after = store.get(IssueSelector::Number(2)).unwrap();
assert_eq!(a_after.relationships.len(), 1);
assert!(
b_after.relationships.is_empty(),
"no back-reference is written for a directed edge"
);
assert_eq!(a_after.relationships[0].kind, RelationKind::Blocks);
assert_eq!(
(
a_after.relationships[0].from.as_number(),
a_after.relationships[0].to.as_number()
),
(Some(1), Some(2))
);
}
#[test]
fn t009_link_appends_exactly_one_history_event() {
let (_tmp, store, a, b) = related_pair();
store
.link(
&a.number.to_string(),
&b.number.to_string(),
RelationKind::Blocks,
)
.unwrap();
let a_after = store.get(IssueSelector::Number(1)).unwrap();
assert_eq!(a_after.history.len(), 1);
assert_eq!(
a_after.history.events()[0].kind,
HistoryEventKind::RelationshipAdded
);
assert_ne!(a_after.history.events()[0].actor, "");
}
#[test]
fn t009_idempotent_reapply_writes_nothing_and_appends_no_second_event() {
let (_tmp, store, a, b) = related_pair();
let args = (a.number.to_string(), b.number.to_string());
store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
let first = store.get(IssueSelector::Number(1)).unwrap();
let bytes_after_first = read_issue_file(&store, &first.filename);
let second = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
assert!(!second.changed, "re-applying an existing edge is a no-op");
assert!(second.written.is_none(), "no file is written for a no-op");
let after = store.get(IssueSelector::Number(1)).unwrap();
assert_eq!(after.relationships.len(), 1, "no duplicate edge");
assert_eq!(after.history.len(), 1, "no second History event");
assert_eq!(
read_issue_file(&store, &after.filename),
bytes_after_first,
"the file is byte-identical after the no-op"
);
}
#[test]
fn t009_inverse_spelling_resolves_to_the_same_stored_edge() {
let (_tmp, store, a, b) = related_pair();
let args = (a.number.to_string(), b.number.to_string());
store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
let again = store
.link(&args.1, &args.0, RelationKind::BlockedBy)
.unwrap();
assert!(!again.changed, "the inverse spelling must not add an edge");
let after = store.get(IssueSelector::Number(1)).unwrap();
assert_eq!(after.relationships.len(), 1);
assert_eq!(
after.relationships[0].kind,
RelationKind::Blocks,
"the stored kind is always the canonical one"
);
}
#[test]
fn t009_self_link_is_refused_and_writes_nothing() {
let (_tmp, store, a, _b) = related_pair();
let before = read_issue_file(&store, &a.filename);
let err = store
.link(
&a.number.to_string(),
&a.number.to_string(),
RelationKind::Blocks,
)
.unwrap_err();
assert!(matches!(err, TixError::Refused(_)));
assert!(
err.to_string().contains("self_link"),
"the diagnostic names the rule: {err}"
);
assert_eq!(
read_issue_file(&store, &a.filename),
before,
"SC-005: a rejected mutation leaves the file byte-identical"
);
}
#[test]
fn t009_cycle_closing_edge_is_refused_and_writes_nothing() {
let (tmp, store) = make_store();
write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
write_issue(&store, "2026-01-01-c.md", &bare_frontmatter(3, "C"));
let _ = tmp;
store.link("1", "2", RelationKind::Blocks).unwrap();
store.link("2", "3", RelationKind::Blocks).unwrap();
let c_before = read_issue_file(&store, "2026-01-01-c.md");
let err = store.link("3", "1", RelationKind::Blocks).unwrap_err();
assert!(err.to_string().contains("cycle"), "{err}");
assert_eq!(
read_issue_file(&store, "2026-01-01-c.md"),
c_before,
"SC-005: both files of a rejected two-issue mutation are unchanged"
);
}
#[test]
fn t009_unlink_removes_the_edge_and_is_idempotent() {
let (_tmp, store, a, b) = related_pair();
let args = (a.number.to_string(), b.number.to_string());
store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
let removed = store
.unlink(&args.0, &args.1, RelationKind::Blocks)
.unwrap();
assert!(removed.changed);
let after = store.get(IssueSelector::Number(1)).unwrap();
assert_eq!(
after.relationships,
[] as [crate::relationships::Relationship; 0]
);
assert_eq!(after.history.len(), 2, "added then removed");
let again = store
.unlink(&args.0, &args.1, RelationKind::Blocks)
.unwrap();
assert!(!again.changed);
let after2 = store.get(IssueSelector::Number(1)).unwrap();
assert_eq!(after2.history.len(), 2, "no third History event");
}
#[test]
fn t009_unlink_of_a_never_linked_pair_is_a_noop() {
let (_tmp, store, a, b) = related_pair();
let outcome = store
.unlink(
&a.number.to_string(),
&b.number.to_string(),
RelationKind::Blocks,
)
.unwrap();
assert!(!outcome.changed);
assert!(outcome.written.is_none());
let after = store.get(IssueSelector::Number(1)).unwrap();
assert_eq!(
after.relationships,
[] as [crate::relationships::Relationship; 0]
);
assert!(after.history.is_empty(), "a no-op appends nothing");
}
#[test]
fn t009_set_relationships_replaces_the_set_and_keeps_surviving_ids() {
let (_tmp, store, _a, _b) = related_pair();
store.link("1", "2", RelationKind::Blocks).unwrap();
let before = store.get(IssueSelector::Number(1)).unwrap();
let survivor_id = before.relationships[0].id.clone();
let updated = store
.set_relationships(&before.filename, &[(2, RelationKind::Blocks)])
.unwrap();
assert_eq!(updated.relationships.len(), 1);
assert_eq!(
updated.relationships[0].id, survivor_id,
"a surviving relationship keeps its stable id"
);
}
#[test]
fn t009_set_relationships_rejects_unknown_target_and_self() {
let (_tmp, store, a, _b) = related_pair();
assert!(
store
.set_relationships(&a.filename, &[(404, RelationKind::Blocks)])
.is_err(),
"an unknown target is refused"
);
assert!(
store
.set_relationships(&a.filename, &[(1, RelationKind::Blocks)])
.is_err(),
"a self-link is refused"
);
}
fn seeded(store: &Store, number: u32, title: &str, extra: &str) {
let slug = title.to_lowercase();
write_issue(
store,
&format!("2026-01-01-{slug}.md"),
&format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\n{extra}files: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
),
);
}
#[test]
fn f1_set_relationships_refuses_a_reciprocal_pair_the_service_rejects() {
let (_tmp, store) = make_store();
seeded(&store, 1, "A", "");
seeded(&store, 2, "B", "");
store.link("1", "2", RelationKind::Blocks).unwrap();
let err = store
.set_relationships("2026-01-01-b.md", &[(1, RelationKind::Blocks)])
.unwrap_err();
assert!(
err.to_string().contains("cycle"),
"the service's verdict is what refuses it: {err}"
);
let two = store.get(IssueSelector::Number(2)).unwrap();
assert!(
two.relationships.is_empty(),
"SC-005: a refused mutation writes nothing"
);
}
#[test]
fn f4_a_hand_ordered_related_row_is_removable() {
let (_tmp, store) = make_store();
seeded(&store, 2, "B", "");
seeded(
&store,
5,
"E",
"relationships:\n- id: rel_handauthored\n from: 5\n to: 2\n kind: related\n display: \"related #2 B\"\n",
);
let out = store.unlink("5", "2", RelationKind::Related).unwrap();
assert!(out.changed, "the row is found and removed");
assert_eq!(
store.get(IssueSelector::Number(5)).unwrap().relationships,
[] as [crate::relationships::Relationship; 0]
);
}
#[test]
fn f5_a_persisted_blocked_by_row_is_removable() {
let (_tmp, store) = make_store();
seeded(&store, 1, "A", "");
seeded(
&store,
2,
"B",
"relationships:\n- id: rel_bb\n from: 2\n to: 1\n kind: blocked_by\n display: \"blocks #1 A\"\n",
);
let out = store.unlink("2", "1", RelationKind::BlockedBy).unwrap();
assert!(out.changed, "the row is found and removed");
assert_eq!(
store.get(IssueSelector::Number(2)).unwrap().relationships,
[] as [crate::relationships::Relationship; 0]
);
}
#[test]
fn f13_a_dangling_edge_is_removable() {
let (_tmp, store) = make_store();
seeded(&store, 1, "A", "");
seeded(&store, 2, "B", "");
store.link("1", "2", RelationKind::Blocks).unwrap();
std::fs::remove_file(store.issues_dir.join("2026-01-01-b.md")).unwrap();
let out = store.unlink("1", "2", RelationKind::Blocks).unwrap();
assert!(out.changed, "a dangling edge is still removable");
let one = store.get(IssueSelector::Number(1)).unwrap();
assert_eq!(
one.relationships,
[] as [crate::relationships::Relationship; 0]
);
assert_eq!(one.history.len(), 2, "and the removal is recorded");
}
#[test]
fn m10_one_unlink_of_a_duplicated_edge_appends_one_event() {
let (_tmp, store) = make_store();
seeded(
&store,
1,
"A",
"relationships:\n- id: rel_dup_12\n from: 1\n to: 2\n kind: related\n display: \"related #2 B\"\n",
);
seeded(
&store,
2,
"B",
"relationships:\n- id: rel_dup_21\n from: 2\n to: 1\n kind: related\n display: \"related #1 A\"\n",
);
let outcome = store.unlink("1", "2", RelationKind::Related).unwrap();
assert!(outcome.changed, "both redundant rows are removed");
let mut summaries = Vec::new();
for number in [1u32, 2] {
let issue = store.get(IssueSelector::Number(number)).unwrap();
assert!(
issue.relationships.is_empty(),
"issue #{number} keeps no edge"
);
assert_eq!(
issue.history.len(),
1,
"issue #{number} records exactly one event, got {:?}",
issue.history.events()
);
let event = &issue.history.events()[0];
assert!(
event.detail.contains("removed 2 relationships"),
"and the event says both spellings went: {}",
event.detail
);
summaries.push(event.detail.clone());
}
assert_eq!(
summaries[0], summaries[1],
"both files record the same single event, not two differing ones"
);
}
#[test]
fn m11_unlink_reports_the_row_it_removed_not_an_empty_placeholder() {
let (_tmp, store, a, b) = related_pair();
let args = (a.number.to_string(), b.number.to_string());
store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
let before = store.get(IssueSelector::Number(1)).unwrap();
let real_id = before.relationships[0].id.clone();
let outcome = store
.unlink(&args.0, &args.1, RelationKind::Blocks)
.unwrap();
assert!(outcome.changed);
assert_eq!(
outcome.relationship.id, real_id,
"the reported row is the one that was actually removed"
);
assert!(!outcome.relationship.id.is_empty(), "so it is addressable");
assert_eq!(
outcome.relationship.display,
before.relationships[0].display
);
}
#[test]
fn m15_the_history_detail_names_the_relationship_id() {
let (_tmp, store, a, b) = related_pair();
let args = (a.number.to_string(), b.number.to_string());
store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
let one = store.get(IssueSelector::Number(1)).unwrap();
let id = one.relationships[0].id.clone();
let detail = &one.history.events()[0].detail;
assert!(
detail.starts_with(&format!("created {id}:")),
"the detail names the stable id: {detail}"
);
assert!(
!detail.contains(&format!("created {}:", RelationKind::Blocks)),
"and does not stutter the kind, which `display` already carries: {detail}"
);
}
#[test]
fn f17_all_diagnostics_are_reported_not_just_the_first() {
let (_tmp, store) = make_store();
seeded(
&store,
1,
"A",
"relationships:\n- id: rel_b12\n from: 1\n to: 2\n kind: blocks\n display: blocks\n- id: rel_d12\n from: 1\n to: 2\n kind: duplicates\n display: dup\n",
);
seeded(
&store,
2,
"B",
"relationships:\n- id: rel_b21\n from: 2\n to: 1\n kind: blocks\n display: blocks\n",
);
let issues = store.list().unwrap();
let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
let verdict = RelationshipGraph::new(&views, "done").validate_snapshot();
assert!(
verdict.errors.len() >= 2,
"the fixture must really produce several diagnostics, got {:?}",
verdict.errors
);
let rendered = relationship_error(&verdict.errors).to_string();
for kind in ["conflicting_edge", "cycle"] {
assert!(
rendered.contains(kind),
"every diagnostic must be reported; {kind} missing from: {rendered}"
);
}
assert!(
!rendered.contains("conflicting_edge: conflicting_edge")
&& !rendered.contains("cycle: cycle:"),
"no doubled kind prefix: {rendered}"
);
let dup = vec![
Diagnostic::new(
DiagnosticKind::SelfLink,
"to",
"#1 cannot block itself: a self-link is not a valid relationship",
),
Diagnostic::new(
DiagnosticKind::SelfLink,
"to",
"#1 cannot block itself: a self-link is not a valid relationship",
),
Diagnostic::new(DiagnosticKind::Cycle, "to", "cycle: 1 -> 1 -> #1"),
];
let rendered = relationship_error(&dup).to_string();
assert_eq!(
rendered.matches("cannot block itself").count(),
1,
"an identical diagnostic renders once, not twice: {rendered}"
);
assert!(
rendered.contains("cycle"),
"a distinct diagnostic is still reported: {rendered}"
);
assert!(
!rendered.contains("self_link: self_link") && !rendered.contains("cycle: cycle:"),
"and no kind is prefixed twice: {rendered}"
);
}
#[test]
fn t009_clearing_a_set_removes_a_related_edge_owned_by_another_file() {
let (_tmp, store) = make_store();
write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(3, "A"));
write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(5, "B"));
store.link("5", "3", RelationKind::Related).unwrap();
let three = store.get(IssueSelector::Number(3)).unwrap();
let five = store.get(IssueSelector::Number(5)).unwrap();
assert_eq!(
three.relationships.len(),
1,
"the edge is stored in the lower-numbered issue's file"
);
assert!(
five.relationships.is_empty(),
"and #5's own file holds none"
);
store.set_relationships(&five.filename, &[]).unwrap();
let three_after = store.get(IssueSelector::Number(3)).unwrap();
let five_after = store.get(IssueSelector::Number(5)).unwrap();
assert!(
three_after.relationships.is_empty(),
"the association is gone from the file that actually stored it"
);
assert_eq!(
five_after.relationships,
[] as [crate::relationships::Relationship; 0]
);
}
#[test]
fn t009_clearing_a_set_does_not_remove_an_inbound_directional_edge() {
let (_tmp, store) = make_store();
write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(3, "A"));
write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(5, "B"));
store.link("3", "5", RelationKind::Blocks).unwrap();
let five = store.get(IssueSelector::Number(5)).unwrap();
store.set_relationships(&five.filename, &[]).unwrap();
let three_after = store.get(IssueSelector::Number(3)).unwrap();
assert!(
three_after
.relationships
.iter()
.any(|r| r.to == 5 && r.kind == RelationKind::Blocks),
"#3's declaration that it blocks #5 survives: a blocked issue \
cannot unilaterally clear the blocker's graph"
);
}
fn relationship_fixture(name: &str) -> String {
format!(
"{}/tests/fixtures/relationships/{name}",
env!("CARGO_MANIFEST_DIR")
)
}
#[test]
fn t010_typed_fixture_round_trips_byte_stable_including_history() {
let original = std::fs::read_to_string(relationship_fixture("typed-relationships.md")).unwrap();
let issue = Issue::parse(&original).unwrap();
assert_eq!(issue.relationships.len(), 2);
assert_eq!(issue.history.len(), 2);
let rendered = issue.to_file_contents();
assert_eq!(
rendered, original,
"a typed fixture must not churn on a read/write round trip"
);
assert_eq!(
Issue::parse(&rendered).unwrap().to_file_contents(),
rendered
);
assert_eq!(issue.relationships[0].id, "rel_01JQ8Z4W2M6N7P9R3S5T7V9X1");
assert!(
rendered.find("## History").unwrap() < rendered.find("Prose that follows").unwrap(),
"History sits between the frontmatter and the body"
);
}
#[test]
fn t010_issue_with_no_relationship_data_is_untouched() {
let original =
std::fs::read_to_string(relationship_fixture("no-relationship-data.md")).unwrap();
let issue = Issue::parse(&original).unwrap();
assert_eq!(
issue.relationships,
[] as [crate::relationships::Relationship; 0]
);
assert!(issue.history.is_empty());
assert!(!issue.has_retired_links_key);
let rendered = issue.to_file_contents();
assert_eq!(
rendered, original,
"an issue with no relationship data must not churn"
);
assert!(!rendered.contains("relationships:"));
assert!(!rendered.contains("## History"));
}
#[test]
fn t010_an_empty_history_section_is_dropped_not_preserved() {
let original = std::fs::read_to_string(relationship_fixture("history-empty.md")).unwrap();
assert!(
original.contains("## History"),
"the fixture must actually carry the empty heading"
);
let issue = Issue::parse(&original).unwrap();
assert!(
issue.history.is_empty(),
"a heading with no entries parses to an empty log"
);
assert!(
issue
.body
.contains("An issue whose History section is present but empty."),
"the prose after the heading is body, not a dropped event: {:?}",
issue.body
);
let rendered = issue.to_file_contents();
assert!(
!rendered.contains("## History"),
"the empty section is omitted rather than kept as a bare heading"
);
assert!(rendered.contains("An issue whose History section is present but empty."));
}
#[test]
fn t010_issue_with_no_history_section_is_untouched() {
let original = std::fs::read_to_string(relationship_fixture("history-absent.md")).unwrap();
let issue = Issue::parse(&original).unwrap();
assert!(issue.history.is_empty());
let rendered = issue.to_file_contents();
assert_eq!(
rendered, original,
"a file written before History existed must not gain an empty section"
);
}
#[test]
fn f2_one_bad_history_line_does_not_make_the_store_unreadable() {
let (_tmp, store) = make_store();
write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
write_issue(
&store,
"2026-01-01-c.md",
"---\nnumber: 3\ntitle: \"C\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\n## History\n\n- 2026-01-01T00:00:00Z - alice - relationship_added - ascii hyphens\n",
);
let all = store.list().expect("the store must remain listable");
assert_eq!(all.len(), 2, "both issues load");
assert_eq!(
store
.get(IssueSelector::Number(1))
.expect("an unrelated, valid issue must stay readable")
.title,
"A"
);
let three = store.get(IssueSelector::Number(3)).unwrap();
assert!(three.history.events().is_empty(), "no event is fabricated");
assert_eq!(
three.history.unparsed().len(),
1,
"the line is preserved for diagnosis: {:?}",
three.history.unparsed()
);
assert!(
!three.body.contains("ascii hyphens"),
"and is not demoted into the body: {:?}",
three.body
);
}
#[cfg(unix)]
fn seeded_in_segment(store: &Store, number: u32, title: &str, segment: &str) {
let dir = store.issues_dir.join(segment);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("2026-01-01-{}.md", title.to_lowercase())),
format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
),
)
.unwrap();
}
#[cfg(unix)]
#[test]
fn f6_a_mid_sequence_write_failure_rolls_every_file_back() {
use std::os::unix::fs::PermissionsExt;
let (tmp, store) = make_store();
let _ = tmp;
seeded_in_segment(&store, 1, "A", "alice");
seeded_in_segment(&store, 2, "B", "bob");
seeded_in_segment(&store, 3, "C", "carol");
seeded_in_segment(&store, 5, "E", "erin");
let paths: Vec<std::path::PathBuf> = [
"alice/2026-01-01-a.md",
"bob/2026-01-01-b.md",
"carol/2026-01-01-c.md",
]
.iter()
.map(|p| store.issues_dir.join(p))
.collect();
let before: Vec<String> = paths
.iter()
.map(|p| std::fs::read_to_string(p).unwrap())
.collect();
let carol = store.issues_dir.join("carol");
let mut perms = std::fs::metadata(&carol).unwrap().permissions();
perms.set_mode(0o555);
std::fs::set_permissions(&carol, perms).unwrap();
let result = store.set_relationships(
"carol/2026-01-01-c.md",
&[
(1, RelationKind::Related),
(2, RelationKind::Related),
(5, RelationKind::Related),
],
);
let mut perms = std::fs::metadata(&carol).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&carol, perms).unwrap();
assert!(result.is_err(), "the blocked write must fail");
for (path, previous) in paths.iter().zip(before.iter()) {
assert_eq!(
std::fs::read_to_string(path).unwrap(),
*previous,
"{} was left mutated by a failed mutation",
path.display()
);
}
}
#[cfg(unix)]
#[test]
fn f6b_the_updated_bump_is_itself_rollback_covered() {
use std::os::unix::fs::PermissionsExt;
let (tmp, store) = make_store();
let _ = tmp;
seeded_in_segment(&store, 1, "A", "alice");
seeded_in_segment(&store, 3, "C", "carol");
let alice = store.issues_dir.join("alice/2026-01-01-a.md");
let before = std::fs::read_to_string(&alice).unwrap();
let carol = store.issues_dir.join("carol");
let mut perms = std::fs::metadata(&carol).unwrap().permissions();
perms.set_mode(0o555);
std::fs::set_permissions(&carol, perms).unwrap();
let result = store.set_relationships("carol/2026-01-01-c.md", &[(1, RelationKind::Related)]);
let mut perms = std::fs::metadata(&carol).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&carol, perms).unwrap();
assert!(result.is_err(), "the blocked bump must fail the call");
assert_eq!(
std::fs::read_to_string(&alice).unwrap(),
before,
"the relationship write that succeeded earlier must be rolled back \
when the subsequent `updated` bump fails"
);
}
#[test]
fn f9_relationships_for_reports_both_endpoints_of_a_one_sided_edge() {
let (_tmp, store) = make_store();
seeded(&store, 1, "A", "");
seeded(&store, 2, "B", "");
store.link("2", "1", RelationKind::Related).unwrap();
let one = store.get(IssueSelector::Number(1)).unwrap();
let two = store.get(IssueSelector::Number(2)).unwrap();
assert_eq!(one.relationships.len(), 1, "precondition: #1 stores it");
assert!(
two.relationships.is_empty(),
"precondition: #2's own field is the partial view that caused F9"
);
for number in [1u32, 2] {
let resolved = store.relationships_for(number).unwrap();
assert_eq!(
resolved.len(),
1,
"issue #{number} must see the relationship that touches it"
);
assert_eq!(resolved[0].kind, RelationKind::Related);
}
}
#[test]
fn f7_writing_an_issue_with_the_retired_key_discloses_that_it_is_removed() {
let (tmp, store) = make_store();
let _ = tmp;
let _ = take_disclosures();
write_issue(
&store,
"2026-01-01-legacy.md",
&legacy_links_frontmatter(1, "Legacy", "['7']"),
);
let issue = store.get(IssueSelector::Number(1)).unwrap();
assert!(
issue.has_retired_links_key,
"precondition: the key is present"
);
store.update(&issue.filename, &issue).unwrap();
let disclosures = take_disclosures();
assert_eq!(
disclosures.len(),
1,
"the write must disclose that it dropped the key: {disclosures:?}"
);
let line = &disclosures[0];
assert!(
line.contains("`links:`"),
"it names what was removed: {line}"
);
assert!(
line.starts_with("remove `links:` by hand"),
"and leads with what to do about it: {line}"
);
assert!(
line.contains(&issue.filename),
"and which file it was removed from: {line}"
);
assert!(
line.contains("C-001"),
"and that no migration was applied, so the user is not left \
wondering where their data went: {line}"
);
let after = read_issue_file(&store, &issue.filename);
assert!(
!after.contains("links:"),
"the key is not re-emitted (C-001: no migration)"
);
let reread = store.get(IssueSelector::Number(1)).unwrap();
assert!(!reread.has_retired_links_key);
assert!(
take_disclosures().is_empty(),
"and it is disclosed once, not on every read"
);
}
#[test]
fn f7b_writing_an_issue_without_the_retired_key_is_silent() {
let (tmp, store) = make_store();
let _ = tmp;
let _ = take_disclosures();
write_issue(&store, "2026-01-01-plain.md", &bare_frontmatter(1, "Plain"));
let issue = store.get(IssueSelector::Number(1)).unwrap();
assert!(!issue.has_retired_links_key);
store.update(&issue.filename, &issue).unwrap();
assert!(
take_disclosures().is_empty(),
"an ordinary write discloses nothing"
);
}
#[test]
fn t010_legacy_fixture_reports_the_retired_key_and_yields_no_edges() {
let original = std::fs::read_to_string(relationship_fixture("legacy-links-key.md")).unwrap();
let issue = Issue::parse(&original).unwrap();
assert!(issue.has_retired_links_key, "the retired key is reported");
assert!(
issue.relationships.is_empty(),
"C-001: legacy values are never reinterpreted as typed edges"
);
assert!(
issue.history.is_empty(),
"a legacy file gains no History section from parsing"
);
let (_tmp, store) = make_store();
write_issue(&store, "2026-01-01-legacy.md", &original);
let findings = crate::commands::lint::collect(&store.config, &store).unwrap();
let retired: Vec<_> = findings
.iter()
.filter(|f| f.kind == "retired_links_key")
.collect();
assert_eq!(
retired.len(),
1,
"one finding for the retired key: {findings:?}"
);
assert_eq!(retired[0].value, "links", "the finding names the field");
assert!(
retired[0].message.contains("retired"),
"the message says why: {}",
retired[0].message
);
assert!(
retired[0].line > 0,
"the finding points into the frontmatter"
);
let rewritten = issue.to_file_contents();
assert!(!rewritten.contains("kind: related"));
assert!(!rewritten.contains("kind: blocks"));
assert!(!rewritten.contains("links:"));
}
#[test]
fn c001_a_legacy_links_key_is_reported_and_yields_no_typed_edges() {
let (tmp, store) = make_store();
write_issue(
&store,
"2026-01-01-legacy.md",
&legacy_links_frontmatter(1, "Legacy", "[\"12\", \"47\"]"),
);
let _ = tmp;
let issue = store.get(IssueSelector::Number(1)).unwrap();
assert!(
issue.has_retired_links_key,
"the retired key is recorded as a validation result"
);
assert!(
issue.relationships.is_empty(),
"C-001: legacy values are never read as typed edges"
);
}
#[test]
fn c001_there_is_no_migration_path_from_the_retired_key() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-legacy.md",
&legacy_links_frontmatter(1, "Legacy", "[\"12\", \"47\"]"),
);
let issue = store.get(IssueSelector::Number(1)).unwrap();
assert!(
issue.has_retired_links_key,
"precondition: the key is present"
);
assert!(
issue.relationships.is_empty(),
"C-001: legacy values are never read as typed edges"
);
store.update(&issue.filename, &issue).unwrap();
let written = read_issue_file(&store, &issue.filename);
let reparsed = store.get(IssueSelector::Number(1)).unwrap();
assert!(
reparsed.relationships.is_empty(),
"the file on disk parses to zero typed edges, not just the in-memory copy"
);
for kind in ["blocks", "blocked_by", "duplicates", "related"] {
assert!(
!written.contains(&format!("kind: {kind}")),
"a legacy link became a `{kind}` edge:\n{written}"
);
}
assert!(
!written.contains("kind:"),
"no typed edge was written at all"
);
assert!(!written.contains("links:"), "the key is not re-emitted");
let reread = store.get(IssueSelector::Number(1)).unwrap();
assert!(
reread.history.is_empty(),
"a format change is not a relationship mutation, so it records no event"
);
}
#[test]
fn f15_the_idempotent_no_op_reports_the_real_addressable_row() {
let (_tmp, store, a, b) = related_pair();
let args = (a.number.to_string(), b.number.to_string());
let first = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
let second = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
assert!(first.changed);
assert!(!second.changed, "the second apply is a no-op");
assert_eq!(
second.relationship.id, first.relationship.id,
"the no-op reports the real, addressable row"
);
assert!(
!second.relationship.id.is_empty(),
"an empty id is not addressable"
);
assert_eq!(second.relationship.display, first.relationship.display);
}
#[test]
fn f16_clearing_a_set_bumps_updated_on_the_named_issue() {
let (_tmp, store, _a, _b) = related_pair();
store.link("2", "1", RelationKind::Related).unwrap();
let before = store.get(IssueSelector::Number(2)).unwrap();
let updated_before = before.updated.clone();
store.set_relationships(&before.filename, &[]).unwrap();
let after = store.get(IssueSelector::Number(2)).unwrap();
assert_ne!(
after.updated, updated_before,
"the named issue's relationship set changed, so `updated` must move"
);
}
#[test]
fn c002_an_issue_with_no_relationship_data_round_trips_unchanged() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(&store, "2026-01-01-plain.md", &bare_frontmatter(1, "Plain"));
let issue = store.get(IssueSelector::Number(1)).unwrap();
let first = issue.to_file_contents();
assert!(
!first.contains("relationships:"),
"the empty typed field is omitted, not written as `[]`"
);
assert!(
!first.contains("## History"),
"an empty History section is omitted entirely"
);
let reparsed = Issue::parse(&first).unwrap();
let second = reparsed.to_file_contents();
assert_eq!(second, first);
}
#[test]
fn c002_history_section_round_trips_byte_stable() {
let (tmp, store) = make_store();
write_issue(&store, "2026-01-01-h.md", &bare_frontmatter(1, "H"));
let _ = tmp;
let mut issue = store.get(IssueSelector::Number(1)).unwrap();
issue.history.append_now(
"alice",
HistoryEventKind::RelationshipAdded,
"created blocks: blocks #2 B",
);
issue.history.append_now(
"bob",
HistoryEventKind::BlockedMoveAcknowledged,
"acknowledged 1 open blocker",
);
let first = issue.to_file_contents();
assert!(first.contains("## History"), "{first}");
let reparsed = Issue::parse(&first).unwrap();
assert_eq!(reparsed.to_file_contents(), first);
assert_eq!(reparsed.history.len(), 2);
assert_eq!(reparsed.history.events()[0].actor, "alice");
assert_eq!(
reparsed.history.events()[1].kind,
HistoryEventKind::BlockedMoveAcknowledged
);
assert!(
!reparsed.body.contains("History"),
"the section is stripped from the body: {:?}",
reparsed.body
);
}
#[test]
fn f1_a_hand_edit_last_in_the_section_stays_in_it() {
let (tmp, store) = make_store();
let _ = tmp;
let original = "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n - my hand edit\n";
write_issue(&store, "2026-01-01-a.md", original);
let before = store.get(IssueSelector::Number(1)).unwrap();
assert_eq!(
before.history.events().len(),
1,
"precondition: the real event parsed"
);
assert_eq!(
before.history.unparsed(),
vec![" - my hand edit"],
"precondition: the hand edit is inside the section"
);
assert_eq!(before.body, "", "precondition: body is empty");
let mut moved = before.clone();
moved.status = crate::model::Status::new("in progress");
store.update(&moved.filename, &moved).unwrap();
let after = store.get(IssueSelector::Number(1)).unwrap();
assert_eq!(
after.history.unparsed(),
vec![" - my hand edit"],
"the hand edit is still in the audit log, not demoted to the body"
);
assert_eq!(
after.history.events().len(),
1,
"and the real event survived"
);
assert_eq!(after.body, "", "nothing leaked into the body");
store
.link("1", "2", RelationKind::Related)
.ok()
.or_else(|| {
write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
store.link("1", "2", RelationKind::Related).ok()
});
let linked = store.get(IssueSelector::Number(1)).unwrap();
let rendered = linked.to_file_contents();
assert!(
rendered.contains("## History"),
"the heading survived an unrelated write: {rendered}"
);
let hand = rendered
.find("my hand edit")
.expect("the hand edit is present");
let newest = rendered
.find("relationship_added")
.expect("the new event is present");
assert!(
newest < hand,
"the new event is appended AFTER the pre-existing content, so file \
order is still append order: {rendered}"
);
}
#[test]
fn f1_but_a_bare_paragraph_at_end_of_section_is_still_prose() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-a.md",
"---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n\njust a closing paragraph\n",
);
let issue = store.get(IssueSelector::Number(1)).unwrap();
assert_eq!(issue.history.events().len(), 1, "the real event parsed");
assert!(
issue.history.unparsed().is_empty(),
"the paragraph is not in the audit log: {:?}",
issue.history.unparsed()
);
assert!(
issue.body.contains("just a closing paragraph"),
"it is body prose: {:?}",
issue.body
);
}
#[test]
fn f1_a_hand_edit_survives_body_prose_and_a_later_body_replacement() {
let (_tmp, store) = make_store();
write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
let hand = "- 2026-02-02T00:00:00Z - bob - relationship_removed - by hand";
write_issue(
&store,
"2026-01-01-a.md",
&format!(
"---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n{hand}\n\nSome body prose.\n"
),
);
let before = store.get(IssueSelector::Number(1)).unwrap();
assert_eq!(before.history.events().len(), 1, "the real event parsed");
assert_eq!(
before.history.unparsed(),
vec![hand],
"the hand-edited entry is retained even though body prose follows"
);
assert!(
before.body.contains("Some body prose."),
"and the prose is still body text: {:?}",
before.body
);
let mut moved = before.clone();
moved.status = crate::model::Status::new("in progress");
store.update(&moved.filename, &moved).unwrap();
assert_eq!(
store
.get(IssueSelector::Number(1))
.unwrap()
.history
.unparsed(),
vec![hand],
"a `tix move` keeps it in the audit log"
);
let mut replaced = store.get(IssueSelector::Number(1)).unwrap();
replaced.body = "Completely new body text.".to_string();
store.update(&replaced.filename, &replaced).unwrap();
let after = store.get(IssueSelector::Number(1)).unwrap();
assert_eq!(
after.history.unparsed(),
vec![hand],
"the hand-edited entry survives a wholesale body replacement"
);
assert_eq!(after.body, "Completely new body text.");
store.link("1", "2", RelationKind::Related).unwrap();
let rendered = store
.get(IssueSelector::Number(1))
.unwrap()
.to_file_contents();
assert!(
rendered.contains("## History"),
"the heading survived: {rendered}"
);
let hand_at = rendered.find("by hand").expect("the hand edit is present");
let new_at = rendered
.find("created rel_")
.expect("the new event is present");
assert!(
new_at > hand_at,
"the new event is appended AFTER the pre-existing content, so file \
order is still append order: {rendered}"
);
}
#[test]
fn f1_a_well_formed_star_marked_entry_is_retained_with_body_prose_after_it() {
let (_tmp, store) = make_store();
write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
let hand = "* 2026-02-02T00:00:00Z \u{2014} bob \u{2014} relationship_removed \u{2014} by hand";
write_issue(
&store,
"2026-01-01-a.md",
&format!(
"---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n{hand}\n\nSome body prose.\n"
),
);
let before = store.get(IssueSelector::Number(1)).unwrap();
assert_eq!(before.history.events().len(), 1, "the real event parsed");
assert_eq!(
before.history.unparsed(),
vec![hand],
"a well-formed `*` entry is retained even with body prose after it"
);
assert!(
before.body.contains("Some body prose."),
"and the prose is still body text: {:?}",
before.body
);
let mut replaced = before.clone();
replaced.body = "Completely new body text.".to_string();
store.update(&replaced.filename, &replaced).unwrap();
assert_eq!(
store
.get(IssueSelector::Number(1))
.unwrap()
.history
.unparsed(),
vec![hand],
"it survives a wholesale body replacement"
);
store.link("1", "2", RelationKind::Related).unwrap();
let rendered = store
.get(IssueSelector::Number(1))
.unwrap()
.to_file_contents();
assert_eq!(
rendered.matches("## History").count(),
1,
"still exactly one section: {rendered}"
);
let hand_at = rendered.find("by hand").expect("the hand edit is present");
let new_at = rendered
.find("created rel_")
.expect("the new event is present");
assert!(
new_at > hand_at,
"appended after, so order is append order: {rendered}"
);
}
#[test]
fn f1_a_three_field_ascii_hyphen_entry_is_retained_with_body_prose_after_it() {
let (_tmp, store) = make_store();
let hand = "- 2026-02-02T00:00:00Z - bob - relationship_removed";
write_issue(
&store,
"2026-01-01-a.md",
&format!(
"---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n{hand}\n\nSome body prose.\n"
),
);
let twin = Issue::parse(
"---\nnumber: 2\ntitle: \"T\"\nstatus: todo\n---\n\n## History\n\n- 2026-02-02T00:00:00Z \u{2014} bob \u{2014} relationship_removed\n",
)
.unwrap();
assert_eq!(
twin.history.events().len(),
1,
"three fields with em-dashes is a valid event, detail optional"
);
let before = store.get(IssueSelector::Number(1)).unwrap();
assert_eq!(
before.history.unparsed(),
vec![hand],
"so its ASCII-hyphen twin is retained, not demoted"
);
let mut replaced = before.clone();
replaced.body = "Completely new body text.".to_string();
store.update(&replaced.filename, &replaced).unwrap();
assert_eq!(
store
.get(IssueSelector::Number(1))
.unwrap()
.history
.unparsed(),
vec![hand],
"and survives a body replacement"
);
}
#[test]
fn f1_a_heading_with_trailing_spaces_is_still_the_heading() {
let (_tmp, store) = make_store();
write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
for (label, heading) in [
("plain", "## History"),
("trailing spaces", "## History "),
] {
write_issue(
&store,
"2026-01-01-a.md",
&format!(
"---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n{heading}\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n"
),
);
let before = store.get(IssueSelector::Number(1)).unwrap();
assert_eq!(
before.history.events().len(),
1,
"{label}: the section is recognised"
);
store.link("1", "2", RelationKind::Related).unwrap();
let rendered = store
.get(IssueSelector::Number(1))
.unwrap()
.to_file_contents();
assert_eq!(
rendered.matches("## History").count(),
1,
"{label}: exactly one section after a write, got {rendered}"
);
assert!(
!rendered.lines().any(|l| l != l.trim_end()),
"{label}: no line keeps trailing whitespace, so the heading is \
normalised: {rendered}"
);
}
}
#[test]
fn set_status_discloses_the_retired_key_rather_than_dropping_it_silently() {
let (_tmp, store) = make_store();
write_issue(
&store,
"2026-01-01-a.md",
"---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nlinks:\n- \"2\"\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n",
);
let before = store.get(IssueSelector::Number(1)).unwrap();
assert!(
before.has_retired_links_key,
"precondition: the file carries the retired key"
);
let _ = crate::storage::take_disclosures();
store
.set_status("2026-01-01-a.md", StatusChange::to("in progress"))
.unwrap();
let disclosures = crate::storage::take_disclosures();
assert_eq!(
disclosures.len(),
1,
"set_status must disclose the removal, got {disclosures:?}"
);
assert!(
disclosures[0].contains("links:") && disclosures[0].contains("a.md"),
"naming the field and the file: {disclosures:?}"
);
assert!(
!store
.get(IssueSelector::Number(1))
.unwrap()
.has_retired_links_key,
"and the key is gone, as C-001 intends"
);
}
#[test]
fn f1_but_ordinary_prose_bullets_still_end_the_section() {
let (_tmp, store) = make_store();
write_issue(
&store,
"2026-01-01-a.md",
concat!(
"---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\n",
"created: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n",
"- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n\n",
"- step one\n- step two\n",
),
);
let issue = store.get(IssueSelector::Number(1)).unwrap();
assert_eq!(
issue.history.events().len(),
1,
"the real event is an event"
);
assert!(
issue.history.unparsed().is_empty(),
"ordinary bullets are not retained: {:?}",
issue.history.unparsed()
);
assert!(
issue.body.contains("step one") && issue.body.contains("step two"),
"they stay body text: {:?}",
issue.body
);
}
#[test]
fn a_year_is_not_an_ordered_list_marker() {
for prose in [
"2026. Q1 goals",
"2026. roadmap review",
"007. padded",
"99999999999999999999. big",
] {
assert!(
!crate::model::looks_like_an_unrecognised_entry(prose)
&& Issue::parse(&format!(
"---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{prose}\n"
))
.unwrap()
.history
.is_empty(),
"{prose:?} must be body prose, not a retained entry"
);
}
}
#[test]
fn f2_a_recognised_entry_with_a_bad_kind_keeps_its_trailing_whitespace() {
let line = " - 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} d ";
let raw = format!(
"---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n{line}\n"
);
let issue = Issue::parse(&raw).unwrap();
assert!(issue.history.events().is_empty(), "no event was fabricated");
assert_eq!(
issue.history.unparsed(),
vec![line],
"kept byte-for-byte, trailing spaces included"
);
let rendered = issue.to_file_contents();
assert!(
rendered.contains(&format!("{line}\n")),
"re-emitted exactly, so an unrelated `tix edit` does not rewrite the \
author's audit log: {rendered}"
);
assert_eq!(
Issue::parse(&rendered).unwrap().to_file_contents(),
rendered,
"and the file is byte-stable from here"
);
}
#[test]
fn c002_history_is_append_only() {
let (tmp, store) = make_store();
write_issue(&store, "2026-01-01-h.md", &bare_frontmatter(1, "H"));
let _ = tmp;
let mut issue = store.get(IssueSelector::Number(1)).unwrap();
issue
.history
.append_now("alice", HistoryEventKind::RelationshipAdded, "first");
let after_first = issue.history.clone();
issue
.history
.append_now("alice", HistoryEventKind::RelationshipRemoved, "second");
assert_eq!(issue.history.len(), 2, "appending never replaces");
assert_eq!(
&issue.history.events()[..1],
after_first.events(),
"the earlier entry is untouched and still first"
);
}
#[test]
fn c002_an_unknown_history_kind_is_kept_verbatim_not_coerced() {
let line = "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} something";
let raw =
format!("---\nnumber: 1\ntitle: \"H\"\nstatus: \"todo\"\n---\n\n## History\n\n{line}\n");
let issue = Issue::parse(&raw).expect("an unknown kind must not fail the parse");
assert!(
issue.history.events().is_empty(),
"no event was fabricated for an unknown kind"
);
assert_eq!(
issue.history.unparsed(),
vec![line],
"the line is kept verbatim: not dropped, not repaired"
);
let rendered = issue.to_file_contents();
assert!(rendered.contains(line), "the line survives the write");
assert_eq!(
Issue::parse(&rendered).unwrap().to_file_contents(),
rendered,
"and the file round-trips byte-identically"
);
}
#[test]
fn list_mixes_grouped_and_flat_issues_sorted_by_relative_filename() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
write_issue(
&store,
"alice/2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
write_issue(
&store,
"bob/2026-05-10-b.md",
&sample_frontmatter(2, "B", "todo"),
);
write_issue(
&store,
"2026-02-01-c.md",
&sample_frontmatter(3, "C", "todo"),
);
let issues = store.list().unwrap();
let filenames: Vec<&str> = issues.iter().map(|i| i.filename.as_str()).collect();
assert_eq!(issues.len(), 3);
assert_eq!(
filenames,
vec![
"2026-02-01-c.md",
"alice/2026-01-01-a.md",
"bob/2026-05-10-b.md"
],
"deterministic sort by relative filename"
);
}
#[test]
fn list_sorts_chronologically_within_a_group() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-03-01-c.md",
&sample_frontmatter(3, "C", "todo"),
);
write_issue(
&store,
"alice/2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
write_issue(
&store,
"alice/2026-02-01-b.md",
&sample_frontmatter(2, "B", "todo"),
);
let issues = store.list().unwrap();
let numbers: Vec<u32> = issues.iter().map(|i| i.number).collect();
assert_eq!(numbers, vec![1, 2, 3]);
}
#[test]
fn list_ignores_non_md_files_in_segments() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
fs::write(store.issues_dir.join("alice/notes.txt"), "not an issue").unwrap();
fs::write(store.issues_dir.join("alice/.tix-tmp-123-456"), "orphan").unwrap();
let issues = store.list().unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].filename, "alice/2026-01-01-a.md");
}
#[test]
fn list_skips_temp_marker_directories() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join(".tix-tmp-999-0")).unwrap();
write_issue(
&store,
".tix-tmp-999-0/2026-01-01-junk.md",
&sample_frontmatter(9, "J", "todo"),
);
write_issue(
&store,
"2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
let issues = store.list().unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].filename, "2026-01-01-a.md");
}
#[test]
fn list_parse_error_carries_grouped_relative_path() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::write(
store.issues_dir.join("alice/2026-01-01-bad.md"),
"no frontmatter here\n",
)
.unwrap();
let err = store.list().unwrap_err();
assert!(
matches!(&err, TixError::Parse(m) if m.starts_with("alice/2026-01-01-bad.md:")),
"parse error must carry the relative path, got {err:?}"
);
}
#[test]
fn create_uses_the_default_layout_template() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let store = Store {
issues_dir: dir,
config: Config::default(),
};
let mut issue = Issue {
projects: None,
id: Some("A1B2C3D4".into()),
number: 0,
title: "Round trip".into(),
status: Status::new("todo"),
labels: vec![],
priority: Priority::default(),
assignee: Some("@alice".into()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
parent: None,
series: None,
health: None,
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
entered_at: None,
source: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
};
store.create(&mut issue, "", None).unwrap();
assert_eq!(
issue.filename,
"alice/default/todo/2026-01-01-A1B2C3D4-round-trip.md"
);
assert!(
store
.issues_dir
.join("alice/default/todo/2026-01-01-A1B2C3D4-round-trip.md")
.is_file()
);
}
#[test]
fn create_assigns_a_valid_id_and_leads_the_filename_with_it() {
let (tmp, store) = make_store();
let _ = tmp;
let mut a = crate::model::Issue::parse(
"---\nnumber: 0\ntitle: Same title\nstatus: todo\nlabels: []\npriority: medium\n\
created: 2026-01-01\nupdated: 2026-01-01\n---\n\n",
)
.unwrap();
let mut b = a.clone();
store.create(&mut a, "", None).unwrap();
store.create(&mut b, "", None).unwrap();
let id_a = a.id.clone().expect("an id is assigned at create");
let id_b = b.id.clone().expect("an id is assigned at create");
assert!(crate::id::is_issue_id(&id_a), "shaped id: {id_a}");
assert_ne!(id_a, id_b, "two creates must not share an id");
assert!(
a.filename.contains(&id_a),
"filename must carry the id: {}",
a.filename
);
assert!(
a.to_file_contents().contains(&format!("id: {id_a}\n")),
"the id must reach the frontmatter"
);
}
#[test]
fn slug_from_stem_skips_the_id_and_date_prefixes() {
assert_eq!(slug_from_stem("2026-01-01-fix-login"), "fix-login");
assert_eq!(slug_from_stem("2026-01-01-A1B2C3D4-fix-login"), "fix-login");
assert_eq!(slug_from_stem("A1B2C3D4-2026-01-01-fix-login"), "fix-login");
assert_eq!(slug_from_stem("no-prefix"), "no-prefix");
}
#[test]
fn update_always_reconciles_the_file_location() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let store = Store {
issues_dir: dir,
config: Config::default(),
};
let mut issue = Issue {
projects: None,
id: Some("A1B2C3D4".into()),
number: 0,
type_: None,
title: "Round trip".into(),
status: Status::new("todo"),
labels: vec![],
priority: Priority::default(),
assignee: Some("@alice".into()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
parent: None,
series: None,
health: None,
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
entered_at: None,
source: None,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
};
store.create(&mut issue, "", None).unwrap();
let old = issue.filename.clone();
issue.status = Status::new("done");
issue.filename = store.update(&old, &issue).unwrap();
assert_eq!(
issue.filename,
"alice/default/done/2026-01-01-A1B2C3D4-round-trip.md"
);
assert!(!store.issues_dir.join(&old).exists());
let flat = "2026-01-01-legacy.md";
fs::write(
store.issues_dir.join(flat),
"---\nnumber: 9\ntitle: L\nstatus: todo\nlabels: []\npriority: medium\n\
files: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nbody\n",
)
.unwrap();
let mut legacy = store
.get(IssueSelector::Filename(flat.to_string()))
.unwrap();
legacy.status = Status::new("done");
let after = store.update(flat, &legacy).unwrap();
assert_eq!(after, "unassigned/default/done/2026-01-01-legacy.md");
assert!(!store.issues_dir.join(flat).exists(), "migrated, not left");
assert!(store.issues_dir.join(&after).is_file());
}
#[test]
fn create_writes_into_assignee_segment_and_records_relative_filename() {
let (tmp, store) = make_store();
let _ = tmp;
let mut issue = Issue {
projects: None,
id: None,
number: 0,
title: "T".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
let path = store
.create(&mut issue, "body", Some("custom-stem"))
.unwrap();
let rel = "herbert-maschke/custom-stem.md";
assert_eq!(issue.filename, rel);
assert_eq!(path, store.issues_dir.join(rel));
assert!(store.issues_dir.join("herbert-maschke").is_dir());
assert!(store.issues_dir.join(rel).is_file());
}
#[test]
fn create_without_assignee_writes_unassigned_segment() {
let (tmp, store) = make_store();
let _ = tmp;
let mut issue = Issue {
projects: None,
id: None,
number: 0,
title: "T".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
let path = store.create(&mut issue, "", Some("stem")).unwrap();
assert_eq!(issue.filename, "unassigned/stem.md");
assert!(store.issues_dir.join("unassigned").is_dir());
assert!(store.issues_dir.join("unassigned/stem.md").is_file());
assert_eq!(path, store.issues_dir.join("unassigned/stem.md"));
}
#[test]
fn create_with_at_handle_derives_bare_segment() {
let (tmp, store) = make_store();
let _ = tmp;
let mut issue = Issue {
projects: None,
id: None,
number: 0,
title: "T".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: Some("@alice".into()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
store.create(&mut issue, "", Some("stem")).unwrap();
assert_eq!(issue.filename, "alice/stem.md");
assert!(store.issues_dir.join("alice/stem.md").is_file());
}
#[test]
fn reassign_moves_file_between_segments() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-x.md",
&sample_frontmatter(1, "X", "todo"),
);
let new_path = store
.reassign("alice/2026-01-01-x.md", Some("@bob"))
.unwrap();
assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x.md"));
assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
}
#[test]
fn reassign_into_occupied_segment_picks_deterministic_suffix() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
write_issue(
&store,
"alice/2026-01-01-x.md",
&sample_frontmatter(1, "X", "todo"),
);
write_issue(
&store,
"bob/2026-01-01-x.md",
&sample_frontmatter(2, "X", "todo"),
);
let new_path = store
.reassign("alice/2026-01-01-x.md", Some("@bob"))
.unwrap();
assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x-1.md"));
assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
assert!(store.issues_dir.join("bob/2026-01-01-x-1.md").is_file());
assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
}
#[test]
fn reassign_legacy_flat_file_into_segment() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-y.md",
&sample_frontmatter(1, "Y", "todo"),
);
let new_path = store.reassign("2026-01-01-y.md", Some("alice")).unwrap();
assert_eq!(new_path, PathBuf::from("alice/2026-01-01-y.md"));
assert!(store.issues_dir.join("alice/2026-01-01-y.md").is_file());
assert!(!store.issues_dir.join("2026-01-01-y.md").exists());
}
#[test]
fn reassign_to_none_uses_unassigned_fallback() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-z.md",
&sample_frontmatter(1, "Z", "todo"),
);
let new_path = store.reassign("alice/2026-01-01-z.md", None).unwrap();
assert_eq!(new_path, PathBuf::from("unassigned/2026-01-01-z.md"));
assert!(
store
.issues_dir
.join("unassigned/2026-01-01-z.md")
.is_file()
);
assert!(!store.issues_dir.join("alice/2026-01-01-z.md").exists());
}
#[test]
fn reassign_keeps_number_and_created_stable() {
let (tmp, store) = make_store();
let _ = tmp;
let mut issue = Issue {
projects: None,
id: None,
number: 0,
title: "Stable".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: Some("alice".into()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
store.create(&mut issue, "body", None).unwrap();
let number = issue.number;
let created = issue.created.clone();
let before = issue.filename.clone();
let new_path = store.reassign(&before, Some("@bob")).unwrap();
let rescan = store.list().unwrap();
let moved = rescan
.iter()
.find(|i| i.filename == new_path)
.expect("moved file must be listed at its new relative path");
assert_eq!(moved.number, number);
assert_eq!(moved.created, created);
assert_eq!(moved.title, "Stable");
}
#[test]
fn reassign_equal_segment_is_noop() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-x.md",
&sample_frontmatter(1, "X", "todo"),
);
let new_path = store
.reassign("alice/2026-01-01-x.md", Some("alice"))
.unwrap();
assert_eq!(new_path, PathBuf::from("alice/2026-01-01-x.md"));
assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
}
#[test]
fn reassign_missing_file_is_not_found() {
let (tmp, store) = make_store();
let _ = tmp;
let err = store.reassign("nope.md", Some("alice")).unwrap_err();
assert!(matches!(err, TixError::NotFound(_)));
}
#[test]
fn validate_filename_allows_flat_and_single_segment_prefix() {
validate_filename("2026-01-01-x.md").unwrap();
validate_filename("alice/2026-01-01-x.md").unwrap();
}
#[test]
fn validate_filename_allows_deeper_segments() {
validate_filename("alice/bug/in-progress/2026-01-01-x.md").unwrap();
}
#[test]
fn validate_filename_refuses_leading_and_trailing_slash_and_absolute() {
let err = validate_filename("/alice/2026-01-01-x.md").unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)));
let err = validate_filename("/etc/passwd").unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)));
let err = validate_filename("alice/").unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)));
}
#[test]
fn path_traversal_with_segment_dotdot_refused() {
let err = validate_filename("alice/../../x.md").unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)));
let err = validate_filename("alice/../x.md").unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)));
}
#[test]
fn get_update_delete_set_status_work_on_grouped_relative_paths() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-g.md",
&sample_frontmatter(7, "G", "todo"),
);
let got = store
.get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
.unwrap();
assert_eq!(got.number, 7);
let done = store
.set_status("alice/2026-01-01-g.md", StatusChange::to("done"))
.unwrap();
assert_eq!(done.issue.status.display(), "done");
assert!(store.issues_dir.join("alice/2026-01-01-g.md").is_file());
let mut edited = store
.get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
.unwrap();
edited.title = "Updated".into();
store.update("alice/2026-01-01-g.md", &edited).unwrap();
let reloaded = store
.get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
.unwrap();
assert_eq!(reloaded.title, "Updated");
store.delete("alice/2026-01-01-g.md").unwrap();
assert!(!store.issues_dir.join("alice/2026-01-01-g.md").exists());
}
#[test]
fn grouped_round_trip_create_list_get_update_delete() {
let (tmp, store) = make_store();
let _ = tmp;
let mut issue = Issue {
projects: None,
id: Some("A1B2C3D4".into()),
number: 0,
title: "Round trip".into(),
status: Status::default(),
labels: vec!["bug".into()],
priority: Priority::default(),
assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
store.create(&mut issue, "initial body", None).unwrap();
let rel = issue.filename.clone();
assert_eq!(rel, "herbert-maschke/2026-01-01-A1B2C3D4-round-trip.md");
let listed = store.list().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].filename, rel);
assert_eq!(listed[0].number, issue.number);
let got = store.get(IssueSelector::Filename(rel.clone())).unwrap();
let mut edited = got.clone();
edited.title = "Renamed".into();
store.update(&rel, &edited).unwrap();
let reloaded = store.get(IssueSelector::Filename(rel.clone())).unwrap();
assert_eq!(reloaded.title, "Renamed");
store.delete(&rel).unwrap();
assert!(!store.issues_dir.join(&rel).exists());
assert_eq!(store.list().unwrap(), [] as [crate::model::Issue; 0]);
}
#[test]
fn legacy_flat_file_edit_and_status_still_work_after_layout_flip() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-legacy.md",
&sample_frontmatter(5, "Legacy", "todo"),
);
let listed = store.list().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].filename, "2026-01-01-legacy.md");
let mutated = store
.set_status("2026-01-01-legacy.md", StatusChange::to("in progress"))
.unwrap();
assert_eq!(mutated.issue.status.display(), "in progress");
assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
}
fn write_asset_source(tmp: &tempfile::TempDir, name: &str, contents: &[u8]) -> PathBuf {
let path = tmp.path().join(name);
fs::write(&path, contents).unwrap();
path
}
fn write_issue_with_basic_body(store: &Store, name: &str, number: u32) {
write_issue(
store,
name,
&sample_frontmatter(number, "Asset host", "todo"),
);
}
#[test]
fn add_asset_image_inserts_image_link_and_writes_file() {
let (tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
let added = store
.add_asset("alice/2026-01-01-fix-login-bug.md", &src)
.unwrap();
assert_eq!(added.written_name, "diagram.png");
assert_eq!(added.link, "");
assert!(
store
.issues_dir
.join("alice/fix-login-bug-assets/diagram.png")
.is_file()
);
}
#[test]
fn add_asset_non_image_inserts_generic_markdown_link() {
let (tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
let src = write_asset_source(&tmp, "report.pdf", b"%PDF");
let added = store
.add_asset("alice/2026-01-01-fix-login-bug.md", &src)
.unwrap();
assert_eq!(added.link, "[report](fix-login-bug-assets/report.pdf)");
assert!(
store
.issues_dir
.join("alice/fix-login-bug-assets/report.pdf")
.is_file()
);
}
#[test]
fn add_asset_collision_resolves_with_numeric_suffix_and_link_points_at_it() {
let (tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
let src = write_asset_source(&tmp, "x.png", b"first");
let first = store
.add_asset("alice/2026-01-01-fix-login-bug.md", &src)
.unwrap();
assert_eq!(first.written_name, "x.png");
let again = store
.add_asset("alice/2026-01-01-fix-login-bug.md", &src)
.unwrap();
assert_eq!(again.written_name, "x-1.png");
assert_eq!(again.link, "");
assert!(
store
.issues_dir
.join("alice/fix-login-bug-assets/x.png")
.is_file()
);
assert!(
store
.issues_dir
.join("alice/fix-login-bug-assets/x-1.png")
.is_file()
);
}
#[test]
fn add_asset_appends_single_link_and_preserves_description() {
let (tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-fix-login-bug.md",
"---\nnumber: 1\ntitle: \"H\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nTitle line.\n\n## Comments\n\n### alice (2026-01-01)\n\nExisting comment.\n",
);
let src = write_asset_source(&tmp, "diagram.svg", b"<svg/>");
store
.add_asset("alice/2026-01-01-fix-login-bug.md", &src)
.unwrap();
let reloaded = store
.get(crate::storage::IssueSelector::Filename(
"alice/2026-01-01-fix-login-bug.md".into(),
))
.unwrap();
assert_eq!(reloaded.description(), "Title line.");
let link_count = reloaded.body.matches("![diagram]").count();
assert_eq!(link_count, 1);
assert!(
reloaded
.body
.ends_with("fix-login-bug-assets/diagram.svg)\n")
);
}
#[test]
fn add_asset_missing_issue_is_not_found() {
let (tmp, store) = make_store();
let _ = tmp;
let src = write_asset_source(&tmp, "x.png", b"x");
let err = store.add_asset("missing.md", &src).unwrap_err();
assert!(matches!(err, TixError::NotFound(_)));
}
#[test]
fn add_asset_legacy_flat_file_uses_stem_assets_under_issues_dir() {
let (tmp, store) = make_store();
write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
let src = write_asset_source(&tmp, "x.png", b"x");
let added = store.add_asset("2026-01-01-legacy.md", &src).unwrap();
assert_eq!(added.link, "");
assert!(store.issues_dir.join("legacy-assets/x.png").is_file());
}
#[test]
fn reassign_moves_asset_dir_with_the_file() {
let (tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
store
.add_asset("alice/2026-01-01-fix-login-bug.md", &src)
.unwrap();
let new_path = store
.reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
.unwrap();
assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
assert!(
store
.issues_dir
.join("bob/fix-login-bug-assets/diagram.png")
.is_file()
);
assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
}
#[test]
fn reassign_without_asset_dir_behaves_as_before() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
let new_path = store
.reassign("alice/2026-01-01-plain.md", Some("@bob"))
.unwrap();
assert_eq!(new_path, PathBuf::from("bob/2026-01-01-plain.md"));
assert!(store.issues_dir.join("bob/2026-01-01-plain.md").is_file());
assert!(!store.issues_dir.join("bob/plain-assets").exists());
}
#[test]
fn reassign_into_segment_with_same_named_asset_dir_uses_suffix_no_loss() {
let (tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
let existing = store.issues_dir.join("bob/fix-login-bug-assets");
fs::create_dir_all(&existing).unwrap();
fs::write(existing.join("keep.txt"), "precious").unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
store
.add_asset("alice/2026-01-01-fix-login-bug.md", &src)
.unwrap();
let new_path = store
.reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
.unwrap();
assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
assert!(existing.join("keep.txt").is_file());
assert!(
store
.issues_dir
.join("bob/fix-login-bug-assets-1/diagram.png")
.is_file()
);
assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
}
#[test]
fn reassign_preserves_markdown_link_resolution_in_new_segment() {
let (tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
store
.add_asset("alice/2026-01-01-fix-login-bug.md", &src)
.unwrap();
store
.reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
.unwrap();
assert!(
store
.issues_dir
.join("bob/fix-login-bug-assets/diagram.png")
.is_file()
);
}
#[test]
fn delete_removes_asset_dir_no_orphans() {
let (tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
let src = write_asset_source(&tmp, "x.png", b"x");
store
.add_asset("alice/2026-01-01-fix-login-bug.md", &src)
.unwrap();
store.delete("alice/2026-01-01-fix-login-bug.md").unwrap();
assert!(
!store
.issues_dir
.join("alice/2026-01-01-fix-login-bug.md")
.exists()
);
assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
}
#[test]
fn delete_without_asset_dir_still_deletes_file() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
store.delete("alice/2026-01-01-plain.md").unwrap();
assert!(!store.issues_dir.join("alice/2026-01-01-plain.md").exists());
}
#[test]
fn list_ignores_asset_directories() {
let (tmp, store) = make_store();
write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
let src = write_asset_source(&tmp, "x.png", b"x");
store.add_asset("2026-01-01-legacy.md", &src).unwrap();
let issues = store.list().unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].filename, "2026-01-01-legacy.md");
}
#[test]
fn list_ignores_nested_asset_directories_under_segment() {
let (tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
let src = write_asset_source(&tmp, "x.png", b"x");
store
.add_asset("alice/2026-01-01-fix-login-bug.md", &src)
.unwrap();
let issues = store.list().unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
}
#[test]
fn asset_dir_with_formless_md_child_is_not_scanned_as_segment() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
let assets_dir = store.issues_dir.join("alice/fix-login-bug-assets");
fs::create_dir_all(&assets_dir).unwrap();
fs::write(assets_dir.join("notes.md"), "no frontmatter").unwrap();
let issues = store.list().unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
}
fn hierarchy_trio() -> (TempDir, Store) {
let (tmp, store) = make_store();
for (n, title) in [(1, "A"), (2, "B"), (3, "C")] {
write_issue(
&store,
&format!("2026-01-01-{}.md", title.to_lowercase()),
&bare_frontmatter(n, title),
);
}
(tmp, store)
}
fn store_from_hierarchy_fixture(name: &str) -> (TempDir, Store) {
let (tmp, store) = make_store();
write_issue(
&store,
name,
&std::fs::read_to_string(format!(
"{}/tests/fixtures/hierarchy/{name}",
env!("CARGO_MANIFEST_DIR")
))
.unwrap(),
);
(tmp, store)
}
fn bytes_of(store: &Store, number: u32) -> String {
let issue = store.get(IssueSelector::Number(number)).unwrap();
read_issue_file(store, &issue.filename)
}
#[test]
fn t008_set_writes_the_field_and_exactly_one_history_event() {
let (_tmp, store) = hierarchy_trio();
let outcome = store.set_parent("2", "1").unwrap();
assert!(outcome.changed);
assert_eq!(outcome.parent, Some(1));
let child = store.get(IssueSelector::Number(2)).unwrap();
assert_eq!(
child.parent,
Some(crate::id::IssueRef::Number(1)),
"the legacy numeric alias is readable"
);
assert_eq!(child.history.len(), 1, "C-004: exactly one event");
let event = &child.history.events()[0];
assert_eq!(event.kind, HistoryEventKind::ParentSet);
assert!(
!event.actor.is_empty(),
"an event without an actor is not an audit record"
);
assert_eq!(event.detail, "parent none -> #1", "got: {}", event.detail);
}
#[test]
fn t008_change_repoints_and_records_parent_changed() {
let (_tmp, store) = hierarchy_trio();
store.set_parent("3", "1").unwrap();
let outcome = store.change_parent("3", "2").unwrap();
assert!(outcome.changed);
assert_eq!(outcome.parent, Some(2));
let child = store.get(IssueSelector::Number(3)).unwrap();
assert_eq!(child.parent, Some(crate::id::IssueRef::Number(2)));
assert_eq!(child.history.len(), 2, "one event per accepted change");
let events = child.history.events();
let last = events.last().unwrap();
assert_eq!(last.kind, HistoryEventKind::ParentChanged);
assert_eq!(last.detail, "parent #1 -> #2", "got: {}", last.detail);
}
#[test]
fn t008_clear_removes_the_field_and_records_parent_cleared() {
let (_tmp, store) = hierarchy_trio();
store.set_parent("2", "1").unwrap();
let outcome = store.clear_parent("2").unwrap();
assert!(outcome.changed);
assert_eq!(outcome.parent, None);
let child = store.get(IssueSelector::Number(2)).unwrap();
assert_eq!(child.parent, None);
assert_eq!(child.history.len(), 2);
let events = child.history.events();
let last = events.last().unwrap();
assert_eq!(last.kind, HistoryEventKind::ParentCleared);
assert_eq!(last.detail, "parent #1 -> none", "got: {}", last.detail);
}
#[test]
fn t008_reapplying_the_same_parent_is_a_no_op_with_no_second_event() {
let (_tmp, store) = hierarchy_trio();
store.set_parent("2", "1").unwrap();
let after_first = bytes_of(&store, 2);
let second = store.set_parent("2", "1").unwrap();
assert!(!second.changed, "an unchanged parent is not a change");
assert!(second.event.is_none(), "a no-op records nothing (C-004)");
assert_eq!(
bytes_of(&store, 2),
after_first,
"a no-op must not rewrite the file at all"
);
}
#[test]
fn t008_clearing_a_child_with_no_parent_is_a_no_op() {
let (_tmp, store) = hierarchy_trio();
let before = bytes_of(&store, 2);
let outcome = store.clear_parent("2").unwrap();
assert!(!outcome.changed);
assert!(outcome.event.is_none());
assert_eq!(bytes_of(&store, 2), before);
}
#[test]
fn t008_sc004_a_rejected_cycle_leaves_every_file_byte_identical() {
let (_tmp, store) = hierarchy_trio();
store.set_parent("2", "1").unwrap();
store.set_parent("3", "2").unwrap();
let before: Vec<String> = (1..=3).map(|n| bytes_of(&store, n)).collect();
let err = store
.set_parent("1", "3")
.expect_err("1 -> 2 -> 3 -> 1 is a cycle and must be refused");
let msg = err.to_string();
assert!(
msg.contains("cycle"),
"the diagnostic must name the rule: {msg}"
);
for n in 1..=3 {
assert_eq!(
bytes_of(&store, n),
before[(n - 1) as usize],
"issue {n} must be byte-identical after a rejected cycle"
);
}
}
#[test]
fn t008_a_self_parent_is_rejected_and_writes_nothing() {
let (_tmp, store) = hierarchy_trio();
let before = bytes_of(&store, 2);
let err = store
.set_parent("2", "2")
.expect_err("an issue cannot be its own parent");
assert!(err.to_string().contains("self_parent"), "got: {err}");
assert_eq!(bytes_of(&store, 2), before);
}
#[test]
fn t008_a_self_parent_already_on_disk_is_still_refused() {
let (_tmp, store) = store_from_hierarchy_fixture("self-parent.md");
let before = bytes_of(&store, 7);
let err = store
.set_parent("7", "7")
.expect_err("a stored self-parent must still be refused on a re-set");
assert!(
err.to_string().contains("self_parent"),
"refused for the wrong reason: {err}"
);
assert_eq!(
bytes_of(&store, 7),
before,
"and the refused write must leave the file byte-identical"
);
assert!(store.change_parent("7", "7").is_err());
assert_eq!(
store.get(IssueSelector::Number(7)).unwrap().parent,
Some(crate::id::IssueRef::Number(7))
);
}
#[test]
fn t008_c003_setting_a_child_writes_nothing_to_the_parents_file() {
let (_tmp, store) = hierarchy_trio();
let parent_before = bytes_of(&store, 1);
store.set_parent("2", "1").unwrap();
store.set_parent("3", "1").unwrap();
assert_eq!(
bytes_of(&store, 1),
parent_before,
"the parent's file must be untouched by its children appearing"
);
assert!(
!parent_before.contains("parent:"),
"the parent is a top of the hierarchy and has no parent itself"
);
}
#[test]
fn t008_a_legacy_file_without_the_field_is_unchanged_by_a_no_op_write() {
let (_tmp, store) = hierarchy_trio();
let before = bytes_of(&store, 2);
assert!(!before.contains("parent:"), "the fixture must be legacy");
store.clear_parent("2").unwrap();
assert_eq!(bytes_of(&store, 2), before, "a no-op must not add the key");
}
#[test]
fn t008_an_unresolvable_selector_is_refused_before_any_write() {
let (_tmp, store) = hierarchy_trio();
let before = bytes_of(&store, 2);
assert!(store.set_parent("2", "no such issue").is_err());
assert!(store.change_parent("2", "999").is_err());
assert_eq!(bytes_of(&store, 2), before);
}
#[test]
fn t008_t008_contains_no_hierarchy_rule_of_its_own() {
let (_tmp, store) = hierarchy_trio();
store.set_parent("2", "1").unwrap();
store.set_parent("3", "2").unwrap();
let issues = store.list().unwrap();
let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
let index = HierarchyIndex::build(&views);
let service = hierarchy::set_parent(&index, 1, 3).expect_err("the service refuses this too");
let storage = store.set_parent("1", "3").expect_err("and so does storage");
assert_eq!(
storage.to_string(),
TixError::from(service).to_string(),
"storage must surface the service's diagnosis verbatim, not its own"
);
let text = storage.to_string();
assert!(
text.contains("cycle:") && !text.contains("cycle: cycle:"),
"a lookalike renderer or a doubled prefix: {text}"
);
assert!(
text.starts_with("error[E-tix-hierarchy]: "),
"a hierarchy refusal carries the shared rustc-style header: {text}"
);
}
#[test]
fn t008_a_no_op_reports_no_written_issue_but_still_reports_the_current_parent() {
let (_tmp, store) = hierarchy_trio();
store.set_parent("2", "1").unwrap();
let again = store.set_parent("2", "1").unwrap();
assert!(!again.changed);
assert!(
again.written.is_none(),
"a no-op wrote nothing, so there is no written issue to hand back"
);
assert!(
again.event.is_none(),
"and no event, or a retry would pad the audit trail"
);
assert_eq!(
again.parent,
Some(1),
"but the resulting state is still reported, so a caller need not re-read"
);
}
#[test]
fn a_failed_transaction_restores_every_file_it_touched() {
let (_tmp, store) = make_store();
write_issue(
&store,
"2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
write_issue(
&store,
"2026-01-01-b.md",
&sample_frontmatter(2, "B", "todo"),
);
let before_a = read_issue_file(&store, "2026-01-01-a.md");
let before_b = read_issue_file(&store, "2026-01-01-b.md");
let mut a = store.get(IssueSelector::Number(1)).unwrap();
a.title = "Changed".into();
let mut b = store.get(IssueSelector::Number(2)).unwrap();
b.title = "Also changed".into();
let err = store
.transaction(|| {
store.update("2026-01-01-a.md", &a)?;
store.update("2026-01-01-b.md", &b)?;
Err::<(), _>(TixError::InvalidInput("boom".into()))
})
.unwrap_err();
assert!(err.to_string().contains("boom"), "got: {err}");
assert_eq!(
read_issue_file(&store, "2026-01-01-a.md"),
before_a,
"the first write must be undone too, not only the last"
);
assert_eq!(read_issue_file(&store, "2026-01-01-b.md"), before_b);
assert_eq!(store.get(IssueSelector::Number(1)).unwrap().title, "A");
assert_eq!(store.get(IssueSelector::Number(2)).unwrap().title, "B");
}
#[test]
fn a_committed_transaction_keeps_its_writes() {
let (_tmp, store) = make_store();
write_issue(
&store,
"2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
let mut a = store.get(IssueSelector::Number(1)).unwrap();
a.title = "Changed".into();
store
.transaction(|| {
store.update("2026-01-01-a.md", &a)?;
Ok(())
})
.unwrap();
assert_eq!(
store.get(IssueSelector::Number(1)).unwrap().title,
"Changed"
);
}
#[test]
fn a_failed_transaction_undoes_a_reassign_move_and_its_content_write() {
let (_tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-x.md",
&sample_frontmatter(5, "X", "todo"),
);
let before = read_issue_file(&store, "alice/2026-01-01-x.md");
let mut edited = store.get(IssueSelector::Number(5)).unwrap();
edited.title = "Edited".into();
let err = store
.transaction(|| {
let moved = store.reassign("alice/2026-01-01-x.md", Some("@bob"))?;
store.update(&moved.to_string_lossy(), &edited)?;
Err::<(), _>(TixError::InvalidInput("boom".into()))
})
.unwrap_err();
assert!(err.to_string().contains("boom"));
assert!(
store.issues_dir.join("alice/2026-01-01-x.md").is_file(),
"the moved file must be back under its original segment"
);
assert!(
!store.issues_dir.join("bob/2026-01-01-x.md").exists(),
"the moved copy must not survive the rollback"
);
assert_eq!(read_issue_file(&store, "alice/2026-01-01-x.md"), before);
}
#[test]
fn a_failed_transaction_restores_a_deleted_file() {
let (_tmp, store) = make_store();
write_issue(
&store,
"2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
let before = read_issue_file(&store, "2026-01-01-a.md");
let _ = store
.transaction(|| {
store.delete("2026-01-01-a.md")?;
Err::<(), _>(TixError::InvalidInput("boom".into()))
})
.unwrap_err();
assert!(store.issues_dir.join("2026-01-01-a.md").is_file());
assert_eq!(read_issue_file(&store, "2026-01-01-a.md"), before);
}
#[test]
fn a_nested_transaction_joins_the_outer_one() {
let (_tmp, store) = make_store();
write_issue(
&store,
"2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
let before = read_issue_file(&store, "2026-01-01-a.md");
let mut a = store.get(IssueSelector::Number(1)).unwrap();
a.title = "Outer".into();
let mut a2 = a.clone();
a2.title = "Inner".into();
let err = store
.transaction(|| {
store.update("2026-01-01-a.md", &a)?;
store.transaction(|| {
store.update("2026-01-01-a.md", &a2)?;
Err::<(), _>(TixError::InvalidInput("inner boom".into()))
})?;
Ok(())
})
.unwrap_err();
assert!(err.to_string().contains("inner boom"));
assert_eq!(
read_issue_file(&store, "2026-01-01-a.md"),
before,
"the outer write must also be undone, so the whole unit is atomic"
);
}
#[test]
fn a_write_outside_a_transaction_is_not_journaled() {
let (_tmp, store) = make_store();
write_issue(
&store,
"2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
let mut a = store.get(IssueSelector::Number(1)).unwrap();
a.title = "Plain".into();
store.update("2026-01-01-a.md", &a).unwrap();
assert_eq!(store.get(IssueSelector::Number(1)).unwrap().title, "Plain");
}
fn project_store() -> (TempDir, 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 mut config = Config {
issues_dir: dir.clone(),
..Config::default()
};
config.layout.directory = "{assignee}".to_string();
config.layout.move_on_change = false;
config.projects.insert(
"p-1".to_string(),
ProjectConfiguration {
name: "One".into(),
..ProjectConfiguration::default()
},
);
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let store = Store {
issues_dir: dir,
config,
};
write_issue(
&store,
"2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
(tmp, store)
}
fn reloaded(store: &Store, filename: &str) -> Issue {
store
.get(IssueSelector::Filename(filename.to_string()))
.unwrap()
}
fn project_fixture(name: &str) -> String {
let path = format!(
"{}/tests/fixtures/projects/{name}",
env!("CARGO_MANIFEST_DIR")
);
fs::read_to_string(&path).unwrap_or_else(|e| panic!("reading fixture {name}: {e}"))
}
fn all_project_fixtures() -> Vec<String> {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/projects");
let mut names: Vec<String> = fs::read_dir(&dir)
.unwrap()
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.ends_with(".md"))
.collect();
names.sort();
names
}
#[test]
fn t013_an_accepted_association_writes_the_list_and_exactly_one_history_entry() {
let (_tmp, store) = project_store();
let before = read_issue_file(&store, "2026-01-01-a.md");
let outcome = store.associate("1", "p-1").unwrap();
assert!(outcome.changed, "an accepted, changing association writes");
assert!(outcome.written.is_some());
assert_eq!(outcome.project_ids, vec!["p-1".to_string()]);
let event = outcome
.event
.clone()
.expect("an accepted change is audited");
assert_eq!(event.detail, "project `p-1` associated");
let after = reloaded(&store, "2026-01-01-a.md");
assert_eq!(after.projects, Some(vec!["p-1".to_string()]));
let events = after.history.events();
assert_eq!(events.len(), 1, "exactly one History entry: {events:?}");
assert_eq!(events[0].kind, HistoryEventKind::ProjectAssociated);
assert_eq!(events[0].detail, "project `p-1` associated");
assert_ne!(
read_issue_file(&store, "2026-01-01-a.md"),
before,
"an accepted change is written"
);
}
#[test]
fn t013_a_refused_association_writes_nothing_and_appends_nothing() {
let (_tmp, store) = project_store();
let before = read_issue_file(&store, "2026-01-01-a.md");
let err = store.associate("1", "p-unknown").unwrap_err();
assert!(
matches!(err, TixError::InvalidInput(_)),
"an unknown project is a user input refusal, got {err:?}"
);
assert_eq!(
read_issue_file(&store, "2026-01-01-a.md"),
before,
"a refusal must leave the file byte-identical"
);
assert!(
reloaded(&store, "2026-01-01-a.md").history.is_empty(),
"a refusal must append nothing"
);
let err = store.associate("999", "p-1").unwrap_err();
assert!(matches!(err, TixError::NotFound(_)), "got {err:?}");
assert_eq!(read_issue_file(&store, "2026-01-01-a.md"), before);
}
#[test]
fn t013_a_refusal_reports_the_service_wording_not_a_storage_copy() {
let (_tmp, store) = project_store();
let err = store.associate("1", "p-unknown").unwrap_err();
assert!(
err.to_string()
.contains(&crate::projects::unknown_project_reason("p-unknown")),
"the refusal must come from the service, got: {err}"
);
}
#[test]
fn t013_re_associating_is_idempotent_no_second_entry_and_no_second_event() {
let (_tmp, store) = project_store();
store.associate("1", "p-1").unwrap();
let after_first = read_issue_file(&store, "2026-01-01-a.md");
let second = store.associate("1", "p-1").unwrap();
assert!(!second.changed, "the idempotent re-apply changes nothing");
assert!(second.written.is_none(), "and writes no file");
assert!(second.event.is_none(), "and appends no event");
assert_eq!(second.project_ids, vec!["p-1".to_string()]);
assert_eq!(
read_issue_file(&store, "2026-01-01-a.md"),
after_first,
"the file is byte-identical after a repeated association"
);
let after = reloaded(&store, "2026-01-01-a.md");
assert_eq!(after.history.events().len(), 1, "no second History event");
assert_eq!(
after.projects,
Some(vec!["p-1".to_string()]),
"and no duplicate entry: (issue, project_id) is unique"
);
}
#[test]
fn t013_dissociate_drops_only_the_association_and_records_one_event() {
let (_tmp, store) = project_store();
store.associate("1", "p-1").unwrap();
let outcome = store.dissociate("1", "p-1").unwrap();
assert!(outcome.changed);
assert_eq!(outcome.project_ids, [] as [std::string::String; 0]);
let event = outcome
.event
.clone()
.expect("an accepted change is audited");
assert_eq!(event.detail, "project `p-1` dissociated");
let after = reloaded(&store, "2026-01-01-a.md");
assert_eq!(after.projects, None, "the association is dropped");
assert!(
!read_issue_file(&store, "2026-01-01-a.md").contains("projects:"),
"an empty list is written as an absent key"
);
let events = after.history.events();
assert_eq!(events.len(), 2, "associate + dissociate = two entries");
assert_eq!(events[1].kind, HistoryEventKind::ProjectDissociated);
let again = store.dissociate("1", "p-1").unwrap();
assert!(!again.changed);
assert!(again.event.is_none());
assert_eq!(
reloaded(&store, "2026-01-01-a.md").history.events().len(),
2,
"no third History event"
);
}
#[test]
fn t013_bulk_associate_writes_each_affected_file_independently() {
let (_tmp, store) = project_store();
write_issue(
&store,
"2026-01-01-b.md",
&sample_frontmatter(2, "B", "todo"),
);
let outcomes = store
.bulk_associate(
"p-1",
&["1".to_string(), "999".to_string(), "2".to_string()],
)
.unwrap();
assert_eq!(outcomes.len(), 3, "one entry per selector, in input order");
assert_eq!(outcomes[0].selector, "1");
assert!(outcomes[0].outcome.as_ref().unwrap().changed);
assert!(
outcomes[1].outcome.is_err(),
"a bad selector is its own error, not a failure of the run"
);
assert!(outcomes[2].outcome.as_ref().unwrap().changed);
for filename in ["2026-01-01-a.md", "2026-01-01-b.md"] {
let issue = reloaded(&store, filename);
assert_eq!(issue.projects, Some(vec!["p-1".to_string()]));
assert_eq!(issue.history.events().len(), 1, "{filename}");
}
write_issue(
&store,
"2026-01-01-c.md",
&sample_frontmatter(3, "C", "todo"),
);
let before = read_issue_file(&store, "2026-01-01-c.md");
let refused = store
.bulk_associate("p-unknown", &["3".to_string()])
.unwrap();
assert!(refused[0].outcome.is_err());
assert_eq!(read_issue_file(&store, "2026-01-01-c.md"), before);
}
#[test]
fn t013_bulk_associate_is_idempotent_across_two_selectors_for_one_issue() {
let (_tmp, store) = project_store();
let outcomes = store
.bulk_associate("p-1", &["1".to_string(), "2026-01-01-a.md".to_string()])
.unwrap();
assert_eq!(outcomes.len(), 2, "one entry per selector, in input order");
let first = outcomes[0].outcome.as_ref().unwrap();
assert!(first.changed, "the first selector writes");
assert!(first.written.is_some());
assert!(first.event.is_some(), "and appends its one event");
let second = outcomes[1].outcome.as_ref().unwrap();
assert!(
!second.changed,
"the repeated selector is the idempotent no-op"
);
assert!(second.written.is_none(), "and writes nothing a second time");
assert!(second.event.is_none(), "and appends no second event");
assert_eq!(
second.project_ids,
vec!["p-1".to_string()],
"and still reports the current list"
);
let after = reloaded(&store, "2026-01-01-a.md");
assert_eq!(after.projects, Some(vec!["p-1".to_string()]));
assert_eq!(
after.history.events().len(),
1,
"exactly one History entry across both selectors"
);
}
#[test]
fn t014_nfr_002_every_project_fixture_round_trips_with_no_key_added_removed_or_reordered() {
let names = all_project_fixtures();
for required in [
"no-associations.md",
"one-association.md",
"several-associations.md",
"missing-reference.md",
"legacy-no-key.md",
] {
assert!(
names.iter().any(|name| name == required),
"the fixture set must cover every shape T014 names; {required} is missing from {names:?}"
);
}
for name in names {
let original = project_fixture(&name);
let issue = Issue::parse(&original).expect("fixture parses");
assert_eq!(
issue.to_file_contents(),
original,
"{name} must round-trip byte-identically"
);
assert_eq!(
Issue::parse(&issue.to_file_contents())
.unwrap()
.to_file_contents(),
original,
"{name} must be byte-stable once re-read"
);
}
}
#[test]
fn t014_nfr_002_a_legacy_fixture_gains_no_projects_key() {
let original = project_fixture("legacy-no-key.md");
assert!(!original.contains("projects:"));
let issue = Issue::parse(&original).unwrap();
assert_eq!(issue.projects, None);
assert_eq!(
issue.to_file_contents(),
original,
"a pre-project file must not gain a key"
);
}
#[test]
fn t014_c_003_a_multi_membership_fixture_is_byte_stable_and_ordered() {
let original = project_fixture("several-associations.md");
let issue = Issue::parse(&original).unwrap();
assert_eq!(
issue.projects,
Some(vec![
"p-platform".to_string(),
"p-docs".to_string(),
"p-infra".to_string()
]),
"declaration order is preserved (NFR-002)"
);
assert_eq!(issue.to_file_contents(), original);
}
#[test]
fn t014_c_005_a_missing_reference_round_trips_through_a_store_unchanged() {
let (_tmp, store) = make_store();
write_issue(
&store,
"missing-reference.md",
&project_fixture("missing-reference.md"),
);
assert!(
store.config.projects.is_empty(),
"the reference is missing by construction"
);
let issue = store.get(IssueSelector::Number(4)).unwrap();
assert_eq!(issue.projects, Some(vec!["p-deleted".to_string()]));
store.update(&issue.filename, &issue).unwrap();
assert_eq!(
read_issue_file(&store, "missing-reference.md"),
project_fixture("missing-reference.md"),
"saving a missing reference must not drop or rewrite it"
);
let reloaded_issue = store.get(IssueSelector::Number(4)).unwrap();
assert_eq!(reloaded_issue.projects, Some(vec!["p-deleted".to_string()]));
}
#[test]
fn t014_c_003_associating_an_issue_writes_no_member_list_to_the_registry() {
let (tmp, store) = project_store();
let config_path = tmp.path().join("tix.toml");
let before = fs::read_to_string(&config_path).unwrap();
assert!(
before.contains("[projects.p-1]"),
"the record is persisted before the association: {before}"
);
store.associate("1", "p-1").unwrap();
let after = fs::read_to_string(&config_path).unwrap();
assert_eq!(
after, before,
"the project's registry record must not grow a member list"
);
assert!(
!after.contains("members"),
"membership is derived, never stored on the project:\n{after}"
);
}
fn epic_store() -> (TempDir, 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 mut config = Config {
issues_dir: dir.clone(),
..Config::default()
};
config.layout.directory = "{assignee}".to_string();
config.layout.move_on_change = false;
config.epics.insert(
"e-7c4a91".to_string(),
EpicConfiguration {
name: "One".into(),
..EpicConfiguration::default()
},
);
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let store = Store {
issues_dir: dir,
config,
};
write_issue(
&store,
"2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
(tmp, store)
}
fn epic_fixture(name: &str) -> String {
let path = format!("{}/tests/fixtures/epics/{name}", env!("CARGO_MANIFEST_DIR"));
fs::read_to_string(&path).unwrap_or_else(|e| panic!("reading fixture {name}: {e}"))
}
fn all_epic_fixtures() -> Vec<String> {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/epics");
let mut names: Vec<String> = fs::read_dir(&dir)
.unwrap()
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.ends_with(".md"))
.collect();
names.sort();
names
}
#[test]
fn t015_an_accepted_reference_writes_the_list_and_exactly_one_history_entry() {
let (_tmp, store) = epic_store();
let before = read_issue_file(&store, "2026-01-01-a.md");
let outcome = store.reference_epic("1", "e-7c4a91").unwrap();
assert!(outcome.changed, "an accepted, changing reference writes");
assert!(outcome.written.is_some());
assert_eq!(outcome.epic_ids, vec!["e-7c4a91".to_string()]);
let event = outcome
.event
.clone()
.expect("an accepted change is audited");
assert_eq!(event.detail, "epic `e-7c4a91` referenced");
let after = reloaded(&store, "2026-01-01-a.md");
assert_eq!(after.epics, Some(vec!["e-7c4a91".to_string()]));
let events = after.history.events();
assert_eq!(events.len(), 1, "exactly one History entry: {events:?}");
assert_eq!(events[0].kind, HistoryEventKind::EpicReferenced);
assert_eq!(events[0].detail, "epic `e-7c4a91` referenced");
assert_ne!(
read_issue_file(&store, "2026-01-01-a.md"),
before,
"an accepted change is written"
);
}
#[test]
fn t015_a_refused_reference_writes_nothing_and_appends_nothing() {
let (_tmp, store) = epic_store();
let before = read_issue_file(&store, "2026-01-01-a.md");
let err = store.reference_epic("1", "e-unknown").unwrap_err();
assert!(
matches!(err, TixError::InvalidInput(_)),
"an unknown epic is a user input refusal, got {err:?}"
);
assert_eq!(
read_issue_file(&store, "2026-01-01-a.md"),
before,
"a refusal must leave the file byte-identical"
);
assert!(
reloaded(&store, "2026-01-01-a.md").history.is_empty(),
"a refusal must append nothing"
);
let err = store.reference_epic("999", "e-7c4a91").unwrap_err();
assert!(matches!(err, TixError::NotFound(_)), "got {err:?}");
assert_eq!(read_issue_file(&store, "2026-01-01-a.md"), before);
}
#[test]
fn t015_a_refusal_reports_the_service_wording_not_a_storage_copy() {
let (_tmp, store) = epic_store();
let err = store.reference_epic("1", "e-unknown").unwrap_err();
assert!(
err.to_string()
.contains(&crate::epics::unknown_epic_reason("e-unknown")),
"the refusal must come from the service, got: {err}"
);
}
#[test]
fn t015_re_referencing_is_idempotent_no_second_entry_and_no_second_event() {
let (_tmp, store) = epic_store();
store.reference_epic("1", "e-7c4a91").unwrap();
let after_first = read_issue_file(&store, "2026-01-01-a.md");
let second = store.reference_epic("1", "e-7c4a91").unwrap();
assert!(!second.changed, "the idempotent re-apply changes nothing");
assert!(second.written.is_none(), "and writes no file");
assert!(second.event.is_none(), "and appends no event");
assert_eq!(second.epic_ids, vec!["e-7c4a91".to_string()]);
assert_eq!(
read_issue_file(&store, "2026-01-01-a.md"),
after_first,
"the file is byte-identical after a repeated reference"
);
let after = reloaded(&store, "2026-01-01-a.md");
assert_eq!(after.history.events().len(), 1, "no second History event");
assert_eq!(
after.epics,
Some(vec!["e-7c4a91".to_string()]),
"and no duplicate entry: (issue, epic_id) is unique"
);
}
#[test]
fn t015_dereference_drops_only_the_reference_and_records_one_event() {
let (_tmp, store) = epic_store();
store.reference_epic("1", "e-7c4a91").unwrap();
let outcome = store.dereference_epic("1", "e-7c4a91").unwrap();
assert!(outcome.changed);
assert_eq!(outcome.epic_ids, [] as [std::string::String; 0]);
let event = outcome
.event
.clone()
.expect("an accepted change is audited");
assert_eq!(event.detail, "epic `e-7c4a91` dereferenced");
let after = reloaded(&store, "2026-01-01-a.md");
assert_eq!(after.epics, None, "the reference is dropped");
assert!(
!read_issue_file(&store, "2026-01-01-a.md").contains("epics:"),
"an empty list is written as an absent key"
);
let events = after.history.events();
assert_eq!(events.len(), 2, "reference + dereference = two entries");
assert_eq!(events[1].kind, HistoryEventKind::EpicDereferenced);
let again = store.dereference_epic("1", "e-7c4a91").unwrap();
assert!(!again.changed);
assert!(again.event.is_none());
assert_eq!(
reloaded(&store, "2026-01-01-a.md").history.events().len(),
2,
"no third History event"
);
}
#[test]
fn t015_dereference_retains_a_missing_reference_and_removes_it_verbatim() {
let (_tmp, store) = epic_store();
write_issue(
&store,
"2026-01-01-b.md",
"---\nnumber: 2\ntitle: B\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\nepics:\n- e-deleted\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.",
);
let outcome = store.dereference_epic("2", "e-deleted").unwrap();
assert!(outcome.changed, "a stale reference is removable");
assert_eq!(outcome.epic_ids, [] as [std::string::String; 0]);
assert_eq!(reloaded(&store, "2026-01-01-b.md").epics, None);
}
#[test]
fn t015_bulk_reference_writes_each_affected_file_independently() {
let (_tmp, store) = epic_store();
write_issue(
&store,
"2026-01-01-b.md",
&sample_frontmatter(2, "B", "todo"),
);
let outcomes = store
.bulk_reference(
"e-7c4a91",
&["1".to_string(), "999".to_string(), "2".to_string()],
)
.unwrap();
assert_eq!(outcomes.len(), 3, "one entry per selector, in input order");
assert_eq!(outcomes[0].selector, "1");
assert!(outcomes[0].outcome.as_ref().unwrap().changed);
assert!(
outcomes[1].outcome.is_err(),
"a bad selector is its own error, not a failure of the run"
);
assert!(outcomes[2].outcome.as_ref().unwrap().changed);
for filename in ["2026-01-01-a.md", "2026-01-01-b.md"] {
let issue = reloaded(&store, filename);
assert_eq!(issue.epics, Some(vec!["e-7c4a91".to_string()]));
assert_eq!(issue.history.events().len(), 1, "{filename}");
}
write_issue(
&store,
"2026-01-01-c.md",
&sample_frontmatter(3, "C", "todo"),
);
let before = read_issue_file(&store, "2026-01-01-c.md");
let refused = store
.bulk_reference("e-unknown", &["3".to_string()])
.unwrap();
assert!(refused[0].outcome.is_err());
assert_eq!(read_issue_file(&store, "2026-01-01-c.md"), before);
}
#[test]
fn t015_bulk_reference_is_idempotent_across_two_selectors_for_one_issue() {
let (_tmp, store) = epic_store();
let outcomes = store
.bulk_reference(
"e-7c4a91",
&["1".to_string(), "2026-01-01-a.md".to_string()],
)
.unwrap();
assert_eq!(outcomes.len(), 2, "one entry per selector, in input order");
let first = outcomes[0].outcome.as_ref().unwrap();
assert!(first.changed, "the first selector writes");
assert!(first.written.is_some());
assert!(first.event.is_some(), "and appends its one event");
let second = outcomes[1].outcome.as_ref().unwrap();
assert!(
!second.changed,
"the repeated selector is the idempotent no-op"
);
assert!(second.written.is_none(), "and writes nothing a second time");
assert!(second.event.is_none(), "and appends no second event");
assert_eq!(
second.epic_ids,
vec!["e-7c4a91".to_string()],
"and still reports the current list"
);
let after = reloaded(&store, "2026-01-01-a.md");
assert_eq!(after.epics, Some(vec!["e-7c4a91".to_string()]));
assert_eq!(
after.history.events().len(),
1,
"exactly one History entry across both selectors"
);
}
#[test]
fn t016_nfr_002_every_epic_fixture_round_trips_with_no_key_added_removed_or_reordered() {
let names = all_epic_fixtures();
for required in [
"no-references.md",
"one-reference.md",
"several-references.md",
"missing-reference.md",
"legacy-no-key.md",
"task-labelled.md",
] {
assert!(
names.iter().any(|name| name == required),
"the fixture set must cover every shape T016 names; {required} is missing from {names:?}"
);
}
for name in names {
let original = epic_fixture(&name);
let issue = Issue::parse(&original).expect("fixture parses");
assert_eq!(
issue.to_file_contents(),
original,
"{name} must round-trip byte-identically"
);
assert_eq!(
Issue::parse(&issue.to_file_contents())
.unwrap()
.to_file_contents(),
original,
"{name} must be byte-stable once re-read"
);
}
}
#[test]
fn t016_nfr_002_a_legacy_fixture_gains_no_epics_key() {
let original = epic_fixture("legacy-no-key.md");
assert!(!original.contains("epics:"));
let issue = Issue::parse(&original).unwrap();
assert_eq!(issue.epics, None);
assert_eq!(
issue.to_file_contents(),
original,
"a pre-epic file must not gain a key"
);
}
#[test]
fn t016_c_003_a_multi_reference_fixture_is_byte_stable_and_ordered() {
let original = epic_fixture("several-references.md");
let issue = Issue::parse(&original).unwrap();
assert_eq!(
issue.epics,
Some(vec![
"e-platform".to_string(),
"e-docs".to_string(),
"e-infra".to_string()
]),
"declaration order is preserved (NFR-002)"
);
assert_eq!(issue.to_file_contents(), original);
}
#[test]
fn t016_c_005_a_missing_reference_round_trips_through_a_store_unchanged() {
let (_tmp, store) = make_store();
write_issue(
&store,
"missing-reference.md",
&epic_fixture("missing-reference.md"),
);
assert!(
store.config.epics.is_empty(),
"the reference is missing by construction"
);
let issue = store.get(IssueSelector::Number(4)).unwrap();
assert_eq!(issue.epics, Some(vec!["e-deleted".to_string()]));
store.update(&issue.filename, &issue).unwrap();
assert_eq!(
read_issue_file(&store, "missing-reference.md"),
epic_fixture("missing-reference.md"),
"saving a missing reference must not drop or rewrite it"
);
let reloaded_issue = store.get(IssueSelector::Number(4)).unwrap();
assert_eq!(reloaded_issue.epics, Some(vec!["e-deleted".to_string()]));
}
#[test]
fn t016_c_001_a_task_labelled_fixture_round_trips_and_gains_no_task_key() {
let original = epic_fixture("task-labelled.md");
let issue = Issue::parse(&original).unwrap();
assert_eq!(issue.epics, Some(vec!["e-7c4a91".to_string()]));
assert_eq!(issue.task, Some("WP03".to_string()));
let rendered = issue.to_file_contents();
assert_eq!(rendered, original, "the fixture round-trips unchanged");
assert_eq!(
rendered.matches("task:").count(),
1,
"exactly one `task:` key — the Spec Kitty pointer, never a second:\n{rendered}"
);
for forbidden in ["work_item:", "epic_task:"] {
assert!(
!rendered.contains(forbidden),
"`{forbidden}` must not exist (C-001):\n{rendered}"
);
}
}
#[test]
fn t016_c_003_referencing_an_issue_writes_no_member_list_to_the_registry() {
let (tmp, store) = epic_store();
let config_path = tmp.path().join("tix.toml");
let before = fs::read_to_string(&config_path).unwrap();
assert!(
before.contains("[epics") && before.contains("e-7c4a91"),
"the record is persisted before the reference: {before}"
);
store.reference_epic("1", "e-7c4a91").unwrap();
let after = fs::read_to_string(&config_path).unwrap();
assert_eq!(
after, before,
"the epic's registry record must not grow a member list"
);
assert!(
!after.contains("members"),
"membership is derived, never stored on the epic:\n{after}"
);
}
#[test]
fn every_issue_write_seam_records_the_paths_the_tui_must_refresh() {
let (_tmp, store) = make_store();
let mut issue = crate::model::Issue::parse(&sample_frontmatter(1, "A", "todo"))
.expect("the fixture parses");
store.create(&mut issue, "Body", None).unwrap();
let created = issue.filename.clone();
match take_internal_issue_writes() {
InternalIssueWrites::Delta(delta) => {
assert_eq!(delta.changed, vec![std::path::PathBuf::from(&created)]);
assert_eq!(delta.removed, [] as [std::path::PathBuf; 0]);
}
other => panic!("create did not journal one changed path: {other:?}"),
}
issue.title = "A edited".into();
store.update(&created, &issue).unwrap();
match take_internal_issue_writes() {
InternalIssueWrites::Delta(delta) => {
assert_eq!(delta.changed, vec![std::path::PathBuf::from(&created)]);
assert_eq!(delta.removed, [] as [std::path::PathBuf; 0]);
}
other => panic!("update did not journal one changed path: {other:?}"),
}
store.delete(&issue.filename).unwrap();
match take_internal_issue_writes() {
InternalIssueWrites::Delta(delta) => {
assert_eq!(delta.changed, [] as [std::path::PathBuf; 0]);
assert_eq!(
delta.removed,
vec![std::path::PathBuf::from(&issue.filename)]
);
}
other => panic!("delete did not journal one removed path: {other:?}"),
}
}
#[test]
fn the_internal_write_journal_is_bounded_and_falls_back_to_a_rebuild() {
take_internal_issue_writes();
for number in 0..=ISSUE_WRITE_JOURNAL_LIMIT {
journal_issue_change(std::path::Path::new(&format!("{number}.md")), true);
}
assert!(matches!(
take_internal_issue_writes(),
InternalIssueWrites::Overflow
));
assert!(matches!(
take_internal_issue_writes(),
InternalIssueWrites::None
));
}