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);
fs::read_dir(&root)
.unwrap()
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().map(|e| e == "md").unwrap_or(false))
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect()
};
let assigned = md_in("alice");
assert_eq!(assigned.len(), 1, "expected one file under alice/");
let assigned_file = issues.join("alice").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").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}"
);
}