use std::fs;
use tempfile::TempDir;
use tix::cli::MigrateAction;
use tix::commands::migrate;
use tix::config::Config;
use tix::id::IssueRef;
use tix::storage::{IssueSelector, Store};
fn project() -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let issues = tmp.path().join("issues");
fs::create_dir_all(&issues).unwrap();
let mut config = Config {
issues_dir: issues,
..Config::default()
};
config.layout.move_on_change = false;
config.save(&tmp.path().join("tix.toml")).unwrap();
let loaded = Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
let store = Store::open(&loaded).unwrap();
(tmp, store)
}
fn write(store: &Store, name: &str, number: u32, id: Option<&str>, extra: &str) {
let id = id.map_or_else(String::new, |id| format!("id: {id}\n"));
let path = store.issues_dir.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(
path,
format!(
"---\nnumber: {number}\n{id}title: \"Issue {number}\"\nstatus: \"todo\"\n\
labels: []\npriority: \"medium\"\nfiles: []\n{extra}\
created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody {number}.\n\
\n## History\n\n- 2026-01-01T00:00:00Z — tester — created — made\n"
),
)
.unwrap();
}
fn snapshot(store: &Store) -> Vec<(String, Vec<u8>)> {
let mut out = Vec::new();
for issue in store.list_all().unwrap() {
let bytes = fs::read(store.issues_dir.join(&issue.filename)).unwrap();
out.push((issue.filename, bytes));
}
out.sort();
out
}
#[test]
fn graph_migration_preview_apply_and_second_apply_are_idempotent() {
let (_tmp, store) = project();
write(&store, "parent.md", 1, Some("A1B2C3D4"), "");
write(
&store,
"child.md",
2,
None,
"custom_metadata: keep-me\nparent: 1\nrelationships:\n- id: rel-one\n from: 2\n to: 1\n \
kind: blocks\n display: blocks #1\n",
);
let before = snapshot(&store);
migrate::run(&store, &MigrateAction::IssueGraph { apply: false }).unwrap();
assert_eq!(snapshot(&store), before, "preview writes nothing");
migrate::run(&store, &MigrateAction::IssueGraph { apply: true }).unwrap();
let issues = store.list_all().unwrap();
let parent = issues.iter().find(|issue| issue.number == 1).unwrap();
let child = issues.iter().find(|issue| issue.number == 2).unwrap();
let parent_id = parent.id.as_deref().unwrap();
let child_id = child.id.as_deref().unwrap();
assert_eq!(child.parent, IssueRef::id(parent_id));
assert_eq!(child.relationships[0].from, IssueRef::id(child_id).unwrap());
assert_eq!(child.relationships[0].to, IssueRef::id(parent_id).unwrap());
let text = fs::read_to_string(store.issues_dir.join(&child.filename)).unwrap();
assert!(text.contains("custom_metadata: keep-me"), "{text}");
assert!(text.contains("Body 2."), "{text}");
let after = snapshot(&store);
migrate::run(&store, &MigrateAction::IssueGraph { apply: true }).unwrap();
assert_eq!(snapshot(&store), after, "a second apply is a no-op");
}
#[test]
fn graph_migration_refuses_ambiguous_and_unresolved_aliases_without_writing() {
let (_tmp, store) = project();
write(
&store,
"a.md",
7,
Some("A1B2C3D4"),
"parent: 7\nrelationships:\n- id: rel-a\n from: 7\n to: 99\n \
kind: related\n display: related\n",
);
write(&store, "b.md", 7, Some("B1B2C3D4"), "");
let before = snapshot(&store);
let error = migrate::run(&store, &MigrateAction::IssueGraph { apply: true })
.expect_err("ambiguous and unresolved aliases refuse the migration");
assert!(error.to_string().contains("refused"), "{error}");
assert_eq!(
snapshot(&store),
before,
"a refused migration writes nothing"
);
}
#[test]
fn graph_migration_assigns_a_distinct_id_when_none_exists() {
let (_tmp, store) = project();
write(&store, "one.md", 1, None, "");
migrate::run(&store, &MigrateAction::IssueGraph { apply: true }).unwrap();
let issue = store.get(IssueSelector::Number(1)).unwrap();
let id = issue.id.expect("an id was backfilled");
assert!(tix::id::is_issue_id(&id), "{id}");
assert!(
fs::read_to_string(store.issues_dir.join(&issue.filename))
.unwrap()
.contains(&format!("id: {id}"))
);
}
#[cfg(unix)]
#[test]
fn graph_migration_rolls_back_earlier_writes_when_a_later_write_fails() {
use std::os::unix::fs::PermissionsExt;
struct Restore(std::path::PathBuf);
impl Drop for Restore {
fn drop(&mut self) {
let mut permissions = fs::metadata(&self.0).unwrap().permissions();
permissions.set_mode(0o755);
let _ = fs::set_permissions(&self.0, permissions);
}
}
let (_tmp, store) = project();
write(&store, "a/one.md", 1, None, "");
write(&store, "b/two.md", 2, None, "");
let before = snapshot(&store);
let blocked = store.issues_dir.join("b");
let mut permissions = fs::metadata(&blocked).unwrap().permissions();
permissions.set_mode(0o555);
fs::set_permissions(&blocked, permissions).unwrap();
let _restore = Restore(blocked);
let error = migrate::run(&store, &MigrateAction::IssueGraph { apply: true })
.expect_err("the later generated-id write must fail");
assert!(
error
.to_string()
.to_ascii_lowercase()
.contains("permission denied"),
"{error}"
);
assert_eq!(
snapshot(&store),
before,
"the first file's generated id must be rolled back"
);
}