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 create_issue_attached(&self, title: &str, body: &str) -> Output {
let out = tix()
.current_dir(self.dir())
.arg("create")
.arg(format!("--title={title}"))
.arg(format!("--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")
.env_remove("LINEAR_API_KEY");
}
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")
.env_remove("LINEAR_API_KEY")
.output()
.unwrap()
}
fn import_linear(&self, fake_list: &str, fake_get: &str) -> Output {
tix()
.current_dir(self.dir())
.arg("import")
.args(["--target", "linear"])
.env(
"PATH",
format!("{}:{}", bin_dir().display(), self.existing_path),
)
.env("TIX_FAKE_RECORD", &self.record)
.env("TIX_FAKE_LIST", fake_list)
.env("TIX_FAKE_GET", fake_get)
.env_remove("GITLAB_TOKEN")
.env_remove("LINEAR_TOKEN")
.env_remove("LINEAR_API_KEY")
.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())
.args(["list", "--all"])
.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 backups = tix::backup::list(&issues_dir);
assert_eq!(backups.len(), 1, "one overwrite, one backup");
let bak = &backups[0];
assert!(bak.is_file(), "backup must exist at {}", bak.display());
assert!(
!issues_dir.join("2026-01-01-old.md.bak").exists(),
"the backup is centralized, not beside the issue"
);
let bak_contents = fs::read_to_string(bak).unwrap();
assert!(
bak_contents.contains("Old Title"),
"backup 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 issues create -t=Eng -d=linear body"),
"values are attached; recorded:\n{record}"
);
assert!(
record.contains("-o json -- Fix login"),
"the positional title follows `--`; recorded:\n{record}"
);
assert!(
record.contains("LINEAR_API_KEY=lin-env"),
"recorded:\n{record}"
);
let fake_list =
r#"[{"id":"u1","identifier":"TIX-200","title":"Linear task","state":{"name":"Todo"}}]"#;
let fake_get = r##"{"id":"u1","identifier":"TIX-200","title":"Linear task","description":"desc",
"updatedAt":"2026-05-12T00:00:00Z","state":{"name":"Todo"},
"labels":{"nodes":[{"name":"feature","color":"#00f"}]}}"##;
let out = h.import_linear(fake_list, fake_get);
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}");
let imported = h.view("linear-task");
assert!(
imported.contains("desc") && imported.contains("feature"),
"detail body/labels must be imported: {imported}"
);
}
#[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 a_hyphen_leading_body_and_title_export_without_clap_rejecting_them() {
let h = Harness::new();
h.init_with_linear();
h.create_issue_attached("-leading title", "- bullet one\n- bullet two");
let out = h.export("linear", &[], Some(("LINEAR_TOKEN", "lin-env")));
assert!(
out.status.success(),
"export failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("created linear issue"), "stdout: {stdout}");
let record = h.record_contents();
assert!(
record.contains("-d=- bullet one\n- bullet two") && record.contains("-- -leading title"),
"attached body and `--`-guarded title; recorded:\n{record}"
);
}
#[test]
fn linear_dry_run_previews_a_resolvable_edge_instead_of_reporting_it_unsendable() {
let h = Harness::new();
h.init_with_linear();
h.create_issue("Blocker", "");
h.create_issue("Blocked work", "");
link(&h, "1", "2", "blocks");
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("not been exported"),
"a first-export edge is resolvable and must not preview as unsendable: {stdout}"
);
assert!(
stdout.contains("would export linear blocks"),
"the preview names the edge pass 2 would send: {stdout}"
);
assert!(h.record_contents().is_empty(), "a dry run invokes nothing");
}
#[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("issues create").count(),
1,
"create must happen once: {record}"
);
assert!(
record.matches("issues 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)
);
}
fn linear_id(h: &Harness, selector: &str) -> String {
let view = h.view(selector);
view.lines()
.find_map(|l| l.strip_prefix("linear_id:").map(|v| v.trim().to_string()))
.unwrap_or_else(|| panic!("no `linear_id:` in view:\n{view}"))
}
fn issue_id(view: &str) -> String {
view.lines()
.find_map(|line| line.strip_prefix("id: "))
.map(str::trim)
.filter(|id| tix::id::is_issue_id(id))
.unwrap_or_else(|| panic!("view carries no canonical id:\n{view}"))
.to_string()
}
#[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(),
"a partial export must be visible to a calling script: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
out.status.code(),
Some(1),
"partial export uses the documented non-zero status"
);
let report = String::from_utf8_lossy(&out.stdout).to_string();
let diagnostics = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
!report.contains("cannot represent"),
"unsupported-field diagnostics do not pollute the report stream: {report}"
);
let record = h.record_contents();
assert!(
!record.contains("--blocking") && !record.contains("relation"),
"no relation verb exists to send, so none may be; recorded:\n{record}"
);
assert!(
diagnostics.contains("exposes no issue-relation command")
&& !diagnostics.contains("not verified"),
"and the blocks edge must say glab has no relation command, not that a verb is \
merely unverified: {diagnostics}"
);
assert!(
diagnostics.contains("cannot represent"),
"diagnostics: {diagnostics}"
);
assert!(
diagnostics.contains("blocking") || diagnostics.contains("blocks"),
"the diagnostic must name the remote field: {diagnostics}"
);
assert!(
diagnostics.contains("gitlab"),
"and the tool: {diagnostics}"
);
assert!(
diagnostics.contains("related"),
"and the kind: {diagnostics}"
);
}
#[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 list = || {
let listing = tix()
.current_dir(h.dir())
.args(["link", "list"])
.output()
.unwrap();
assert!(
listing.status.success(),
"{}",
String::from_utf8_lossy(&listing.stderr)
);
String::from_utf8_lossy(&listing.stdout).to_string()
};
let before = list();
assert!(before.contains("--related-->"), "{before}");
let out = h.export("gitlab", &[], None);
assert!(
!out.status.success(),
"the unsupported relationship is a partial export"
);
let after = list();
assert_eq!(
after, before,
"an export that cannot express the related edge must leave it byte-identical"
);
let rows = after;
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(),
"a dry run previews the partial status a real run would have"
);
let report = String::from_utf8_lossy(&out.stderr).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(),
"the unsupported blocks edge makes the export partial"
);
let after_first = h.view("1");
let second = h.export("gitlab", &[], None);
assert!(
!second.status.success(),
"the second export is still a partial mirror: {}",
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(),
"the unsupported blocks edge makes the export partial"
);
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 a_blocks_edge_is_sent_exactly_once_from_its_declaring_side() {
let h = Harness::new();
h.init_with_linear();
h.create_issue("Blocker", "");
h.create_issue("Blocked work", "");
link(&h, "1", "2", "blocks");
let out = h.export("linear", &[], Some(("LINEAR_TOKEN", "lin-env")));
assert!(
out.status.success(),
"export failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let from = linear_id(&h, "1"); let to = linear_id(&h, "2"); assert_ne!(from, to, "each issue must have its own remote id");
let record = h.record_contents();
let adds: Vec<&str> = record
.lines()
.filter(|l| l.contains("relations add"))
.collect();
assert_eq!(
adds.len(),
1,
"one edge must be sent exactly once, from its declaring side:\n{record}"
);
let expected = format!("linear-cli relations add -r blocks {from} {to}");
assert_eq!(adds[0], expected.as_str());
assert!(
!record.contains(&format!("relations add -r blocks {from} {from}"))
&& !record.contains(&format!("relations add -r blocks {to} {to}")),
"no self-edge may be sent:\n{record}"
);
let report = String::from_utf8_lossy(&out.stdout).to_string();
assert_eq!(
report
.matches(&format!("exported linear blocks {to}"))
.count(),
1,
"the mapped edge must be reported exactly once: {report}"
);
}
#[test]
fn a_relation_whose_target_is_not_exported_is_reported_not_sent() {
let h = Harness::new();
h.init_with_linear();
h.create_issue("A", "");
h.create_issue("B", "");
link(&h, "1", "2", "blocks");
let archived = tix()
.current_dir(h.dir())
.args(["archive", "2"])
.output()
.unwrap();
assert!(
archived.status.success(),
"archive failed: {}",
String::from_utf8_lossy(&archived.stderr)
);
let out = h.export("linear", &[], Some(("LINEAR_TOKEN", "lin-env")));
assert!(
!out.status.success(),
"the unexported target makes the export partial: {}",
String::from_utf8_lossy(&out.stderr)
);
let report = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
report.contains("not been exported"),
"the missing target must be reported: {report}"
);
let record = h.record_contents();
assert!(
!record.contains("relations add"),
"no edge may be sent when the target has no remote id:\n{record}"
);
}
#[test]
fn gitlab_reports_a_relation_because_its_cli_has_no_relation_command() {
let h = Harness::new();
h.init_with_gitlab();
h.create_issue("A", "");
h.create_issue("B", "");
link(&h, "1", "2", "blocks");
let out = h.export("gitlab", &[], None);
assert!(
!out.status.success(),
"the unsupported relation makes the export partial: {}",
String::from_utf8_lossy(&out.stderr)
);
let record = h.record_contents();
assert!(
!record.contains("--blocking") && !record.contains("relation"),
"no relation verb exists to send, so none may be; recorded:\n{record}"
);
let report = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
report.contains("gitlab: ")
&& report.contains("exposes no issue-relation command")
&& !report.contains("not verified"),
"the per-tool reason must be the permanent one: {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(),
"the unsupported parent references make the export partial"
);
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
let epic_id = issue_id(&h.view("1"));
for child in ["2", "3"] {
let view = h.view(child);
assert!(
view.contains(&format!("parent: {epic_id}")),
"the local reference must survive an export that cannot send it:\n{view}"
);
}
assert!(
stderr.contains("#2's parent #1"),
"the diagnostic must name the reference: {stderr}"
);
assert!(
stderr.contains("parent"),
"and the remote field it would have used: {stderr}"
);
assert!(
stderr.contains("not verified"),
"and must distinguish 'expressible but not drivable' from 'unsupported', \\
so a reader knows what to go and check: {stderr}"
);
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(),
"a dry run previews the same partial status"
);
let report = String::from_utf8_lossy(&out.stderr).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(),
"parent diagnostics make the export partial"
);
let before = h.view("2");
let second = h.export("gitlab", &[], None);
assert!(
!second.status.success(),
"the unchanged parent references still cannot travel"
);
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}");
let epic_id = issue_id(&epic);
assert!(
child.contains(&format!("parent: {epic_id}")),
"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 epic_id = issue_id(&h.view("2"));
let child = h.view("1");
assert!(
child.contains(&format!("parent: {epic_id}")),
"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(),
"parent diagnostics make the export partial"
);
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,
false,
)
.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"
);
}
fn declare_projects(h: &Harness, projects: &[(&str, &str)]) {
let toml_path = h.dir().join("tix.toml");
let mut contents = fs::read_to_string(&toml_path).unwrap();
for (id, name) in projects {
contents.push_str(&format!("\n[projects.\"{id}\"]\nname = \"{name}\"\n"));
}
fs::write(&toml_path, contents).unwrap();
}
fn write_project_issue(
h: &Harness,
number: u32,
filename: &str,
title: &str,
gitlab_id: Option<&str>,
projects: &[&str],
updated: &str,
) {
let issues = h.dir().join("docs").join("issues");
fs::create_dir_all(&issues).unwrap();
let mut text = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\n"
);
if let Some(id) = gitlab_id {
text.push_str(&format!("gitlab_id: \"{id}\"\n"));
}
if !projects.is_empty() {
text.push_str("projects:\n");
for project in projects {
text.push_str(&format!("- {project}\n"));
}
}
text.push_str(&format!(
"created: \"2026-01-01\"\nupdated: \"{updated}\"\n---\n\nBody.\n"
));
fs::write(issues.join(filename), text).unwrap();
}
fn issue_text(h: &Harness, number: u32) -> String {
let needle = format!("number: {number}\n");
for text in find_issue_texts(h) {
if text.contains(&needle) {
return text;
}
}
panic!("no issue file carrying `number: {number}`");
}
fn find_issue_texts(h: &Harness) -> Vec<String> {
let mut out = Vec::new();
let mut stack = vec![h.dir().join("docs").join("issues")];
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.ends_with("-assets") {
continue;
}
stack.push(path);
} else if path.extension().is_some_and(|e| e == "md") {
out.push(fs::read_to_string(&path).unwrap());
}
}
}
out
}
fn projects_of(h: &Harness, number: u32) -> Vec<String> {
let text = issue_text(h, number);
let mut lines = text.lines();
while let Some(line) = lines.next() {
if line.trim_end() == "projects:" {
let mut ids = Vec::new();
for entry in lines.by_ref() {
match entry.strip_prefix("- ") {
Some(id) => ids.push(id.trim().to_string()),
None => break,
}
}
return ids;
}
}
Vec::new()
}
fn association_events(h: &Harness, number: u32) -> usize {
issue_text(h, number)
.lines()
.filter(|line| line.contains("project_associated"))
.count()
}
#[test]
fn t038_an_unrepresentable_project_association_survives_the_round_trip_and_is_named() {
let h = Harness::new();
h.init_with_gitlab();
declare_projects(
&h,
&[("p-platform", "Platform hardening"), ("p-docs", "Docs")],
);
write_project_issue(
&h,
1,
"2026-01-01-platform.md",
"In two projects",
None,
&["p-platform", "p-docs"],
"2026-01-01",
);
let out = h.export("gitlab", &[], None);
assert!(
!out.status.success(),
"unsupported project memberships make the export partial: {}",
String::from_utf8_lossy(&out.stderr)
);
let report = String::from_utf8_lossy(&out.stderr).to_string();
let record = h.record_contents();
assert!(
!record.contains("p-platform") && !record.contains("p-docs"),
"no local project id may reach the command line:\n{record}"
);
for needle in [
"gitlab: projects",
"cannot represent",
"#1",
"p-platform",
"p-docs",
] {
assert!(report.contains(needle), "`{needle}` missing from: {report}");
}
assert_eq!(projects_of(&h, 1), ["p-platform", "p-docs"]);
let remote = r#"[{"iid":100,"title":"In two projects","description":"Body.","labels":[],
"state":"opened","updated_at":"2099-01-01T00:00:00Z"}]"#;
let imported = h.import("gitlab", remote);
assert!(
imported.status.success(),
"import failed: {}",
String::from_utf8_lossy(&imported.stderr)
);
assert_eq!(
projects_of(&h, 1),
["p-platform", "p-docs"],
"an association the remote cannot hold survives the whole round trip"
);
}
#[test]
fn t038_an_issue_in_three_projects_keeps_all_three_in_order() {
let h = Harness::new();
h.init_with_gitlab();
declare_projects(&h, &[("p-1", "One"), ("p-2", "Two"), ("p-3", "Three")]);
write_project_issue(
&h,
1,
"2026-01-01-triple.md",
"Three projects",
Some("100"),
&["p-1", "p-2", "p-3"],
"2026-01-01",
);
assert_eq!(projects_of(&h, 1), ["p-1", "p-2", "p-3"]);
let _ = h.export("gitlab", &[], None);
let remote = r#"[{"iid":100,"title":"Three projects","description":"Body.","labels":[],
"state":"opened","updated_at":null,
"projects":["p-3","p-1","p-2"]}]"#;
let imported = h.import("gitlab", remote);
assert!(
imported.status.success(),
"import failed: {}",
String::from_utf8_lossy(&imported.stderr)
);
assert_eq!(
projects_of(&h, 1),
["p-1", "p-2", "p-3"],
"every association is kept, in the local order — the remote's order is not \
the local list's order"
);
assert_eq!(
association_events(&h, 1),
0,
"all three were already associated, so the import was a no-op"
);
}
#[test]
fn t038_a_second_export_and_a_second_import_are_true_no_ops() {
let h = Harness::new();
h.init_with_gitlab();
declare_projects(&h, &[("p-1", "One"), ("p-2", "Two")]);
write_project_issue(
&h,
1,
"2026-01-01-local.md",
"Local only",
Some("100"),
&[],
"2026-01-01",
);
let remote = r#"[{"iid":100,"title":"Local only","description":"Body.","labels":[],
"state":"opened","updated_at":null,"projects":["p-1","p-2"]}]"#;
let first = h.import("gitlab", remote);
assert!(first.status.success());
let first_report = String::from_utf8_lossy(&first.stdout).to_string();
assert!(
first_report.contains("kept gitlab issue 100"),
"the issue's own fields must be kept, or this test is about a different \
branch: {first_report}"
);
assert_eq!(projects_of(&h, 1), ["p-1", "p-2"]);
assert_eq!(
association_events(&h, 1),
2,
"one History entry per accepted association: {first_report}"
);
let after_first = issue_text(&h, 1);
let second = h.import("gitlab", remote);
assert!(second.status.success());
let second_report = String::from_utf8_lossy(&second.stdout).to_string();
assert_eq!(
issue_text(&h, 1),
after_first,
"a second identical import must change no byte of the file — no duplicate, no \
reorder, no History entry"
);
assert_eq!(
association_events(&h, 1),
2,
"and no third association event"
);
assert!(
!second_report.contains("associated"),
"an association that is already present is not reported as work: {second_report}"
);
let first_export = h.export("gitlab", &[], None);
assert!(
!first_export.status.success(),
"the first export reports unsupported project memberships"
);
let after_export = issue_text(&h, 1);
let again = h.export("gitlab", &[], None);
assert!(
!again.status.success(),
"the second export remains partial for the same unsupported memberships"
);
assert_eq!(
issue_text(&h, 1),
after_export,
"re-exporting an unchanged snapshot must not change a byte"
);
assert_eq!(projects_of(&h, 1), ["p-1", "p-2"]);
}
#[test]
fn t038_a_remote_project_rename_matches_on_the_id_and_never_on_the_name() {
let h = Harness::new();
h.init_with_gitlab();
declare_projects(
&h,
&[("p-1", "Platform hardening"), ("p-2", "Remotely renamed")],
);
write_project_issue(
&h,
1,
"2026-01-01-renamed.md",
"Renamed remotely",
Some("100"),
&["p-1"],
"2026-01-01",
);
let toml_before = fs::read_to_string(h.dir().join("tix.toml")).unwrap();
let remote = r#"[{"iid":100,"title":"Renamed remotely","description":"Body.","labels":[],
"state":"opened","updated_at":null,
"projects":[{"id":"p-1","name":"Remotely renamed"}]}]"#;
let out = h.import("gitlab", remote);
assert!(
out.status.success(),
"import failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
projects_of(&h, 1),
["p-1"],
"the remote renamed a project and the association is still the *same* one"
);
assert_eq!(
association_events(&h, 1),
0,
"an already-present association is a no-op even when the remote renames it"
);
assert_eq!(
fs::read_to_string(h.dir().join("tix.toml")).unwrap(),
toml_before,
"the local registry's display labels are not the remote's to rewrite"
);
let by_name = r#"[{"iid":100,"title":"Renamed remotely","description":"Body.","labels":[],
"state":"opened","updated_at":null,
"projects":[{"name":"Platform hardening"}]}]"#;
let out = h.import("gitlab", by_name);
assert!(out.status.success());
let report = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
report.contains("gitlab: projects") && report.contains("no id"),
"a name is not an identity, and the diagnostic says so: {report}"
);
assert_eq!(
projects_of(&h, 1),
["p-1"],
"and no association was invented from a name"
);
}
#[test]
fn t038_a_remote_project_the_registry_does_not_know_creates_no_dangling_reference() {
let h = Harness::new();
h.init_with_gitlab();
declare_projects(&h, &[("p-1", "One")]);
write_project_issue(
&h,
1,
"2026-01-01-tracked.md",
"Tracked",
Some("10"),
&[],
"2026-01-01",
);
let toml_before = fs::read_to_string(h.dir().join("tix.toml")).unwrap();
let remote = r#"[{"iid":11,"title":"Created one","description":"","labels":[],
"state":"opened","updated_at":null,"projects":["p-1","p-ghost"]},
{"iid":12,"title":"Created two","description":"","labels":[],
"state":"opened","updated_at":null,"projects":["p-1","p-ghost"]},
{"iid":10,"title":"Tracked","description":"","labels":[],
"state":"opened","updated_at":null,"projects":["p-1","p-ghost"]}]"#;
let out = h.import("gitlab", remote);
assert!(
out.status.success(),
"import failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let report = String::from_utf8_lossy(&out.stdout).to_string();
assert_eq!(
report.matches("registry does not declare").count(),
3,
"one diagnostic per unknown reference, not a storm: {report}"
);
for needle in ["gitlab: projects", "p-ghost", "cannot represent"] {
assert!(report.contains(needle), "`{needle}` missing from: {report}");
}
let all = find_issue_texts(&h).join("\n");
assert!(
!all.contains("p-ghost"),
"an unknown project must not be written into any issue file as a dangling \
reference:\n{all}"
);
for number in [1, 2, 3] {
assert_eq!(
projects_of(&h, number),
["p-1"],
"issue #{number} keeps exactly the association the registry can resolve"
);
}
assert_eq!(
fs::read_to_string(h.dir().join("tix.toml")).unwrap(),
toml_before,
"a sync never writes the project registry"
);
let before = find_issue_texts(&h).join("\n");
let second = h.import("gitlab", remote);
assert!(second.status.success());
let second_report = String::from_utf8_lossy(&second.stdout).to_string();
assert_eq!(
second_report.matches("registry does not declare").count(),
3,
"still one per reference: {second_report}"
);
assert_eq!(
find_issue_texts(&h).join("\n"),
before,
"the end state is stable"
);
}
#[test]
fn t038_both_adapters_present_the_same_project_seam_and_send_no_verb() {
use tix::sync::glab::GitLabAdapter;
use tix::sync::linear::LinearAdapter;
use tix::sync::{ProcessRunner, Tool, TrackerAdapter};
let mut gitlab = GitLabAdapter::new(ProcessRunner, None, None, "acme/tix".into());
let mut linear = LinearAdapter::new(ProcessRunner, None, "Eng".into());
let seams: [(Tool, &mut dyn TrackerAdapter); 2] =
[(Tool::GitLab, &mut gitlab), (Tool::Linear, &mut linear)];
for (tool, adapter) in seams {
assert_eq!(adapter.tool(), tool);
adapter
.set_projects("100", &[])
.unwrap_or_else(|e| panic!("{tool}: the empty membership seam must be a no-op: {e}"));
}
for (target, setup, field, reason, project) in [
(
"gitlab",
Harness::init_with_gitlab as fn(&Harness),
"gitlab: projects",
"--project",
"p-1",
),
(
"linear",
Harness::init_with_linear as fn(&Harness),
"linear: project",
"one `project`",
"p-1",
),
] {
let h = Harness::new();
setup(&h);
declare_projects(&h, &[(project, "One")]);
write_project_issue(
&h,
1,
"2026-01-01-one.md",
"One project",
None,
&[project],
"2026-01-01",
);
let out = h.export(target, &[], None);
assert!(
!out.status.success(),
"{target} export should report a partial mirror: {}",
String::from_utf8_lossy(&out.stderr)
);
let record = h.record_contents();
assert!(
!record.contains(project),
"{target}: no local project id may be sent:\n{record}"
);
let report = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
report.contains(field) && report.contains(reason),
"{target}: the diagnostic names the field and the per-tool reason: {report}"
);
}
}
#[test]
fn t038_a_project_diagnostic_carries_no_credential_material() {
let h = Harness::new();
h.init_with_gitlab();
declare_projects(&h, &[("p-1", "One")]);
write_project_issue(
&h,
1,
"2026-01-01-cred.md",
"Credential test",
Some("100"),
&["p-1"],
"2026-01-01",
);
let secret = fs::read_to_string(h.dir().join("tix.toml"))
.unwrap()
.lines()
.find_map(|line| line.strip_prefix("token = \""))
.map(|value| value.trim_end_matches('"').to_string())
.expect("the fixture configures a token");
let out = h.export("gitlab", &[], None);
assert!(
!out.status.success(),
"unsupported project memberships make the export partial"
);
let remote = format!(
r#"[{{"iid":100,"title":"Credential test","description":"Body.","labels":[],
"state":"opened","updated_at":null,
"projects":[{{"id":"p-ghost","name":"{secret}"}},{{"name":"{secret}"}}]}}]"#
);
let imported = h.import("gitlab", &remote);
assert!(
imported.status.success(),
"import failed: {}",
String::from_utf8_lossy(&imported.stderr)
);
let stdout = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&imported.stdout)
);
let stderr = format!(
"{}{}",
String::from_utf8_lossy(&out.stderr),
String::from_utf8_lossy(&imported.stderr)
);
assert!(
stdout.contains("<token>"),
"the remote-reported name must have been redacted, not omitted: {stdout}"
);
assert!(
!stdout.contains(&secret) && !stderr.contains(&secret),
"a diagnostic built from remote output leaked the token:\n out: {stdout}\n err: {stderr}"
);
assert!(
stdout.contains("p-ghost") && stdout.contains("registry does not declare"),
"the unknown project is reported by id: {stdout}"
);
}
fn declare_epics(h: &Harness, epics: &[(&str, &str)]) {
let toml_path = h.dir().join("tix.toml");
let mut contents = fs::read_to_string(&toml_path).unwrap();
for (id, name) in epics {
contents.push_str(&format!("\n[epics.\"{id}\"]\nname = \"{name}\"\n"));
}
fs::write(&toml_path, contents).unwrap();
}
fn write_epic_issue(
h: &Harness,
number: u32,
filename: &str,
title: &str,
gitlab_id: Option<&str>,
epics: &[&str],
updated: &str,
) {
let issues = h.dir().join("docs").join("issues");
fs::create_dir_all(&issues).unwrap();
let mut text = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\n"
);
if let Some(id) = gitlab_id {
text.push_str(&format!("gitlab_id: \"{id}\"\n"));
}
if !epics.is_empty() {
text.push_str("epics:\n");
for epic in epics {
text.push_str(&format!("- {epic}\n"));
}
}
text.push_str(&format!(
"created: \"2026-01-01\"\nupdated: \"{updated}\"\n---\n\nBody.\n"
));
fs::write(issues.join(filename), text).unwrap();
}
fn epics_of(h: &Harness, number: u32) -> Vec<String> {
let text = issue_text(h, number);
let mut lines = text.lines();
while let Some(line) = lines.next() {
if line.trim_end() == "epics:" {
let mut ids = Vec::new();
for entry in lines.by_ref() {
match entry.strip_prefix("- ") {
Some(id) => ids.push(id.trim().to_string()),
None => break,
}
}
return ids;
}
}
Vec::new()
}
fn reference_events(h: &Harness, number: u32) -> usize {
issue_text(h, number)
.lines()
.filter(|line| line.contains("epic_referenced"))
.count()
}
#[test]
fn t039_an_unrepresentable_epic_reference_survives_the_round_trip_and_is_named() {
let h = Harness::new();
h.init_with_gitlab();
declare_epics(
&h,
&[("e-platform", "Platform hardening"), ("e-docs", "Docs")],
);
write_epic_issue(
&h,
1,
"2026-01-01-in-two-epics.md",
"In two epics",
None,
&["e-platform", "e-docs"],
"2026-01-01",
);
let dry = h.export("gitlab", &["--dry-run"], None);
assert!(
!dry.status.success(),
"the dry run previews a partial live export: {}",
String::from_utf8_lossy(&dry.stderr)
);
let dry_report = String::from_utf8_lossy(&dry.stderr).to_string();
for needle in [
"gitlab: epics",
"cannot represent",
"#1",
"e-platform",
"e-docs",
] {
assert!(
dry_report.contains(needle),
"`{needle}` missing from the dry-run report: {dry_report}"
);
}
let out = h.export("gitlab", &[], None);
assert!(
!out.status.success(),
"unsupported epic references make the export partial: {}",
String::from_utf8_lossy(&out.stderr)
);
let report = String::from_utf8_lossy(&out.stderr).to_string();
let record = h.record_contents();
assert!(
!record.contains("e-platform") && !record.contains("e-docs"),
"no local epic id may reach the command line:\n{record}"
);
for needle in [
"gitlab: epics",
"cannot represent",
"#1",
"e-platform",
"e-docs",
] {
assert!(report.contains(needle), "`{needle}` missing from: {report}");
}
assert_eq!(epics_of(&h, 1), ["e-platform", "e-docs"]);
let remote = r#"[{"iid":100,"title":"In two epics","description":"Body.","labels":[],
"state":"opened","updated_at":"2099-01-01T00:00:00Z"}]"#;
let imported = h.import("gitlab", remote);
assert!(
imported.status.success(),
"import failed: {}",
String::from_utf8_lossy(&imported.stderr)
);
assert_eq!(
epics_of(&h, 1),
["e-platform", "e-docs"],
"a reference the remote cannot hold survives the whole round trip"
);
}
#[test]
fn t039_an_imported_payload_lands_its_epic_references_through_the_service() {
let h = Harness::new();
h.init_with_gitlab();
declare_epics(&h, &[("e-1", "One"), ("e-2", "Two"), ("e-3", "Three")]);
write_epic_issue(
&h,
1,
"2026-01-01-tracked.md",
"Tracked",
Some("100"),
&["e-1"],
"2026-01-01",
);
assert_eq!(epics_of(&h, 1), ["e-1"]);
let remote = r#"[{"iid":100,"title":"Tracked","description":"Body.","labels":[],
"state":"opened","updated_at":null,"epics":["e-3","e-1","e-2"]}]"#;
let imported = h.import("gitlab", remote);
assert!(
imported.status.success(),
"import failed: {}",
String::from_utf8_lossy(&imported.stderr)
);
let report = String::from_utf8_lossy(&imported.stdout).to_string();
assert!(
report.contains("kept gitlab issue 100"),
"the issue's own fields must be kept, or this test is about a different \
branch: {report}"
);
assert_eq!(
epics_of(&h, 1),
["e-1", "e-3", "e-2"],
"the already-present reference keeps its place, and the two new ones are \
appended in the order the payload presented them — the local list is never \
reordered"
);
assert_eq!(
reference_events(&h, 1),
2,
"one History entry per newly accepted reference: {report}"
);
}
#[test]
fn t039_a_second_export_and_a_second_import_are_true_no_ops() {
let h = Harness::new();
h.init_with_gitlab();
declare_epics(&h, &[("e-1", "One"), ("e-2", "Two"), ("e-3", "Three")]);
write_epic_issue(
&h,
1,
"2026-01-01-triple.md",
"Three epics",
Some("100"),
&["e-1", "e-2", "e-3"],
"2026-01-01",
);
let remote = r#"[{"iid":100,"title":"Three epics","description":"Body.","labels":[],
"state":"opened","updated_at":null,"epics":["e-3","e-1","e-2"]}]"#;
let first = h.import("gitlab", remote);
assert!(first.status.success());
assert_eq!(
epics_of(&h, 1),
["e-1", "e-2", "e-3"],
"every reference is kept, in the local order"
);
assert_eq!(
reference_events(&h, 1),
0,
"all three were already referenced, so the import was a no-op"
);
let after_first = issue_text(&h, 1);
let second = h.import("gitlab", remote);
assert!(second.status.success());
assert_eq!(
issue_text(&h, 1),
after_first,
"a second identical import must change no byte of the file — no duplicate, no \
reorder, no History entry"
);
assert!(
!String::from_utf8_lossy(&second.stdout).contains("referenced gitlab"),
"an already-present reference is not reported as work"
);
let first_export = h.export("gitlab", &[], None);
assert!(
!first_export.status.success(),
"the first export reports unsupported epic references"
);
let after_export = issue_text(&h, 1);
let again = h.export("gitlab", &[], None);
assert!(
!again.status.success(),
"the second export remains partial for the same unsupported references"
);
assert_eq!(
issue_text(&h, 1),
after_export,
"re-exporting an unchanged snapshot must not change a byte"
);
assert_eq!(epics_of(&h, 1), ["e-1", "e-2", "e-3"]);
}
#[test]
fn t039_a_remote_epic_rename_matches_on_the_id_and_never_on_the_name() {
let h = Harness::new();
h.init_with_gitlab();
declare_epics(
&h,
&[("e-1", "Platform hardening"), ("e-2", "Remotely renamed")],
);
write_epic_issue(
&h,
1,
"2026-01-01-renamed.md",
"Renamed remotely",
Some("100"),
&["e-1"],
"2026-01-01",
);
let toml_before = fs::read_to_string(h.dir().join("tix.toml")).unwrap();
let remote = r#"[{"iid":100,"title":"Renamed remotely","description":"Body.","labels":[],
"state":"opened","updated_at":null,
"epics":[{"id":"e-1","name":"Remotely renamed"}]}]"#;
let out = h.import("gitlab", remote);
assert!(
out.status.success(),
"import failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
epics_of(&h, 1),
["e-1"],
"the remote renamed an epic and the reference is still the *same* one"
);
assert_eq!(
reference_events(&h, 1),
0,
"an already-present reference is a no-op even when the remote renames it"
);
assert_eq!(
fs::read_to_string(h.dir().join("tix.toml")).unwrap(),
toml_before,
"the local registry's display labels are not the remote's to rewrite"
);
let by_name = r#"[{"iid":100,"title":"Renamed remotely","description":"Body.","labels":[],
"state":"opened","updated_at":null,
"epics":[{"name":"Platform hardening"}]}]"#;
let out = h.import("gitlab", by_name);
assert!(out.status.success());
let report = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
report.contains("gitlab: epics") && report.contains("no id"),
"a name is not an identity, and the diagnostic says so: {report}"
);
assert_eq!(
epics_of(&h, 1),
["e-1"],
"and no reference was invented from a name"
);
}
#[test]
fn t039_a_remote_epic_the_registry_does_not_know_creates_no_dangling_reference() {
let h = Harness::new();
h.init_with_gitlab();
declare_epics(&h, &[("e-1", "One")]);
write_epic_issue(
&h,
1,
"2026-01-01-tracked.md",
"Tracked",
Some("10"),
&[],
"2026-01-01",
);
let toml_before = fs::read_to_string(h.dir().join("tix.toml")).unwrap();
let remote = r#"[{"iid":11,"title":"Created one","description":"","labels":[],
"state":"opened","updated_at":null,"epics":["e-1","e-ghost"]},
{"iid":12,"title":"Created two","description":"","labels":[],
"state":"opened","updated_at":null,"epics":["e-1","e-ghost"]},
{"iid":10,"title":"Tracked","description":"","labels":[],
"state":"opened","updated_at":null,"epics":["e-1","e-ghost"]}]"#;
let out = h.import("gitlab", remote);
assert!(
out.status.success(),
"import failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let report = String::from_utf8_lossy(&out.stdout).to_string();
assert_eq!(
report.matches("registry does not declare").count(),
3,
"one diagnostic per unknown reference, not a storm: {report}"
);
for needle in ["gitlab: epics", "e-ghost", "cannot represent"] {
assert!(report.contains(needle), "`{needle}` missing from: {report}");
}
let all = find_issue_texts(&h).join("\n");
assert!(
!all.contains("e-ghost"),
"an unknown epic must not be written into any issue file as a dangling \
reference:\n{all}"
);
for number in [1, 2, 3] {
assert_eq!(
epics_of(&h, number),
["e-1"],
"issue #{number} keeps exactly the reference the registry can resolve"
);
}
assert_eq!(
fs::read_to_string(h.dir().join("tix.toml")).unwrap(),
toml_before,
"a sync never writes the epic registry"
);
let before = find_issue_texts(&h).join("\n");
let second = h.import("gitlab", remote);
assert!(second.status.success());
let second_report = String::from_utf8_lossy(&second.stdout).to_string();
assert_eq!(
second_report.matches("registry does not declare").count(),
3,
"still one per reference: {second_report}"
);
assert_eq!(
find_issue_texts(&h).join("\n"),
before,
"the end state is stable"
);
}
#[test]
fn t040_both_adapters_present_the_same_epic_seam_and_send_no_verb() {
use tix::sync::glab::GitLabAdapter;
use tix::sync::linear::LinearAdapter;
use tix::sync::{ProcessRunner, Tool, TrackerAdapter};
let mut gitlab = GitLabAdapter::new(ProcessRunner, None, None, "acme/tix".into());
let mut linear = LinearAdapter::new(ProcessRunner, None, "Eng".into());
let seams: [(Tool, &mut dyn TrackerAdapter); 2] =
[(Tool::GitLab, &mut gitlab), (Tool::Linear, &mut linear)];
for (tool, adapter) in seams {
assert_eq!(adapter.tool(), tool);
adapter
.set_epics("100", &[])
.unwrap_or_else(|e| panic!("{tool}: the empty epic seam must be a no-op: {e}"));
}
for (target, setup, field, reason, epic) in [
(
"gitlab",
Harness::init_with_gitlab as fn(&Harness),
"gitlab: epics",
"epic_iid",
"e-1",
),
(
"linear",
Harness::init_with_linear as fn(&Harness),
"linear: epics",
"no epic level",
"e-1",
),
] {
let h = Harness::new();
setup(&h);
declare_epics(&h, &[(epic, "One")]);
write_epic_issue(
&h,
1,
"2026-01-01-one.md",
"One epic",
None,
&[epic],
"2026-01-01",
);
let out = h.export(target, &[], None);
assert!(
!out.status.success(),
"{target} export should report a partial mirror: {}",
String::from_utf8_lossy(&out.stderr)
);
let record = h.record_contents();
assert!(
!record.contains(epic),
"{target}: no local epic id may be sent:\n{record}"
);
let report = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
report.contains(field) && report.contains(reason),
"{target}: the diagnostic names the field and the per-tool reason: {report}"
);
}
}
#[test]
fn t040_an_epic_diagnostic_carries_no_credential_material() {
let h = Harness::new();
h.init_with_gitlab();
declare_epics(&h, &[("e-1", "One")]);
write_epic_issue(
&h,
1,
"2026-01-01-cred.md",
"Credential test",
Some("100"),
&["e-1"],
"2026-01-01",
);
let secret = fs::read_to_string(h.dir().join("tix.toml"))
.unwrap()
.lines()
.find_map(|line| line.strip_prefix("token = \""))
.map(|value| value.trim_end_matches('"').to_string())
.expect("the fixture configures a token");
let out = h.export("gitlab", &[], None);
assert!(
!out.status.success(),
"unsupported epic references make the export partial"
);
let remote = format!(
r#"[{{"iid":100,"title":"Credential test","description":"Body.","labels":[],
"state":"opened","updated_at":null,
"epics":[{{"id":"e-ghost","name":"{secret}"}},{{"name":"{secret}"}}]}}]"#
);
let imported = h.import("gitlab", &remote);
assert!(
imported.status.success(),
"import failed: {}",
String::from_utf8_lossy(&imported.stderr)
);
let stdout = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&imported.stdout)
);
let stderr = format!(
"{}{}",
String::from_utf8_lossy(&out.stderr),
String::from_utf8_lossy(&imported.stderr)
);
assert!(
stdout.contains("<token>"),
"the remote-reported name must have been redacted, not omitted: {stdout}"
);
assert!(
!stdout.contains(&secret) && !stderr.contains(&secret),
"a diagnostic built from remote output leaked the token:\n out: {stdout}\n err: {stderr}"
);
assert!(
stdout.contains("e-ghost") && stdout.contains("registry does not declare"),
"the unknown epic is reported by id: {stdout}"
);
}