use std::path::Path;
use std::process::Command;
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_swapdex")
}
fn run(root: &Path, args: &[&str]) -> (String, String, i32) {
let out = Command::new(bin())
.args(args)
.env("SWAPDEX_ROOT", root)
.output()
.unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code().unwrap_or(-1),
)
}
fn seed_codex(root: &Path, account_id: &str) {
let d = root.join(".codex");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(
d.join("auth.json"),
serde_json::to_vec(&serde_json::json!({
"auth_mode":"chatgpt","OPENAI_API_KEY":"sk-SENTINEL",
"tokens":{"id_token":"h.eyJlbWFpbCI6ImFAeC5jb20ifQ.s","access_token":"AT",
"refresh_token":"RT","account_id":account_id},
"last_refresh":"2026-07-03T00:00:00Z"}))
.unwrap(),
)
.unwrap();
}
#[test]
fn add_use_roundtrip_and_egress_sentinel() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
let (_o, e, c) = run(root.path(), &["add", "work", "--tool", "codex"]);
assert_eq!(c, 0, "add failed: {e}");
seed_codex(root.path(), "acct-B"); run(root.path(), &["add", "home", "--tool", "codex"]);
let (_o, e, c) = run(root.path(), &["use", "work", "--tool", "codex"]);
assert_eq!(c, 0, "use failed: {e}");
let live: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.path().join(".codex/auth.json")).unwrap())
.unwrap();
assert_eq!(
live["tokens"]["account_id"], "acct-A",
"live login is now work"
);
for args in [vec!["ls"], vec!["status"], vec!["ls", "--json"]] {
let (o, e, _c) = run(root.path(), &args);
assert!(
!o.contains("SENTINEL") && !e.contains("SENTINEL"),
"token leak in {args:?}: {o}{e}"
);
}
}
#[test]
fn status_trusts_live_identity_not_stale_active_json() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
run(root.path(), &["use", "work", "--tool", "codex"]);
seed_codex(root.path(), "acct-Z");
let (o, _e, _c) = run(root.path(), &["status"]);
assert!(
o.contains("not saved"),
"status must reconcile against the live login: {o}"
);
}
#[test]
fn use_nonexistent_profile_exits_nonzero() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
let (_o, _e, c) = run(root.path(), &["use", "ghost", "--tool", "codex"]);
assert_ne!(c, 0);
}
#[test]
fn ls_empty_store_guides_onboarding() {
let root = tempfile::tempdir().unwrap();
let (o, _e, c) = run(root.path(), &["ls"]);
assert_eq!(c, 0);
assert!(o.contains("No accounts saved"));
assert!(
o.contains("swapdex setup"),
"empty state should point to setup: {o}"
);
}
#[test]
fn setup_non_tty_degrades_gracefully() {
let root = tempfile::tempdir().unwrap();
let out = std::process::Command::new(bin())
.arg("setup")
.env("SWAPDEX_ROOT", root.path())
.stdin(std::process::Stdio::null())
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&out.stderr).contains("interactive"),
"setup should explain it needs a terminal"
);
}
fn run_setup(root: &Path, input: &str) -> (String, i32) {
use std::io::Write;
use std::process::Stdio;
let mut child = Command::new(bin())
.arg("setup")
.env("SWAPDEX_ROOT", root)
.env("SWAPDEX_ASSUME_TTY", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.as_mut()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
let out = child.wait_with_output().unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned(),
out.status.code().unwrap_or(-1),
)
}
#[test]
fn setup_wizard_saves_account_from_prompts() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
let (o, c) = run_setup(root.path(), "mycodex\nn\n");
assert_eq!(c, 0, "{o}");
let (ls, _e, _c) = run(root.path(), &["ls"]);
assert!(
ls.contains("mycodex"),
"setup should save the account: {ls}"
);
}
#[test]
fn setup_reprompts_on_an_invalid_name() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
let (o, c) = run_setup(root.path(), "bad/name\ngood\nn\n");
assert_eq!(c, 0);
assert!(
o.contains("can't be a name"),
"should reject the invalid name: {o}"
);
let (ls, _e, _c) = run(root.path(), &["ls"]);
assert!(ls.contains("good"), "should save the valid retry: {ls}");
}
#[test]
fn login_claude_guides_the_add_step() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
let bin_dir = root.path().join("fakebin");
std::fs::create_dir_all(&bin_dir).unwrap();
let fake = bin_dir.join("claude");
std::fs::write(&fake, "#!/bin/sh\necho 1.0.0\n").unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
let out = Command::new(bin())
.args(["login", "work", "--tool", "claude"])
.env("SWAPDEX_ROOT", root.path())
.env("PATH", &bin_dir)
.output()
.unwrap();
let o = String::from_utf8_lossy(&out.stdout);
assert_eq!(out.status.code().unwrap_or(-1), 0, "{o}");
assert!(o.contains("swapdex add work --tool claude"), "{o}");
}
#[test]
fn profile_name_traversal_is_rejected() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
let (_o, _e, c) = run(root.path(), &["add", "../escape", "--tool", "codex"]);
assert_eq!(c, 2, "traversal name must be rejected");
assert!(!root.path().join(".local/share/escape").exists());
let (_o, _e, c2) = run(root.path(), &["rm", "../escape", "--yes"]);
assert_eq!(c2, 2);
}
#[test]
fn use_already_active_is_a_noop() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
let (o, _e, c) = run(root.path(), &["use", "work", "--tool", "codex"]);
assert_eq!(c, 0);
assert!(o.contains("already active"), "{o}");
let tl = std::fs::read_to_string(root.path().join(".local/share/swapdex/timeline.jsonl"))
.unwrap_or_default();
assert!(
!tl.contains("\"account\":\"work\""),
"no-op must not append a timeline event: {tl}"
);
}
#[test]
fn rename_moves_the_profile() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
let (_o, e, c) = run(root.path(), &["rename", "work", "job"]);
assert_eq!(c, 0, "rename failed: {e}");
let (o, _e, _c) = run(root.path(), &["ls"]);
assert!(o.contains("job"), "ls should show renamed profile: {o}");
}
#[test]
fn ls_json_reports_active_per_tool_in_mixed_state() {
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
seed_codex_tok(root.path(), "acct-A", "AT", "2026-07-04T00:00:00Z");
run(root.path(), &["add", "work"]);
seed_codex_tok(root.path(), "acct-B", "AT2", "2026-07-04T00:00:00Z");
run(root.path(), &["add", "home", "--tool", "codex"]);
let (o, _e, c) = run(root.path(), &["ls", "--json"]);
assert_eq!(c, 0);
let rows: serde_json::Value = serde_json::from_str(o.trim()).unwrap();
let get = |name: &str| -> Vec<String> {
rows.as_array()
.unwrap()
.iter()
.find(|r| r["name"] == name)
.unwrap()["active_tools"]
.as_array()
.unwrap()
.iter()
.map(|t| t.as_str().unwrap().to_string())
.collect()
};
assert_eq!(
get("work"),
vec!["claude-code"],
"work is active only for claude"
);
assert_eq!(get("home"), vec!["codex"], "home is active only for codex");
}
#[test]
fn stray_file_in_store_is_ignored() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
std::fs::write(
root.path().join(".local/share/swapdex/active.json"),
b"[1,2,3]",
)
.unwrap();
seed_codex(root.path(), "acct-B"); let (_o, _e, c) = run(root.path(), &["use", "work", "--tool", "codex"]);
assert_ne!(c, 101, "must not panic on a stray file");
assert_eq!(c, 0);
}
#[test]
fn no_args_prints_ascii_banner_plain_when_piped() {
let root = tempfile::tempdir().unwrap();
let (o, _e, c) = run(root.path(), &[]);
assert_eq!(c, 0);
assert!(o.contains('\u{2588}'), "should print block ASCII art");
assert!(
!o.contains('\u{1b}'),
"no ANSI colour codes when stdout is piped"
);
}
#[test]
fn completions_and_status_json() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
let (o, _e, c) = run(root.path(), &["completions", "bash"]);
assert_eq!(c, 0);
assert!(
o.contains("swapdex"),
"completion script should mention swapdex"
);
let (o2, _e, c2) = run(root.path(), &["status", "--json"]);
assert_eq!(c2, 0);
let v: serde_json::Value =
serde_json::from_str(o2.trim()).expect("status --json must be valid JSON");
assert!(v.is_array(), "status --json is an array of tools");
}
fn seed_codex_tok(root: &Path, account_id: &str, access: &str, last_refresh: &str) {
let d = root.join(".codex");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(
d.join("auth.json"),
serde_json::to_vec(&serde_json::json!({
"auth_mode":"chatgpt","OPENAI_API_KEY":"sk-X",
"tokens":{"id_token":"h.eyJlbWFpbCI6ImFAeC5jb20ifQ.s","access_token":access,
"refresh_token":"RT","account_id":account_id},
"last_refresh":last_refresh}))
.unwrap(),
)
.unwrap();
}
fn seed_claude(root: &Path, uuid: &str, email: &str) {
let d = root.join(".claude");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(
d.join(".credentials.json"),
serde_json::to_vec(&serde_json::json!({"claudeAiOauth":{
"accessToken":"AT","refreshToken":"RT","expiresAt":9999999999999i64,
"subscriptionType":"max"}}))
.unwrap(),
)
.unwrap();
std::fs::write(
root.join(".claude.json"),
serde_json::to_vec(&serde_json::json!({
"oauthAccount":{"accountUuid":uuid,"emailAddress":email,"displayName":"X"}}))
.unwrap(),
)
.unwrap();
}
#[test]
fn tool_typo_is_rejected_not_fail_open() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
let (_o, e, c) = run(root.path(), &["use", "x", "--tool", "cluade"]);
assert_ne!(c, 0, "typo'd --tool must be rejected");
assert!(e.contains("cluade") || e.contains("possible values"), "{e}");
}
#[test]
fn empty_account_id_still_switches() {
let root = tempfile::tempdir().unwrap();
seed_codex_tok(root.path(), "", "AAA", "2026-07-04T00:00:00Z");
run(root.path(), &["add", "p1", "--tool", "codex"]);
seed_codex_tok(root.path(), "", "BBB", "2026-07-04T00:00:00Z"); let (o, _e, c) = run(root.path(), &["use", "p1", "--tool", "codex"]);
assert_eq!(c, 0);
assert!(
!o.contains("already active"),
"empty ids must not be 'already active': {o}"
);
let live: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.path().join(".codex/auth.json")).unwrap())
.unwrap();
assert_eq!(
live["tokens"]["access_token"], "AAA",
"must actually switch to p1"
);
}
#[test]
fn ls_shows_stale_codex_in_a_both_tool_profile() {
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
seed_codex_tok(root.path(), "acct-A", "AT", "2020-01-01T00:00:00Z"); run(root.path(), &["add", "work"]); let (o, _e, c) = run(root.path(), &["ls"]);
assert_eq!(c, 0);
assert!(
o.contains("(stale)"),
"codex staleness must surface in a both-tool profile: {o}"
);
}
#[test]
fn add_default_both_attaches_missing_tool() {
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com"); run(root.path(), &["add", "work"]);
seed_codex_tok(root.path(), "acct-A", "AT", "2026-07-04T00:00:00Z"); let (o, _e, c) = run(root.path(), &["add", "work"]); assert_eq!(c, 0, "should attach codex without --update: {o}");
let (ls, _e, _c) = run(root.path(), &["ls"]);
assert!(
ls.contains("codex"),
"codex should now be in the profile: {ls}"
);
}
#[test]
fn ls_marks_a_stale_codex_profile() {
let root = tempfile::tempdir().unwrap();
let d = root.path().join(".codex");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(
d.join("auth.json"),
serde_json::to_vec(&serde_json::json!({
"auth_mode":"chatgpt","OPENAI_API_KEY":"sk-X",
"tokens":{"id_token":"h.eyJlbWFpbCI6ImFAeC5jb20ifQ.s","access_token":"AT",
"refresh_token":"RT","account_id":"acct-A"},
"last_refresh":"2020-01-01T00:00:00Z"}))
.unwrap(),
)
.unwrap();
run(root.path(), &["add", "old", "--tool", "codex"]);
let (o, _e, c) = run(root.path(), &["ls"]);
assert_eq!(c, 0);
assert!(
o.contains("(stale)"),
"old codex login should be flagged stale: {o}"
);
}
#[test]
fn add_works_without_claude_json() {
let root = tempfile::tempdir().unwrap();
let d = root.path().join(".claude");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(
d.join(".credentials.json"),
serde_json::to_vec(&serde_json::json!({"claudeAiOauth":{
"accessToken":"AT","refreshToken":"RT","expiresAt":9999999999999i64,
"subscriptionType":"max"}}))
.unwrap(),
)
.unwrap();
let (_o, e, c) = run(root.path(), &["add", "work", "--tool", "claude"]);
assert_eq!(c, 0, "add must work without .claude.json: {e}");
}
#[test]
fn restore_brings_back_the_pre_switch_login() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
seed_codex(root.path(), "acct-B");
let (_o, e, c) = run(root.path(), &["use", "work", "--tool", "codex"]);
assert_eq!(c, 0, "use failed: {e}");
let (o, e, c) = run(root.path(), &["restore", "--tool", "codex"]);
assert_eq!(c, 0, "restore failed: {e}");
assert!(o.contains("restored"), "should say what it did: {o}");
let live: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.path().join(".codex/auth.json")).unwrap())
.unwrap();
assert_eq!(live["tokens"]["account_id"], "acct-B", "B is live again");
let (_o, e, c) = run(root.path(), &["restore", "--tool", "codex"]);
assert_eq!(c, 0, "second restore failed: {e}");
let live: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.path().join(".codex/auth.json")).unwrap())
.unwrap();
assert_eq!(live["tokens"]["account_id"], "acct-A", "toggled back to A");
}
#[test]
fn restore_without_backups_is_a_clear_error() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
let (_o, e, c) = run(root.path(), &["restore", "--tool", "codex"]);
assert_eq!(c, 5, "no backup -> exit 5: {e}");
assert!(e.contains("no backup"), "message should say why: {e}");
}
#[test]
fn restore_dry_run_changes_nothing() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
seed_codex(root.path(), "acct-B");
run(root.path(), &["use", "work", "--tool", "codex"]);
let (o, _e, c) = run(root.path(), &["restore", "--tool", "codex", "--dry-run"]);
assert_eq!(c, 0);
assert!(o.contains("would restore"), "dry-run narrates: {o}");
let live: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.path().join(".codex/auth.json")).unwrap())
.unwrap();
assert_eq!(live["tokens"]["account_id"], "acct-A", "nothing written");
}
#[test]
fn use_notes_a_tool_left_unchanged() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
let d = root.path().join(".claude");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(
d.join(".credentials.json"),
serde_json::to_vec(&serde_json::json!({"claudeAiOauth":{
"accessToken":"AT","refreshToken":"RT","expiresAt":9999999999999i64,
"subscriptionType":"max"}}))
.unwrap(),
)
.unwrap();
run(root.path(), &["add", "cx", "--tool", "codex"]);
seed_codex(root.path(), "acct-B");
let (o, e, c) = run(root.path(), &["use", "cx"]);
assert_eq!(c, 0, "use failed: {e}");
assert!(
(o.clone() + &e).contains("unchanged"),
"must note claude-code was left unchanged: {o}{e}"
);
}
fn claude_live_uuid(root: &Path) -> String {
let v: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.join(".claude.json")).unwrap()).unwrap();
v["oauthAccount"]["accountUuid"]
.as_str()
.unwrap()
.to_string()
}
#[test]
fn bare_restore_scopes_to_the_last_switch() {
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-C1", "c1@x.com");
seed_codex(root.path(), "acct-X");
run(root.path(), &["add", "p1"]);
seed_claude(root.path(), "uuid-C2", "c2@x.com"); seed_codex(root.path(), "acct-Y"); let (_o, e, c) = run(root.path(), &["use", "p1"]); assert_eq!(c, 0, "use p1 failed: {e}");
std::thread::sleep(std::time::Duration::from_millis(1100));
seed_codex(root.path(), "acct-Z"); let (_o, e, c) = run(root.path(), &["use", "p1", "--tool", "codex"]); assert_eq!(c, 0, "codex-only use failed: {e}");
let (o, e, c) = run(root.path(), &["restore"]);
assert_eq!(c, 0, "bare restore failed: {e}");
let live: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.path().join(".codex/auth.json")).unwrap())
.unwrap();
assert_eq!(live["tokens"]["account_id"], "acct-Z", "codex undone: {o}");
assert_eq!(
claude_live_uuid(root.path()),
"uuid-C1",
"claude-code was NOT part of the last switch and must stay: {o}{e}"
);
}
#[test]
fn use_warns_when_outgoing_login_is_unsaved() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
seed_codex(root.path(), "acct-PRECIOUS"); let (o, e, c) = run(root.path(), &["use", "work", "--tool", "codex"]);
assert_eq!(c, 0, "use failed: {e}");
assert!(
(o + &e).contains("not saved"),
"must warn the outgoing login is unsaved"
);
}
#[test]
fn rename_collision_exits_6() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
run(
root.path(),
&["add", "personal", "--tool", "codex", "--update"],
);
let (_o, e, c) = run(root.path(), &["rename", "work", "personal"]);
assert_eq!(
c, 6,
"collision is 'already exists' (6), not a hard error: {e}"
);
}
#[test]
fn login_claude_missing_cli_exits_3() {
let root = tempfile::tempdir().unwrap();
let out = Command::new(bin())
.args(["login", "x", "--tool", "claude"])
.env("SWAPDEX_ROOT", root.path())
.env("PATH", "/nonexistent")
.output()
.unwrap();
assert_eq!(
out.status.code().unwrap_or(-1),
3,
"missing claude CLI must exit 3 like codex"
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("PATH"),
"guidance goes to stderr"
);
}
#[test]
fn use_replaces_a_corrupt_live_login() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
std::fs::write(root.path().join(".codex/auth.json"), b"NOT JSON{{{").unwrap();
let (_o, e, c) = run(root.path(), &["use", "work", "--tool", "codex"]);
assert_eq!(c, 0, "use must recover from a corrupt live file: {e}");
assert!(
e.contains("could not be read"),
"warns about the skipped backup: {e}"
);
let live: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.path().join(".codex/auth.json")).unwrap())
.unwrap();
assert_eq!(
live["tokens"]["account_id"], "acct-A",
"good snapshot applied"
);
}
#[test]
fn status_reports_unreadable_login_file() {
let root = tempfile::tempdir().unwrap();
let d = root.path().join(".codex");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("auth.json"), b"NOT JSON{{{").unwrap();
let (o, e, c) = run(root.path(), &["status"]);
assert_eq!(c, 0, "status must not abort: {e}");
assert!(o.contains("unreadable"), "says the file is unreadable: {o}");
let (o, _e, c) = run(root.path(), &["status", "--json"]);
assert_eq!(c, 0);
let v: serde_json::Value = serde_json::from_str(o.trim()).unwrap();
let codex = v
.as_array()
.unwrap()
.iter()
.find(|r| r["tool"] == "codex")
.unwrap();
assert_eq!(codex["unreadable"], true, "json marks unreadable: {o}");
}
#[test]
fn doctor_healthy_exits_zero() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
let (o, e, c) = run(root.path(), &["doctor"]);
assert_eq!(c, 0, "healthy doctor must exit 0: {o}{e}");
assert!(o.contains("ok"), "reports ok sections: {o}");
assert!(
!o.contains("problem"),
"no problems on a healthy setup: {o}"
);
}
#[test]
fn doctor_flags_corrupt_snapshot_with_remedy() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
let blob = root
.path()
.join(".local/share/swapdex/accounts/work/codex/auth");
std::fs::write(&blob, b"NOT JSON{{{").unwrap();
let (o, _e, c) = run(root.path(), &["doctor"]);
assert_eq!(c, 9, "problems -> exit 9: {o}");
assert!(o.contains("work"), "names the profile: {o}");
assert!(o.contains("--update"), "gives the remedy: {o}");
}
#[test]
fn doctor_flags_corrupt_live_login() {
let root = tempfile::tempdir().unwrap();
let d = root.path().join(".codex");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("auth.json"), b"NOT JSON{{{").unwrap();
let (o, _e, c) = run(root.path(), &["doctor"]);
assert_eq!(c, 9);
assert!(o.contains("unreadable"), "{o}");
}
#[test]
fn manpage_emits_roff() {
let root = tempfile::tempdir().unwrap();
let (o, _e, c) = run(root.path(), &["manpage"]);
assert_eq!(c, 0);
assert!(
o.contains(".TH swapdex 1"),
"roff man header present: {}",
&o[..o.len().min(80)]
);
assert!(o.contains("restore"), "documents the subcommands");
}
#[test]
fn use_dash_toggles_between_two_profiles() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
seed_codex(root.path(), "acct-B");
run(root.path(), &["add", "personal", "--tool", "codex"]);
let (o, e, c) = run(root.path(), &["use", "-"]);
assert_eq!(c, 0, "use - failed: {o}{e}");
let live: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.path().join(".codex/auth.json")).unwrap())
.unwrap();
assert_eq!(live["tokens"]["account_id"], "acct-A", "toggled to work");
let (_o, _e, c) = run(root.path(), &["use", "-"]);
assert_eq!(c, 0);
let live: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.path().join(".codex/auth.json")).unwrap())
.unwrap();
assert_eq!(live["tokens"]["account_id"], "acct-B", "toggled back");
}
#[test]
fn use_dash_with_ambiguity_refuses_with_candidates() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "one", "--tool", "codex"]);
seed_codex(root.path(), "acct-B");
run(root.path(), &["add", "two", "--tool", "codex"]);
seed_codex(root.path(), "acct-C");
run(root.path(), &["add", "three", "--tool", "codex"]);
let (_o, e, c) = run(root.path(), &["use", "-"]);
assert_ne!(c, 0, "ambiguous toggle must refuse");
assert!(
e.contains("one") || e.contains("swapdex use"),
"lists a way out: {e}"
);
}
#[test]
fn use_unique_prefix_matches() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
seed_codex(root.path(), "acct-B");
run(root.path(), &["add", "personal", "--tool", "codex"]);
let (o, e, c) = run(root.path(), &["use", "w"]);
assert_eq!(c, 0, "unique prefix must resolve: {e}");
assert!(
(o + &e).contains("work"),
"says which profile it resolved to"
);
}
#[test]
fn use_ambiguous_prefix_refuses() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "prod-a", "--tool", "codex"]);
seed_codex(root.path(), "acct-B");
run(root.path(), &["add", "prod-b", "--tool", "codex"]);
let (_o, e, c) = run(root.path(), &["use", "prod"]);
assert_eq!(c, 5, "ambiguous prefix -> no such profile class: {e}");
assert!(
e.contains("prod-a") && e.contains("prod-b"),
"lists candidates: {e}"
);
}
#[test]
fn status_short_is_one_compact_line() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
let (o, _e, c) = run(root.path(), &["status", "--short"]);
assert_eq!(c, 0);
assert_eq!(o.trim().lines().count(), 1, "exactly one line: {o}");
assert!(o.contains("codex:work"), "tool:profile pairs: {o}");
}
#[test]
fn rm_confirms_interactively_on_tty() {
use std::io::Write;
use std::process::Stdio;
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "victim", "--tool", "codex"]);
let mut child = Command::new(bin())
.args(["rm", "victim"])
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_ASSUME_TTY", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.as_mut().unwrap().write_all(b"y\n").unwrap();
let out = child.wait_with_output().unwrap();
assert_eq!(out.status.code().unwrap_or(-1), 0);
let (ls, _e, _c) = run(root.path(), &["ls"]);
assert!(!ls.contains("victim"), "profile removed after y: {ls}");
}