use std::fs;
use std::path::PathBuf;
use std::process::Output;
use assert_cmd::Command;
use tempfile::TempDir;
fn tix() -> Command {
Command::cargo_bin("tix").unwrap()
}
fn bin_dir() -> PathBuf {
let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into());
PathBuf::from(manifest).join("tests").join("bin")
}
struct Harness {
tmp: TempDir,
record: PathBuf,
existing_path: String,
}
impl Harness {
fn new() -> Self {
let tmp = TempDir::new().unwrap();
let record = tmp.path().join("records.txt");
let existing_path = std::env::var("PATH").unwrap_or_default();
Harness {
tmp,
record,
existing_path,
}
}
fn dir(&self) -> &std::path::Path {
self.tmp.path()
}
fn init(&self) {
tix().current_dir(self.dir()).arg("init").assert().success();
}
fn init_with_gitlab(&self) {
self.init();
let toml_path = self.dir().join("tix.toml");
let mut contents = fs::read_to_string(&toml_path).unwrap();
contents.push_str("\n[export.gitlab]\nproject = \"acme/tix\"\ntoken = \"gl-file-token\"\n");
fs::write(&toml_path, contents).unwrap();
}
fn init_with_linear(&self) {
self.init();
let toml_path = self.dir().join("tix.toml");
let mut contents = fs::read_to_string(&toml_path).unwrap();
contents.push_str("\n[export.linear]\nteam = \"Eng\"\ntoken = \"lin-file-token\"\n");
fs::write(&toml_path, contents).unwrap();
}
fn create_issue(&self, title: &str, body: &str) -> Output {
let out = tix()
.current_dir(self.dir())
.arg("create")
.args(["--title", title])
.args(["--body", body])
.output()
.unwrap();
assert!(
out.status.success(),
"create failed: {}",
String::from_utf8_lossy(&out.stderr)
);
out
}
fn export(&self, target: &str, extra_args: &[&str], env_token: Option<(&str, &str)>) -> Output {
let mut cmd = tix();
cmd.current_dir(self.dir())
.arg("export")
.args(["--target", target])
.args(extra_args)
.env(
"PATH",
format!("{}:{}", bin_dir().display(), self.existing_path),
)
.env("TIX_FAKE_RECORD", &self.record);
if let Some((key, val)) = env_token {
cmd.env(key, val);
} else {
cmd.env_remove("GITLAB_TOKEN").env_remove("LINEAR_TOKEN");
}
cmd.output().unwrap()
}
fn import(&self, target: &str, fake_list: &str) -> Output {
tix()
.current_dir(self.dir())
.arg("import")
.args(["--target", target])
.env(
"PATH",
format!("{}:{}", bin_dir().display(), self.existing_path),
)
.env("TIX_FAKE_RECORD", &self.record)
.env("TIX_FAKE_LIST", fake_list)
.env_remove("GITLAB_TOKEN")
.env_remove("LINEAR_TOKEN")
.output()
.unwrap()
}
fn record_contents(&self) -> String {
fs::read_to_string(&self.record).unwrap_or_default()
}
fn list(&self) -> String {
let out = tix().current_dir(self.dir()).arg("list").output().unwrap();
assert!(out.status.success());
String::from_utf8_lossy(&out.stdout).to_string()
}
fn view(&self, selector: &str) -> String {
let out = tix()
.current_dir(self.dir())
.arg("view")
.arg(selector)
.output()
.unwrap();
assert!(out.status.success());
String::from_utf8_lossy(&out.stdout).to_string()
}
}
#[test]
fn export_creates_and_backfills() {
let h = Harness::new();
h.init_with_gitlab();
h.create_issue("Fix login", "users cannot log in");
let out = h.export("gitlab", &[], Some(("GITLAB_TOKEN", "gl-env-secret")));
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("created gitlab issue"), "stdout: {stdout}");
let record = h.record_contents();
assert!(
record.contains("glab issue create --project acme/tix"),
"record: {record}"
);
assert!(
record.contains("GITLAB_TOKEN=gl-env-secret"),
"record: {record}"
);
let view = h.view("fix-login");
assert!(
view.contains("gitlab_id:"),
"backfill missing in view: {view}"
);
}
#[test]
fn export_dry_run_does_not_invoke_tool() {
let h = Harness::new();
h.init_with_gitlab();
h.create_issue("Fix login", "users cannot log in");
let out = h.export(
"gitlab",
&["--dry-run"],
Some(("GITLAB_TOKEN", "gl-env-secret")),
);
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
stdout.contains("would create gitlab issue"),
"stdout: {stdout}"
);
assert!(!h.record.exists(), "dry run must not create a record file");
}
#[test]
fn reexport_issues_update_not_duplicate() {
let h = Harness::new();
h.init_with_gitlab();
h.create_issue("Fix login", "users cannot log in");
h.export("gitlab", &[], Some(("GITLAB_TOKEN", "gl-env-secret")));
let record_after_first = h.record_contents();
assert!(record_after_first.contains("issue create"));
let out = h.export("gitlab", &[], Some(("GITLAB_TOKEN", "gl-env-secret")));
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("updated gitlab issue"), "stdout: {stdout}");
let record = h.record_contents();
let create_count = record.matches("issue create").count();
let update_count = record.matches("issue update").count();
assert_eq!(create_count, 1, "expected exactly 1 create in:\n{record}");
assert!(
update_count >= 1,
"expected at least 1 update in:\n{record}"
);
}
#[test]
fn import_creates_new_local_issues() {
let h = Harness::new();
h.init_with_gitlab();
let fake_list = r#"[{"iid":10,"title":"Remote bug","description":"from remote","labels":[{"name":"bug"}],"state":"opened","updated_at":"2026-05-12T08:00:00Z","assignees":[{"username":"alice"}]}]"#;
let out = h.import("gitlab", fake_list);
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
stdout.contains("imported gitlab issue 10"),
"stdout: {stdout}"
);
let list = h.list();
assert!(list.contains("Remote bug"), "list: {list}");
let view = h.view("remote");
assert!(view.contains("gitlab_id: '10'"), "backfill missing: {view}");
assert!(view.contains("- bug"), "labels missing: {view}");
assert!(view.contains("@alice"), "assignee missing: {view}");
assert!(view.contains("title: Remote bug"), "title missing: {view}");
}
#[test]
fn import_filters_closed_issues() {
let h = Harness::new();
h.init_with_gitlab();
let fake_list = r#"[{"iid":10,"title":"Open","state":"opened"},{"iid":20,"title":"Closed","state":"closed"}]"#;
let out = h.import("gitlab", fake_list);
assert!(out.status.success());
let list = h.list();
assert!(list.contains("Open"), "list: {list}");
assert!(
!list.contains("Closed"),
"list must not contain closed: {list}"
);
}
#[test]
fn import_routes_assignee_into_derived_segment() {
let h = Harness::new();
h.init_with_gitlab();
let fake_list = r#"[
{"iid":10,"title":"Assigned remote","description":"with an owner","labels":[],"state":"opened","updated_at":"2026-05-12T08:00:00Z","assignees":[{"username":"alice"}]},
{"iid":20,"title":"Unassigned remote","description":"orphaned","labels":[],"state":"opened","updated_at":"2026-05-12T08:00:00Z"}
]"#;
let out = h.import("gitlab", fake_list);
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
stdout.contains("imported gitlab issue 10"),
"stdout: {stdout}"
);
assert!(
stdout.contains("imported gitlab issue 20"),
"stdout: {stdout}"
);
let issues = h.dir().join("docs").join("issues");
let md_in = |rel: &str| -> Vec<String> {
let root = issues.join(rel);
let mut files: Vec<String> = Vec::new();
let mut stack = vec![root];
while let Some(next) = stack.pop() {
for entry in fs::read_dir(&next).unwrap() {
let path = entry.unwrap().path();
let name = path.file_name().unwrap().to_string_lossy().to_string();
if path.is_dir() {
if name.starts_with('.')
|| name.starts_with(".tix-tmp-")
|| name.ends_with("-assets")
{
continue;
}
stack.push(path);
} else if path.extension().is_some_and(|e| e == "md") {
files.push(
path.strip_prefix(&issues)
.unwrap()
.to_string_lossy()
.to_string(),
);
}
}
}
files.sort();
files
};
let assigned = md_in("alice");
assert_eq!(assigned.len(), 1, "expected one file under alice/");
let assigned_file = issues.join(&assigned[0]);
let assigned_raw = fs::read_to_string(&assigned_file).unwrap();
assert!(
assigned_raw.contains("assignee:") && assigned_raw.contains("alice"),
"assigned frontmatter must carry @alice: {assigned_raw}"
);
let unassigned = md_in("unassigned");
assert_eq!(
unassigned.len(),
1,
"expected one file under unassigned/ (got {unassigned:?})"
);
let unassigned_raw = fs::read_to_string(issues.join(&unassigned[0])).unwrap();
assert!(
!unassigned_raw.contains("assignee"),
"unassigned frontmatter must omit assignee: {unassigned_raw}"
);
}
fn write_issue_file(
dir: &std::path::Path,
filename: &str,
gitlab_id: &str,
title: &str,
updated: &str,
) {
let body = format!(
"---\nnumber: 1\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ngitlab_id: \"{gitlab_id}\"\ncreated: \"2026-01-01\"\nupdated: \"{updated}\"\n---\n\nOriginal body."
);
fs::write(dir.join("docs").join("issues").join(filename), body).unwrap();
}
#[test]
fn import_keeps_local_when_older_remote() {
let h = Harness::new();
h.init_with_gitlab();
let issues_dir = h.dir().join("docs").join("issues");
fs::create_dir_all(&issues_dir).unwrap();
write_issue_file(
h.dir(),
"2026-01-01-existing.md",
"10",
"Existing Title",
"2026-01-02",
);
let fake_list = r#"[{"iid":10,"title":"Remote Title","description":"remote body","labels":[],"state":"opened","updated_at":"2020-01-01T00:00:00Z"}]"#;
let out = h.import("gitlab", fake_list);
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("kept gitlab issue 10"), "stdout: {stdout}");
let view = h.view("existing");
assert!(
view.contains("title: Existing Title"),
"title should be unchanged: {view}"
);
assert!(
!view.contains("Remote Title"),
"should not contain remote title: {view}"
);
}
#[test]
fn import_overwrites_when_remote_newer_and_creates_bak() {
let h = Harness::new();
h.init_with_gitlab();
let issues_dir = h.dir().join("docs").join("issues");
fs::create_dir_all(&issues_dir).unwrap();
write_issue_file(
h.dir(),
"2026-01-01-old.md",
"10",
"Old Title",
"2020-01-01",
);
let fake_list = r#"[{"iid":10,"title":"New Title","description":"new body","labels":["new-label"],"state":"opened","updated_at":"2026-05-12T00:00:00Z","assignees":[{"username":"bob"}]}]"#;
let out = h.import("gitlab", fake_list);
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("overwrote"), "stdout: {stdout}");
let view = h.view("old");
assert!(
view.contains("title: New Title"),
"title should be updated: {view}"
);
assert!(
view.contains("gitlab_id: '10'"),
"gitlab_id preserved: {view}"
);
assert!(
view.contains("created: 2026-01-01"),
"created must be preserved: {view}"
);
let bak = issues_dir.join("2026-01-01-old.md.bak");
assert!(bak.is_file(), ".bak must exist at {}", bak.display());
let bak_contents = fs::read_to_string(&bak).unwrap();
assert!(
bak_contents.contains("Old Title"),
".bak must hold original: {bak_contents}"
);
}
#[test]
fn export_missing_binary_exits_3() {
let h = Harness::new();
h.init_with_gitlab();
h.create_issue("Fix login", "users cannot log in");
let mut cmd = tix();
cmd.current_dir(h.dir())
.arg("export")
.args(["--target", "gitlab"])
.env("PATH", "/usr/bin")
.env_remove("GITLAB_TOKEN");
let out = cmd.output().unwrap();
assert_eq!(
out.status.code(),
Some(3),
"expected exit 3, got: {:?}",
out.status
);
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
stderr.contains(
"required tool not found: glab — install from https://gitlab.com/gitlab-org/cli"
),
"stderr: {stderr}"
);
}
#[test]
fn export_token_scrubbed_on_tool_failure() {
let h = Harness::new();
h.init_with_gitlab();
h.create_issue("Fix login", "users cannot log in");
let mut cmd = tix();
cmd.current_dir(h.dir())
.arg("export")
.args(["--target", "gitlab"])
.env("PATH", format!("{}:{}", bin_dir().display(), "/usr/bin"))
.env("TIX_FAKE_RECORD", &h.record)
.env("TIX_FAKE_FAIL", "1")
.env("GITLAB_TOKEN", "gl-env-secret");
let out = cmd.output().unwrap();
assert_eq!(
out.status.code(),
Some(3),
"expected exit 3, got: {:?}",
out.status
);
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
!stderr.contains("gl-env-secret"),
"token leaked to stderr: {stderr}"
);
assert!(
stderr.contains("<token>"),
"expected redaction, stderr: {stderr}"
);
}
#[test]
fn linear_export_import_roundtrip() {
let h = Harness::new();
h.init_with_linear();
h.create_issue("Fix login", "linear body");
let out = h.export("linear", &[], Some(("LINEAR_TOKEN", "lin-env")));
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("created linear issue"), "stdout: {stdout}");
let view = h.view("fix-login");
assert!(view.contains("linear_id:"), "backfill missing: {view}");
let record = h.record_contents();
assert!(record.contains("linear-cli issue create --team Eng"));
assert!(record.contains("LINEAR_TOKEN=lin-env"));
let fake_list = r#"[{"identifier":"TIX-200","title":"Linear task","description":"desc","labels":["feature"],"status":{"name":"Todo"},"updated_at":"2026-05-12T00:00:00Z"}]"#;
let out = h.import("linear", fake_list);
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
stdout.contains("imported linear issue TIX-200"),
"stdout: {stdout}"
);
let list = h.list();
assert!(list.contains("Linear task"), "list: {list}");
}
#[test]
fn linear_export_dry_run() {
let h = Harness::new();
h.init_with_linear();
h.create_issue("Task", "body");
let out = h.export("linear", &["--dry-run"], Some(("LINEAR_TOKEN", "lin-env")));
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
stdout.contains("would create linear issue"),
"stdout: {stdout}"
);
assert!(!h.record.exists());
}
#[test]
fn linear_reexport_updates_not_duplicates() {
let h = Harness::new();
h.init_with_linear();
h.create_issue("Task", "body");
h.export("linear", &[], Some(("LINEAR_TOKEN", "lin-env")));
let out = h.export("linear", &[], Some(("LINEAR_TOKEN", "lin-env")));
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("updated linear issue"), "stdout: {stdout}");
let record = h.record_contents();
assert_eq!(
record.matches("issue create").count(),
1,
"create must happen once: {record}"
);
assert!(
record.matches("issue update").count() >= 1,
"expected at least 1 update: {record}"
);
}
#[test]
fn export_nothing_to_export_prints_message() {
let h = Harness::new();
h.init_with_gitlab();
let out = h.export("gitlab", &[], Some(("GITLAB_TOKEN", "gl-env")));
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("nothing to export"), "stdout: {stdout}");
}
#[test]
fn import_nothing_to_import_prints_message() {
let h = Harness::new();
h.init_with_gitlab();
let out = h.import("gitlab", "[]");
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("nothing to import"), "stdout: {stdout}");
}
#[test]
fn export_missing_gitlab_config_fails() {
let h = Harness::new();
h.init(); let out = h.export("gitlab", &[], Some(("GITLAB_TOKEN", "x")));
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
stderr.contains("[export.gitlab]") || stderr.contains("tix.toml"),
"stderr: {stderr}"
);
}
fn link(h: &Harness, a: &str, b: &str, kind: &str) {
let out = tix()
.current_dir(h.dir())
.args(["link", a, b, "--type", kind])
.output()
.unwrap();
assert!(
out.status.success(),
"link {a} {b} --type {kind} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn a_blocks_edge_diagnoses_as_unverified_and_a_related_edge_diagnoses_as_unrepresentable() {
let h = Harness::new();
h.init_with_gitlab();
h.create_issue("Blocker", "");
h.create_issue("Blocked work", "");
h.create_issue("Merely related", "");
link(&h, "1", "2", "blocks");
link(&h, "1", "3", "related");
let out = h.export("gitlab", &[], None);
assert!(
out.status.success(),
"export failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let report = String::from_utf8_lossy(&out.stdout).to_string();
let record = h.record_contents();
assert!(
!record.contains("--blocking") && !record.contains("relation"),
"no relation verb is verified, so none may be sent; recorded:\n{record}"
);
assert!(
report.contains("not verified"),
"and the blocks edge must say the verb is unverified, not that the model lacks it: {report}"
);
assert!(report.contains("cannot represent"), "report: {report}");
assert!(
report.contains("blocking") || report.contains("blocks"),
"the diagnostic must name the remote field: {report}"
);
assert!(report.contains("gitlab"), "and the tool: {report}");
assert!(report.contains("related"), "and the kind: {report}");
}
#[test]
fn an_unrepresentable_relationship_survives_the_export() {
let h = Harness::new();
h.init_with_gitlab();
h.create_issue("A", "");
h.create_issue("B", "");
link(&h, "1", "2", "related");
link(&h, "2", "1", "duplicates");
let before = h.view("1");
assert!(
before.contains("related #2"),
"precondition: the display names the *target*, not the source: {before}"
);
let out = h.export("gitlab", &[], None);
assert!(out.status.success());
let after = h.view("1");
assert!(
after.contains("related #2"),
"the related edge must survive an export that cannot express it:\n{after}"
);
let listing = tix()
.current_dir(h.dir())
.args(["link", "list"])
.output()
.unwrap();
let rows = String::from_utf8_lossy(&listing.stdout);
assert!(rows.contains("--related-->"), "tix link list: {rows}");
assert!(rows.contains("--duplicates-->"), "tix link list: {rows}");
assert_eq!(
rows.lines().filter(|l| l.contains("rel_")).count(),
2,
"both edges survive, and neither was duplicated:\n{rows}"
);
}
#[test]
fn a_dry_run_reports_the_same_relationship_diagnostics() {
let h = Harness::new();
h.init_with_gitlab();
h.create_issue("A", "");
h.create_issue("B", "");
link(&h, "1", "2", "related");
let out = h.export("gitlab", &["--dry-run"], None);
assert!(out.status.success());
let report = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
report.contains("cannot represent"),
"a dry run must surface the same diagnostic: {report}"
);
assert!(
h.record_contents().is_empty(),
"a dry run must not call the tool: {}",
h.record_contents()
);
}
#[test]
fn a_second_export_changes_nothing_and_appends_no_history() {
let h = Harness::new();
h.init_with_gitlab();
h.create_issue("A", "");
h.create_issue("B", "");
link(&h, "1", "2", "blocks");
let first = h.export("gitlab", &[], None);
assert!(first.status.success());
let after_first = h.view("1");
let second = h.export("gitlab", &[], None);
assert!(
second.status.success(),
"the second export failed: {}",
String::from_utf8_lossy(&second.stderr)
);
assert_eq!(
h.view("1"),
after_first,
"a second export of an unchanged snapshot must change nothing — including \\
appending no second relationship_added event"
);
}
#[test]
fn a_remote_rename_does_not_duplicate_the_relationship() {
let h = Harness::new();
h.init_with_gitlab();
h.create_issue("Original title", "");
h.create_issue("Other", "");
link(&h, "1", "2", "blocks");
let out = h.export("gitlab", &[], None);
assert!(out.status.success());
let after_export = h.view("1");
let remote = r#"[{"iid":100,"title":"Renamed remotely","description":"","labels":[],
"state":"opened","updated_at":"2099-01-01T00:00:00Z","assignee":null},
{"iid":101,"title":"Other","description":"","labels":[],
"state":"opened","updated_at":"2099-01-01T00:00:00Z","assignee":null}]"#;
let imported = h.import("gitlab", remote);
assert!(
imported.status.success(),
"import failed: {}",
String::from_utf8_lossy(&imported.stderr)
);
let listing = tix()
.current_dir(h.dir())
.args(["link", "list"])
.output()
.unwrap();
let rows = String::from_utf8_lossy(&listing.stdout);
let edges = rows.lines().filter(|l| l.contains("rel_")).count();
assert_eq!(edges, 1, "exactly one edge after a rename:\\n{rows}");
let after_import = h.view("1");
assert!(
after_import.contains("blocks #2"),
"the display names the target, which the rename did not change: {after_import}"
);
assert_ne!(
after_import, after_export,
"the rename must have been applied, or this test proves nothing"
);
}
#[test]
fn neither_tracker_sends_a_relation_while_no_verb_is_verified() {
for (target, verb) in [("gitlab", "--blocking"), ("linear", "relation")] {
let h = Harness::new();
if target == "gitlab" {
h.init_with_gitlab();
} else {
h.init_with_linear();
}
h.create_issue("A", "");
h.create_issue("B", "");
link(&h, "1", "2", "blocks");
let out = h.export(target, &[], None);
assert!(
out.status.success(),
"{target} export failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let record = h.record_contents();
assert!(
!record.contains(verb),
"{target}: no relation verb is verified, so `{verb}` must not be sent; \
recorded:\\n{record}"
);
let report = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
report.contains(&format!("{}: ", target)) && report.contains("not verified"),
"{target}: the relationship must be reported as unverified-drivable: {report}"
);
}
}
fn hierarchy(h: &Harness) {
h.create_issue("Epic", "");
h.create_issue("Alpha", "");
h.create_issue("Bravo", "");
for child in ["2", "3"] {
let out = tix()
.current_dir(h.dir())
.args(["edit", child, "--parent", "1"])
.output()
.unwrap();
assert!(
out.status.success(),
"setting the parent of #{child} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn t032_an_unrepresentable_parent_survives_an_export_and_is_named_in_a_diagnostic() {
let h = Harness::new();
h.init_with_gitlab();
hierarchy(&h);
let out = h.export("gitlab", &[], None);
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
for child in ["2", "3"] {
let view = h.view(child);
assert!(
view.contains("parent: 1"),
"the local reference must survive an export that cannot send it:\n{view}"
);
}
assert!(
stdout.contains("#2's parent #1"),
"the diagnostic must name the reference: {stdout}"
);
assert!(
stdout.contains("parent"),
"and the remote field it would have used: {stdout}"
);
assert!(
stdout.contains("not verified"),
"and must distinguish 'expressible but not drivable' from 'unsupported', \\
so a reader knows what to go and check: {stdout}"
);
let recorded = h.record_contents();
assert!(
!recorded.contains("--parent"),
"no parent flag may reach a CLI whose verb is unverified:\n{recorded}"
);
}
#[test]
fn t032_a_dry_run_reports_the_parent_diagnostic_too() {
let h = Harness::new();
h.init_with_gitlab();
hierarchy(&h);
let out = h.export("gitlab", &["--dry-run"], None);
assert!(out.status.success());
let report = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
report.contains("#2's parent #1"),
"a dry run must surface the same diagnostic a real run would: {report}"
);
assert!(
h.record_contents().is_empty(),
"and must still not invoke the tool"
);
}
#[test]
fn t034_a_second_export_appends_no_parent_history_and_changes_no_bytes() {
let h = Harness::new();
h.init_with_gitlab();
hierarchy(&h);
let first = h.export("gitlab", &[], None);
assert!(first.status.success());
let before = h.view("2");
let second = h.export("gitlab", &[], None);
assert!(second.status.success());
let after = h.view("2");
assert_eq!(
before, after,
"a second export with no remote change must be byte-identical"
);
let events = after.matches("parent_set").count();
assert_eq!(
events, 1,
"exactly one parent_set entry after two exports, not one per export:\n{after}"
);
}
#[test]
fn t035_an_imported_parent_reference_is_restored_through_the_storage_path() {
let h = Harness::new();
h.init_with_gitlab();
let payload = r#"[{"iid":100,"title":"Remote Epic","description":"","labels":[],
"state":"opened","updated_at":"2026-05-11T10:00:00Z","assignee":null},
{"iid":101,"title":"Remote Child","description":"","labels":[],
"state":"opened","updated_at":"2026-05-11T10:00:00Z","assignee":null,
"epic_iid":100}]"#;
let out = h.import("gitlab", payload);
assert!(
out.status.success(),
"import failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let epic = h.view("1");
let child = h.view("2");
assert!(epic.contains("Remote Epic"), "{epic}");
assert!(
child.contains("parent: 1"),
"the imported child must carry the parent the remote reported:\n{child}"
);
let events = child.matches("parent_set").count();
assert_eq!(
events, 1,
"exactly one parent_set entry, written by the same path as a CLI write:\n{child}"
);
}
#[test]
fn t035_the_parent_is_restored_even_when_the_child_comes_first_in_the_payload() {
let h = Harness::new();
h.init_with_gitlab();
let payload = r#"[{"iid":101,"title":"Remote Child","description":"","labels":[],
"state":"opened","updated_at":"2026-05-11T10:00:00Z","assignee":null,
"epic_iid":100},
{"iid":100,"title":"Remote Epic","description":"","labels":[],
"state":"opened","updated_at":"2026-05-11T10:00:00Z","assignee":null}]"#;
let out = h.import("gitlab", payload);
assert!(
out.status.success(),
"import failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let child = h.view("1");
assert!(
child.contains("parent: 2"),
"the child is #1 here and its parent #2, restored regardless of payload \
order:\n{child}"
);
}
#[test]
fn t035_a_parent_the_import_did_not_create_leaves_the_issue_parentless() {
let h = Harness::new();
h.init_with_gitlab();
let payload = r#"[{"iid":101,"title":"Orphan","description":"","labels":[],
"state":"opened","updated_at":"2026-05-11T10:00:00Z","assignee":null,
"epic_iid":999}]"#;
let out = h.import("gitlab", payload);
assert!(
out.status.success(),
"import failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let view = h.view("1");
assert!(
view.contains("- parent: none"),
"the issue must read as parentless, not merely lack a `parent:` line:\n{view}"
);
assert!(
!view.lines().any(|l| l.starts_with("parent: ")),
"and no frontmatter key may be written at all:\n{view}"
);
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
stdout.contains("999"),
"and the diagnostic must name the id it could not resolve: {stdout}"
);
assert!(
stdout.contains("parentless"),
"and must say what it did instead: {stdout}"
);
}
#[test]
fn t035_an_import_that_would_close_a_cycle_is_refused_and_writes_nothing() {
let h = Harness::new();
h.init_with_gitlab();
hierarchy(&h);
let payload = r#"[{"iid":900,"title":"Existing Epic","description":"","labels":[],
"state":"opened","updated_at":"2026-05-11T10:00:00Z","assignee":null},
{"iid":901,"title":"Existing Child","description":"","labels":[],
"state":"opened","updated_at":"2026-05-11T10:00:00Z","assignee":null,
"epic_iid":900}]"#;
let before = h.view("1");
let _ = h.import("gitlab", payload);
let after = h.view("1");
assert_eq!(
before, after,
"an import that cannot place a reference must leave the file untouched"
);
let out = tix()
.current_dir(h.dir())
.args(["edit", "1", "--parent", "2"])
.output()
.unwrap();
assert!(
!out.status.success(),
"the service must refuse a cycle, and the restore inherits that refusal"
);
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(err.contains("cycle"), "and say so: {err}");
assert_eq!(
h.view("1"),
before,
"a refused cycle leaves the file byte-identical"
);
}
#[test]
fn t035_a_parent_diagnostic_carries_no_credential_material() {
let h = Harness::new();
h.init_with_gitlab();
hierarchy(&h);
let out = h.export("gitlab", &[], None);
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
for secret in ["gl-file-token", "glpat-"] {
assert!(
!stdout.contains(secret) && !stderr.contains(secret),
"the export report leaked the token value `{secret}`.\n \
out: {stdout}\n err: {stderr}"
);
}
let configured = fs::read_to_string(h.dir().join("tix.toml"))
.unwrap()
.lines()
.find_map(|l| l.strip_prefix("token = \""))
.map(|v| v.trim_end_matches('"').to_string())
.expect("the fixture configures a token");
assert!(
!stdout.contains(&configured) && !stderr.contains(&configured),
"the configured token reached the report: {configured}\n out: {stdout}"
);
}
fn init_with_gitlab_and_categories(h: &Harness) {
h.init_with_gitlab();
let toml_path = h.dir().join("tix.toml");
let contents = fs::read_to_string(&toml_path).unwrap();
let mut kept = Vec::new();
let mut skipping = false;
for line in contents.lines() {
if line.starts_with('[') {
skipping = line.starts_with("[triage");
}
if !skipping {
kept.push(line);
}
}
fs::write(&toml_path, format!("{}\n", kept.join("\n"))).unwrap();
let board_path = h.dir().join("docs").join("boards").join("default.md");
fs::write(
&board_path,
"---\nname: default\ncolumns:\n\
- triage\n- wip\n- review\n- shipped\n- abandoned\n\
done_column: shipped\n\
category_map:\n triage: backlog\n wip: active\n review: active\n \
shipped: completed\n abandoned: canceled\n\
hidden_columns: []\nshow_mismatch: false\nwrap_titles: true\ntitle_lines: 3\n\
card_order: []\norder_by: created_at_desc\n---\n\n",
)
.unwrap();
}
fn issue_bytes(h: &Harness) -> Vec<(PathBuf, Vec<u8>)> {
let mut out = Vec::new();
let mut stack = vec![h.dir().join("docs").join("issues")];
while let Some(cur) = stack.pop() {
for entry in fs::read_dir(&cur).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
} else {
out.push((path.clone(), fs::read(&path).unwrap()));
}
}
}
out.sort();
out
}
#[test]
fn t031_a_terminal_exit_is_refused_and_a_reason_unlocks_it() {
let (_tmp, config, store) = {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
fs::create_dir_all(dir.join("docs").join("issues")).unwrap();
let cfg = tix::config::Config {
issues_dir: dir.join("docs").join("issues"),
kanban: tix::config::KanbanSection {
configurations: indexmap::IndexMap::from([(
"default".to_string(),
tix::config::BoardConfiguration {
columns: vec!["triage".into(), "wip".into(), "shipped".into()],
done_column: Some("shipped".to_string()),
category_map: indexmap::IndexMap::from([
("triage".to_string(), "backlog".to_string()),
("wip".to_string(), "active".to_string()),
("shipped".to_string(), "completed".to_string()),
]),
..Default::default()
},
)]),
},
..tix::config::Config::default()
};
cfg.save(&dir.join("tix.toml")).unwrap();
let loaded = tix::config::Config::load_with_warning(&dir.join("tix.toml"), |_| {}).unwrap();
let store = tix::storage::Store {
issues_dir: loaded.issues_dir.clone(),
config: loaded.clone(),
};
(tmp, loaded, store)
};
let _ = &config;
tix::commands::create::create(
&store.config,
&store,
"Local",
None,
Some(tix::model::Status::new("triage")),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
tix::commands::mv::run(
&store.config,
&store,
"1",
"shipped",
Some("abc"),
None,
true,
)
.unwrap();
assert_eq!(
store
.get(tix::storage::IssueSelector::Number(1))
.unwrap()
.status
.display(),
"shipped"
);
let issue = store.get(tix::storage::IssueSelector::Number(1)).unwrap();
let (_, resolution) = tix::workflow::active_resolution(&store.config);
let outcome =
tix::workflow::validate_transition(&issue, "triage", &resolution, &store.config, None);
let tix::workflow::TransitionOutcome::Rejected(diagnostic) = outcome else {
panic!("precondition: the transition must be refused");
};
assert!(diagnostic.to_string().contains("invalid_transition"));
let accepted = tix::workflow::validate_transition(
&issue,
"triage",
&resolution,
&store.config,
Some("because"),
);
assert!(matches!(
accepted,
tix::workflow::TransitionOutcome::Accepted { .. }
));
}
#[test]
fn t031_the_import_path_runs_and_is_idempotent() {
let h = Harness::new();
init_with_gitlab_and_categories(&h);
let seed =
r#"[{"iid":10,"title":"Remote","state":"opened","updated_at":"2020-01-01T00:00:00Z"}]"#;
let out = h.import("gitlab", seed);
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
stdout.contains("imported gitlab issue 10") || stdout.contains("kept gitlab issue 10"),
"the import reports what it did: {stdout} / stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let before = issue_bytes(&h);
let out2 = h.import("gitlab", seed);
assert!(out2.status.success());
assert_eq!(
issue_bytes(&h),
before,
"a second identical import must leave the local state byte-stable"
);
}