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) {
run_env(root, args, &[])
}
fn run_env(root: &Path, args: &[&str], envs: &[(&str, &str)]) -> (String, String, i32) {
let mut cmd = Command::new(bin());
cmd.args(args).env("SWAPDEX_ROOT", root);
for (k, v) in envs {
cmd.env(k, v);
}
let out = cmd.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 chmod600(p: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o600)).unwrap();
}
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();
chmod600(&root.join(".codex/auth.json"));
}
#[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 setup_rejects_the_reserved_dash_name() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
let (o, c) = run_setup(root.path(), "-\ngood\nn\n");
assert_eq!(c, 0);
assert!(o.contains("reserved"), "must reject '-' as reserved: {o}");
let (names, _e, _c) = run(root.path(), &["ls", "--names"]);
assert!(
!names.lines().any(|l| l == "-"),
"no profile literally named '-': {names}"
);
assert!(names.contains("good"), "the valid retry is saved: {names}");
}
#[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), 3, "{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();
chmod600(&root.join(".claude/.credentials.json"));
chmod600(&root.join(".claude.json"));
}
#[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_env(root.path(), &["doctor"], &[("PATH", "")]);
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 status_does_not_flag_a_normally_lapsed_access_token() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join(".claude")).unwrap();
let one_hour_ago_ms = (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64)
- 3_600_000;
std::fs::write(
root.path().join(".claude/.credentials.json"),
format!(
r#"{{"claudeAiOauth":{{"accessToken":"AT","refreshToken":"RT","expiresAt":{one_hour_ago_ms},"subscriptionType":"max"}}}}"#
),
)
.unwrap();
std::fs::write(
root.path().join(".claude.json"),
br#"{"oauthAccount":{"accountUuid":"u","emailAddress":"a@x.com","displayName":"D"}}"#,
)
.unwrap();
let (o, _e, c) = run(root.path(), &["status"]);
assert_eq!(c, 0);
assert!(o.contains("a@x.com"), "shows the account: {o}");
assert!(
!o.to_lowercase().contains("expired"),
"a normally-lapsed access token is not 'expired': {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}");
}
#[test]
fn ls_names_prints_bare_names() {
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(), &["ls", "--names"]);
assert_eq!(c, 0);
assert_eq!(o, "personal\nwork\n", "bare sorted names only: {o:?}");
}
#[test]
fn add_without_name_asks_on_tty() {
use std::io::Write;
use std::process::Stdio;
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
let mut child = Command::new(bin())
.arg("add")
.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"\n").unwrap();
let out = child.wait_with_output().unwrap();
assert_eq!(out.status.code().unwrap_or(-1), 0);
let (names, _e, _c) = run(root.path(), &["ls", "--names"]);
assert_eq!(names, "a\n", "saved under the suggested name: {names:?}");
}
#[test]
fn add_without_name_errors_helpfully_non_tty() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
let (_o, e, c) = run(root.path(), &["add"]);
assert_eq!(c, 2, "non-tty add without a name is an argument error");
assert!(e.contains("swapdex add <name>"), "guides the fix: {e}");
}
#[test]
fn use_empty_string_never_switches() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
seed_codex(root.path(), "acct-UNSAVED"); let (_o, _e, c) = run(root.path(), &["use", ""]);
assert_eq!(c, 2, "empty name is invalid, never a prefix match");
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-UNSAVED",
"live login untouched"
);
}
#[test]
fn rm_nonexistent_does_not_prompt() {
use std::process::Stdio;
let root = tempfile::tempdir().unwrap();
let out = Command::new(bin())
.args(["rm", "ghost"])
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_ASSUME_TTY", "1")
.stdin(Stdio::null()) .output()
.unwrap();
assert_eq!(
out.status.code().unwrap_or(-1),
5,
"straight to 'no profile'"
);
assert!(
!String::from_utf8_lossy(&out.stdout).contains("delete saved profile"),
"no confirmation prompt for a ghost"
);
}
#[test]
fn use_dash_never_repicks_the_newest_switch_destination() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "a", "--tool", "codex"]);
seed_codex(root.path(), "acct-B");
run(root.path(), &["add", "b", "--tool", "codex"]);
seed_codex(root.path(), "acct-C");
run(root.path(), &["add", "c", "--tool", "codex"]);
run(root.path(), &["use", "a", "--tool", "codex"]);
seed_codex(root.path(), "");
let (_o, e, _c) = run(root.path(), &["use", "-"]);
assert!(
!e.contains("'-' -> 'a'"),
"must not re-pick the newest switch destination: {e}"
);
}
#[test]
fn ls_aligns_cjk_names_by_display_width() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "íšŒì‚¬ê³„ì •", "--tool", "codex"]); seed_codex(root.path(), "acct-B");
run(root.path(), &["add", "personal", "--tool", "codex"]); let (o, _e, c) = run(root.path(), &["ls"]);
assert_eq!(c, 0);
fn disp_prefix(l: &str) -> usize {
let idx = l.find("a@x.com").unwrap();
l[..idx]
.chars()
.map(|c| if (c as u32) >= 0x1100 { 2 } else { 1 })
.sum()
}
let widths: Vec<usize> = o
.lines()
.filter(|l| l.contains("a@x.com"))
.map(disp_prefix)
.collect();
assert_eq!(widths.len(), 2, "both rows visible: {o}");
assert_eq!(
widths[0], widths[1],
"email column must align in display columns: {o}"
);
}
fn run_ui(root: &Path, input: &str) -> (String, String, i32) {
use std::io::Write;
use std::process::Stdio;
let mut child = Command::new(bin())
.arg("ui")
.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(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code().unwrap_or(-1),
)
}
#[test]
fn ui_picker_switches_by_number() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "alpha", "--tool", "codex"]);
seed_codex(root.path(), "acct-B");
run(root.path(), &["add", "beta", "--tool", "codex"]);
let (o, e, c) = run_ui(root.path(), "1\n");
assert_eq!(c, 0, "picker switch 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", "switched to alpha");
}
#[test]
fn ui_picker_enter_cancels() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "alpha", "--tool", "codex"]);
seed_codex(root.path(), "acct-B");
run(root.path(), &["add", "beta", "--tool", "codex"]);
let (o, _e, c) = run_ui(root.path(), "\n");
assert_eq!(c, 0);
assert!(
o.contains("cancel") || o.contains("nothing"),
"says it did nothing: {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", "nothing switched");
}
#[test]
fn ui_picker_rejects_bad_number_then_accepts() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "alpha", "--tool", "codex"]);
seed_codex(root.path(), "acct-B");
run(root.path(), &["add", "beta", "--tool", "codex"]);
let (o, e, c) = run_ui(root.path(), "9\n1\n");
assert_eq!(c, 0, "{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",
"re-prompt then switch"
);
}
#[test]
fn ui_non_tty_degrades_gracefully() {
use std::process::Stdio;
let root = tempfile::tempdir().unwrap();
let out = Command::new(bin())
.arg("ui")
.env("SWAPDEX_ROOT", root.path())
.stdin(Stdio::null())
.output()
.unwrap();
assert_ne!(out.status.code().unwrap_or(-1), 0);
assert!(
String::from_utf8_lossy(&out.stderr).contains("terminal"),
"explains it needs a terminal"
);
}
#[test]
fn ui_resume_pick_execs_sessionwiki() {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::process::Stdio;
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "alpha", "--tool", "codex"]);
seed_codex(root.path(), "acct-B");
run(root.path(), &["add", "beta", "--tool", "codex"]);
let fixture = root.path().join("sessions.json");
std::fs::write(
&fixture,
serde_json::to_vec(&serde_json::json!([
{"id":"aaa111","tool":"codex","title":"fix the retry loop",
"started":"2099-01-01T00:00:00Z"}
]))
.unwrap(),
)
.unwrap();
let bin_dir = root.path().join("fakebin");
std::fs::create_dir_all(&bin_dir).unwrap();
let fake = bin_dir.join("sessionwiki");
std::fs::write(&fake, "#!/bin/sh\necho \"RESUMED $4\"\n").unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
let mut child = Command::new(bin())
.arg("ui")
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_ASSUME_TTY", "1")
.env("SWAPDEX_SESSIONWIKI_JSON", &fixture)
.env("PATH", &path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.as_mut().unwrap().write_all(b"1\n1\n").unwrap();
let out = child.wait_with_output().unwrap();
let o = String::from_utf8_lossy(&out.stdout);
assert_eq!(out.status.code().unwrap_or(-1), 0, "{o}");
assert!(
o.contains("RESUMED aaa111"),
"exec'd sessionwiki resume: {o}"
);
}
#[test]
fn ui_resume_enter_skips() {
use std::io::Write;
use std::process::Stdio;
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "alpha", "--tool", "codex"]);
seed_codex(root.path(), "acct-B");
run(root.path(), &["add", "beta", "--tool", "codex"]);
let fixture = root.path().join("sessions.json");
std::fs::write(
&fixture,
serde_json::to_vec(&serde_json::json!([
{"id":"aaa111","tool":"codex","title":"t","started":"2099-01-01T00:00:00Z"}
]))
.unwrap(),
)
.unwrap();
let mut child = Command::new(bin())
.arg("ui")
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_ASSUME_TTY", "1")
.env("SWAPDEX_SESSIONWIKI_JSON", &fixture)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.as_mut().unwrap().write_all(b"1\n\n").unwrap();
let out = child.wait_with_output().unwrap();
let o = String::from_utf8_lossy(&out.stdout);
assert_eq!(out.status.code().unwrap_or(-1), 0, "{o}");
assert!(!o.contains("RESUMED"), "no exec on skip: {o}");
}
#[test]
fn ui_empty_sessionwiki_falls_back_to_native_sessions() {
use std::io::Write;
use std::process::Stdio;
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "alpha", "--tool", "codex"]);
let fixture = root.path().join("sessions.json");
std::fs::write(&fixture, b"[]").unwrap();
let cx = root.path().join(".codex/sessions/2026/07/14");
std::fs::create_dir_all(&cx).unwrap();
std::fs::write(
cx.join("rollout-2026-07-14T09-00-00-0a000000-0000-4000-8000-0000000000dd.jsonl"),
format!(
"{}\n{}\n",
serde_json::json!({"type":"session_meta","payload":{"id":"x"}}),
serde_json::json!({"type":"event_msg","payload":{"type":"user_message",
"message":"wire up the websocket reconnect"}}),
),
)
.unwrap();
let mut child = Command::new(bin())
.arg("ui")
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_ASSUME_TTY", "1")
.env("SWAPDEX_SESSIONWIKI_JSON", &fixture)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.as_mut().unwrap().write_all(b"1\n\n").unwrap();
let out = child.wait_with_output().unwrap();
let o = String::from_utf8_lossy(&out.stdout);
assert_eq!(out.status.code().unwrap_or(-1), 0, "{o}");
assert!(
o.contains("wire up the websocket reconnect"),
"the on-disk session shows even with an empty sessionwiki index: {o}"
);
}
#[test]
fn ui_hint_survives_multibyte_session_id() {
use std::io::Write;
use std::process::Stdio;
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "alpha", "--tool", "codex"]);
seed_codex(root.path(), "acct-B");
run(root.path(), &["add", "beta", "--tool", "codex"]);
let fixture = root.path().join("sessions.json");
std::fs::write(
&fixture,
serde_json::to_vec(&serde_json::json!([
{"id":"a日本語id","tool":"codex","title":"t","started":"2099-01-01T00:00:00Z"}
]))
.unwrap(),
)
.unwrap();
let mut child = Command::new(bin())
.arg("ui")
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_ASSUME_TTY", "1")
.env("SWAPDEX_SESSIONWIKI_JSON", &fixture)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.as_mut().unwrap().write_all(b"1\n\n").unwrap();
let out = child.wait_with_output().unwrap();
assert_eq!(
out.status.code().unwrap_or(-1),
0,
"no panic on multibyte id: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn ui_hint_fallback_fires_on_first_real_switch() {
use std::io::Write;
use std::process::Stdio;
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "alpha", "--tool", "codex"]);
seed_codex(root.path(), "acct-B");
run(root.path(), &["add", "beta", "--tool", "codex"]);
let fixture = root.path().join("sessions.json");
std::fs::write(
&fixture,
serde_json::to_vec(&serde_json::json!([
{"id":"aaa111","tool":"codex","title":"old work","started":"2000-01-01T00:00:00Z"}
]))
.unwrap(),
)
.unwrap();
let mut child = Command::new(bin())
.arg("ui")
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_ASSUME_TTY", "1")
.env("SWAPDEX_SESSIONWIKI_JSON", &fixture)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.as_mut().unwrap().write_all(b"1\n\n").unwrap();
let out = child.wait_with_output().unwrap();
let o = String::from_utf8_lossy(&out.stdout);
assert!(
o.contains("recent sessions"),
"fallback hint must appear on the first real switch: {o}"
);
}
fn seed_gemini(root: &Path, sub: &str, email: &str) {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let d = root.join(".gemini");
std::fs::create_dir_all(&d).unwrap();
let payload = URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&serde_json::json!({"sub": sub, "email": email})).unwrap());
std::fs::write(
d.join("oauth_creds.json"),
serde_json::to_vec(&serde_json::json!({
"access_token":"AT-SENTINEL","refresh_token":"RT-SENTINEL",
"id_token": format!("h.{payload}.s"),
"expiry_date": 9999999999999i64,
"scope":"openid","token_type":"Bearer"}))
.unwrap(),
)
.unwrap();
std::fs::write(
d.join("google_accounts.json"),
serde_json::to_vec(&serde_json::json!({"active": email, "old": []})).unwrap(),
)
.unwrap();
chmod600(&root.join(".gemini/oauth_creds.json"));
chmod600(&root.join(".gemini/google_accounts.json"));
}
#[test]
fn gemini_add_use_roundtrip() {
let root = tempfile::tempdir().unwrap();
seed_gemini(root.path(), "sub-A", "a@gmail.com");
let (_o, e, c) = run(root.path(), &["add", "gwork", "--tool", "gemini"]);
assert_eq!(c, 0, "add failed: {e}");
seed_gemini(root.path(), "sub-B", "b@gmail.com");
run(root.path(), &["add", "ghome", "--tool", "gemini"]);
let (o, e, c) = run(root.path(), &["use", "gwork", "--tool", "gemini"]);
assert_eq!(c, 0, "use failed: {o}{e}");
let oauth: serde_json::Value = serde_json::from_slice(
&std::fs::read(root.path().join(".gemini/oauth_creds.json")).unwrap(),
)
.unwrap();
assert!(
oauth["id_token"].as_str().unwrap().contains("."),
"oauth swapped"
);
let accounts: serde_json::Value = serde_json::from_slice(
&std::fs::read(root.path().join(".gemini/google_accounts.json")).unwrap(),
)
.unwrap();
assert_eq!(
accounts["active"], "a@gmail.com",
"accounts swapped together"
);
let (ls, _e, _c) = run(root.path(), &["ls"]);
assert!(ls.contains("a@gmail.com"), "identity shown: {ls}");
assert!(ls.contains("[gemini") || ls.contains("gemini"), "{ls}");
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:?}"
);
}
}
#[test]
fn three_tool_profile_switches_together() {
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
seed_codex(root.path(), "acct-A");
seed_gemini(root.path(), "sub-A", "a@gmail.com");
run(root.path(), &["add", "all-a"]);
seed_claude(root.path(), "uuid-B", "b@x.com");
seed_codex(root.path(), "acct-B");
seed_gemini(root.path(), "sub-B", "b@gmail.com");
run(root.path(), &["add", "all-b"]);
let (o, e, c) = run(root.path(), &["use", "all-a"]);
assert_eq!(c, 0, "{o}{e}");
assert_eq!(claude_live_uuid(root.path()), "uuid-A");
let codex: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.path().join(".codex/auth.json")).unwrap())
.unwrap();
assert_eq!(codex["tokens"]["account_id"], "acct-A");
let g: serde_json::Value = serde_json::from_slice(
&std::fs::read(root.path().join(".gemini/google_accounts.json")).unwrap(),
)
.unwrap();
assert_eq!(g["active"], "a@gmail.com");
assert_eq!(o.matches("switched").count(), 3, "all three switched: {o}");
}
fn seed_antigravity(root: &Path, refresh: &str) {
let d = root.join(".gemini").join("antigravity-cli");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(
d.join("antigravity-oauth-token"),
serde_json::to_vec(&serde_json::json!({
"token": {"access_token":"AT-SENTINEL","token_type":"Bearer",
"refresh_token": refresh,
"expiry":"2026-07-06T10:09:19.638+09:00"},
"auth_method":"consumer"}))
.unwrap(),
)
.unwrap();
chmod600(&root.join(".gemini/antigravity-cli/antigravity-oauth-token"));
}
#[test]
fn antigravity_add_use_roundtrip() {
let root = tempfile::tempdir().unwrap();
seed_antigravity(root.path(), "RT-SENTINEL-A");
let (_o, e, c) = run(root.path(), &["add", "aw", "--tool", "antigravity"]);
assert_eq!(c, 0, "add failed: {e}");
seed_antigravity(root.path(), "RT-SENTINEL-B");
run(root.path(), &["add", "ah", "--tool", "antigravity"]);
let (o, e, c) = run(root.path(), &["use", "aw", "--tool", "antigravity"]);
assert_eq!(c, 0, "use failed: {o}{e}");
let tok: serde_json::Value = serde_json::from_slice(
&std::fs::read(
root.path()
.join(".gemini/antigravity-cli/antigravity-oauth-token"),
)
.unwrap(),
)
.unwrap();
assert_eq!(tok["token"]["refresh_token"], "RT-SENTINEL-A", "swapped");
let (o, _e, c) = run(root.path(), &["use", "aw", "--tool", "antigravity"]);
assert_eq!(c, 0);
assert!(o.contains("already active"), "{o}");
for args in [
vec!["ls"],
vec!["status"],
vec!["ls", "--json"],
vec!["status", "--json"],
] {
let (o, e, _c) = run(root.path(), &args);
assert!(
!o.contains("SENTINEL") && !e.contains("SENTINEL"),
"token leak in {args:?}: {o}{e}"
);
}
}
fn fake_claude(root: &Path, script: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let bin_dir = root.join("fakebin");
std::fs::create_dir_all(&bin_dir).unwrap();
let fake = bin_dir.join("claude");
let wrapped = script.replacen(
"#!/bin/sh\n",
"#!/bin/sh\ncase \"$1 $2\" in \"auth logout\") exit 0 ;; esac\n",
1,
);
std::fs::write(&fake, wrapped).unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
bin_dir
}
fn run_login_tty(root: &Path, bin_dir: &Path, args: &[&str], input: &str) -> (String, String, i32) {
use std::io::Write;
use std::process::Stdio;
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
let mut child = Command::new(bin())
.args(args)
.env("SWAPDEX_ROOT", root)
.env("SWAPDEX_ASSUME_TTY", "1")
.env("PATH", &path)
.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(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code().unwrap_or(-1),
)
}
#[test]
fn login_claude_adds_a_second_account_in_one_flow() {
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
run(root.path(), &["add", "old", "--tool", "claude"]);
let script = r#"#!/bin/sh
case "$1" in --version) echo 1.0.0; exit 0;; esac
mkdir -p "$SWAPDEX_ROOT/.claude"
cat > "$SWAPDEX_ROOT/.claude/.credentials.json" <<'CRED'
{"claudeAiOauth":{"accessToken":"AT-B","refreshToken":"RT-B","expiresAt":9999999999999,"subscriptionType":"pro"}}
CRED
cat > "$SWAPDEX_ROOT/.claude.json" <<'CFG'
{"oauthAccount":{"accountUuid":"uuid-B","emailAddress":"b@x.com","displayName":"B"},"projects":{"/keep/me":{"trust":true}}}
CFG
"#;
let bin_dir = fake_claude(root.path(), script);
let (o, e, c) = run_login_tty(
root.path(),
&bin_dir,
&["login", "newacc", "--tool", "claude"],
"y\n",
);
assert_eq!(c, 0, "{o}{e}");
assert_eq!(claude_live_uuid(root.path()), "uuid-B", "B is live: {o}");
let (names, _e, _c) = run(root.path(), &["ls", "--names"]);
assert!(names.contains("newacc"), "B saved as newacc: {names}");
assert!(names.contains("old"), "A's profile still there: {names}");
let (_o, e, c) = run(root.path(), &["use", "old"]);
assert_eq!(c, 0, "{e}");
assert_eq!(
claude_live_uuid(root.path()),
"uuid-A",
"A restored via use"
);
}
#[test]
fn login_claude_restores_original_when_no_new_signin() {
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
run(root.path(), &["add", "old", "--tool", "claude"]);
let bin_dir = fake_claude(root.path(), "#!/bin/sh\nexit 0\n");
let (o, e, c) = run_login_tty(
root.path(),
&bin_dir,
&["login", "newacc", "--tool", "claude"],
"y\n",
);
assert_eq!(c, 8, "incomplete login flow: {o}{e}");
assert_eq!(
claude_live_uuid(root.path()),
"uuid-A",
"original login restored - NEVER lost: {o}{e}"
);
}
#[test]
fn ui_post_switch_c_opens_claude() {
use std::io::Write;
use std::process::Stdio;
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "alpha"]);
seed_claude(root.path(), "uuid-B", "b@x.com");
seed_codex(root.path(), "acct-B");
run(root.path(), &["add", "beta"]);
let bin_dir = fake_claude(root.path(), "#!/bin/sh\necho CLAUDE-OPENED\n");
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
let mut child = Command::new(bin())
.arg("ui")
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_ASSUME_TTY", "1")
.env("PATH", &path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.as_mut().unwrap().write_all(b"1\nc\n").unwrap();
let out = child.wait_with_output().unwrap();
let o = String::from_utf8_lossy(&out.stdout);
assert_eq!(out.status.code().unwrap_or(-1), 0, "{o}");
assert!(
o.contains("CLAUDE-OPENED"),
"claude exec'd after switch: {o}"
);
}
#[test]
fn use_open_execs_the_tool() {
use std::process::Stdio;
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "alpha", "--tool", "codex"]);
seed_codex(root.path(), "acct-B");
use std::os::unix::fs::PermissionsExt;
let bin_dir = root.path().join("fakebin");
std::fs::create_dir_all(&bin_dir).unwrap();
let fake = bin_dir.join("codex");
std::fs::write(&fake, "#!/bin/sh\necho CODEX-OPENED\n").unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
let out = Command::new(bin())
.args(["use", "alpha", "--tool", "codex", "--open"])
.env("SWAPDEX_ROOT", root.path())
.env("PATH", &path)
.stdin(Stdio::null())
.output()
.unwrap();
let o = String::from_utf8_lossy(&out.stdout);
assert_eq!(out.status.code().unwrap_or(-1), 0, "{o}");
assert!(o.contains("switched codex"), "{o}");
assert!(o.contains("CODEX-OPENED"), "codex exec'd after switch: {o}");
}
#[test]
fn use_open_dir_launches_in_that_folder() {
use std::os::unix::fs::PermissionsExt;
use std::process::Stdio;
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "alpha", "--tool", "codex"]);
seed_codex(root.path(), "acct-B");
let bin_dir = root.path().join("fakebin");
std::fs::create_dir_all(&bin_dir).unwrap();
let fake = bin_dir.join("codex");
std::fs::write(&fake, "#!/bin/sh\necho \"OPENED-IN $(pwd)\"\n").unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
let proj = root.path().join("myproject");
std::fs::create_dir_all(&proj).unwrap();
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
let out = Command::new(bin())
.args([
"use",
"alpha",
"--tool",
"codex",
"--open",
"--dir",
proj.to_str().unwrap(),
])
.env("SWAPDEX_ROOT", root.path())
.env("PATH", &path)
.stdin(Stdio::null())
.output()
.unwrap();
let o = String::from_utf8_lossy(&out.stdout);
let proj = proj.canonicalize().unwrap();
assert!(
o.contains(&format!("OPENED-IN {}", proj.display())),
"launched in the chosen folder: {o}"
);
}
#[test]
fn post_switch_native_sessions_without_sessionwiki() {
use std::io::Write;
use std::process::Stdio;
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "alpha", "--tool", "codex"]);
seed_codex(root.path(), "acct-B");
run(root.path(), &["add", "beta", "--tool", "codex"]);
let proj_dir = root.path().join("myproj");
std::fs::create_dir_all(&proj_dir).unwrap();
let store = root.path().join(".claude/projects/-myproj");
std::fs::create_dir_all(&store).unwrap();
std::fs::write(
store.join("0a000000-0000-4000-8000-0000000000aa.jsonl"),
format!(
"{}\n",
serde_json::json!({"type":"user","cwd":proj_dir.to_str().unwrap(),
"message":{"content":[{"type":"text","text":"fix the flaky retry test"}]}}),
),
)
.unwrap();
let bin_dir = fake_claude(
root.path(),
"#!/bin/sh\necho \"RESUME-ARGS $@\"\necho \"RESUME-PWD $(pwd)\"\n",
);
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
let mut child = Command::new(bin())
.arg("ui")
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_ASSUME_TTY", "1")
.env("PATH", &path) .stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.as_mut().unwrap().write_all(b"1\n1\n").unwrap();
let out = child.wait_with_output().unwrap();
let o = String::from_utf8_lossy(&out.stdout);
assert_eq!(out.status.code().unwrap_or(-1), 0, "{o}");
assert!(
o.contains("fix the flaky retry"),
"native session listed: {o}"
);
assert!(
o.contains("RESUME-ARGS --resume 0a000000-0000-4000-8000-0000000000aa"),
"claude --resume exec'd: {o}"
);
let proj_dir = proj_dir.canonicalize().unwrap();
assert!(
o.contains(&format!("RESUME-PWD {}", proj_dir.display())),
"opened in the session's own folder: {o}"
);
}
#[test]
fn setup_add_another_asks_which_tool() {
use std::io::Write;
use std::process::Stdio;
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
let mut child = Command::new(bin())
.arg("setup")
.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"work\ny\n\nn\n")
.unwrap();
let out = child.wait_with_output().unwrap();
let o = String::from_utf8_lossy(&out.stdout);
assert!(
o.contains("which tool?") && o.contains("4) Antigravity"),
"add-another asks the tool, all four listed: {o}"
);
assert!(
!o.contains("add another Codex account"),
"the Codex-only prompt is gone: {o}"
);
}
#[test]
fn login_tool_question_reprompts_on_garbage() {
use std::io::Write;
use std::process::Stdio;
let root = tempfile::tempdir().unwrap();
let mut child = Command::new(bin())
.args(["login", "newone"])
.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"7\n\n").unwrap();
let out = child.wait_with_output().unwrap();
let o = String::from_utf8_lossy(&out.stdout);
assert_eq!(out.status.code().unwrap_or(-1), 0, "{o}");
assert!(
o.contains("pick a number between 1 and 4"),
"garbage re-prompts: {o}"
);
assert!(o.contains("cancelled"), "Enter cancels: {o}");
}
#[test]
fn switch_away_refreshes_matched_profile_snapshot() {
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"]);
run(root.path(), &["use", "work"]);
let auth = root.path().join(".codex/auth.json");
let mut v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&auth).unwrap()).unwrap();
v["tokens"]["access_token"] = "AT-ROTATED".into();
v["tokens"]["refresh_token"] = "RT-ROTATED".into();
std::fs::write(&auth, serde_json::to_string(&v).unwrap()).unwrap();
let (_o, e, c) = run(root.path(), &["use", "personal"]);
assert_eq!(c, 0, "{e}");
let (_o, e, c) = run(root.path(), &["use", "work"]);
assert_eq!(c, 0, "{e}");
let live = std::fs::read_to_string(&auth).unwrap();
assert!(
live.contains("RT-ROTATED"),
"switching back restores the ROTATED tokens, not day-one ones: {live}"
);
}
#[test]
fn login_flow_refreshes_matched_profile_from_stash() {
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
run(root.path(), &["add", "old", "--tool", "claude"]);
let cred = root.path().join(".claude/.credentials.json");
let mut v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&cred).unwrap()).unwrap();
v["claudeAiOauth"]["refreshToken"] = "RT-ROTATED".into();
std::fs::write(&cred, serde_json::to_string(&v).unwrap()).unwrap();
let script = r#"#!/bin/sh
case "$1" in --version) echo 1.0.0; exit 0;; esac
mkdir -p "$SWAPDEX_ROOT/.claude"
cat > "$SWAPDEX_ROOT/.claude/.credentials.json" <<'CRED'
{"claudeAiOauth":{"accessToken":"AT-B","refreshToken":"RT-B","expiresAt":9999999999999,"subscriptionType":"pro"}}
CRED
cat > "$SWAPDEX_ROOT/.claude.json" <<'CFG'
{"oauthAccount":{"accountUuid":"uuid-B","emailAddress":"b@x.com","displayName":"B"},"projects":{}}
CFG
"#;
let bin_dir = fake_claude(root.path(), script);
let (o, e, c) = run_login_tty(
root.path(),
&bin_dir,
&["login", "newacc", "--tool", "claude"],
"y\n",
);
assert_eq!(c, 0, "{o}{e}");
let (_o, e, c) = run(root.path(), &["use", "old"]);
assert_eq!(c, 0, "{e}");
let live = std::fs::read_to_string(&cred).unwrap();
assert!(
live.contains("RT-ROTATED"),
"profile 'old' was refreshed from the stash: {live}"
);
}
#[test]
fn store_open_tightens_loose_permissions() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
let dir = root.path().join(".local/share/swapdex/accounts/work/codex");
let file = dir.join("auth");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
run(root.path(), &["ls"]);
let dmode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
let fmode = std::fs::metadata(&file).unwrap().permissions().mode() & 0o777;
assert_eq!(dmode, 0o700, "dir tightened");
assert_eq!(fmode, 0o600, "token file tightened");
}
#[test]
fn restore_refreshes_outgoing_matched_profile() {
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"]);
run(root.path(), &["use", "work"]);
let auth = root.path().join(".codex/auth.json");
let mut v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&auth).unwrap()).unwrap();
v["tokens"]["refresh_token"] = "RT-ROTATED".into();
std::fs::write(&auth, serde_json::to_string(&v).unwrap()).unwrap();
let (_o, e, c) = run(root.path(), &["restore"]);
assert_eq!(c, 0, "{e}");
let (_o, e, c) = run(root.path(), &["use", "work"]);
assert_eq!(c, 0, "{e}");
let live = std::fs::read_to_string(&auth).unwrap();
assert!(
live.contains("RT-ROTATED"),
"restore refreshed 'work': {live}"
);
}
#[test]
fn use_noop_still_refreshes_profile() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
let auth = root.path().join(".codex/auth.json");
let mut v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&auth).unwrap()).unwrap();
v["tokens"]["refresh_token"] = "RT-ROTATED".into();
std::fs::write(&auth, serde_json::to_string(&v).unwrap()).unwrap();
let (o, _e, c) = run(root.path(), &["use", "work"]);
assert_eq!(c, 0);
assert!(o.contains("already active"), "{o}");
let stored = std::fs::read_to_string(
root.path()
.join(".local/share/swapdex/accounts/work/codex/auth"),
)
.unwrap();
assert!(
stored.contains("RT-ROTATED"),
"no-op use refreshed the snapshot: {stored}"
);
}
#[test]
fn add_update_refuses_account_change_noninteractive() {
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(), &["add", "work", "--tool", "codex", "--update"]);
assert_eq!(c, 7, "refused: {e}");
assert!(
e.contains("different account"),
"says WHY and what it holds: {e}"
);
let stored = std::fs::read_to_string(
root.path()
.join(".local/share/swapdex/accounts/work/codex/auth"),
)
.unwrap();
assert!(stored.contains("acct-A"), "unchanged: {stored}");
}
fn fake_tool(root: &Path, bin: &str, script: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let bin_dir = root.join("fakebin").join(bin);
std::fs::create_dir_all(&bin_dir).unwrap();
let fake = bin_dir.join(bin);
std::fs::write(&fake, script).unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
bin_dir
}
#[test]
fn login_gemini_adds_a_second_account_in_one_flow() {
let root = tempfile::tempdir().unwrap();
seed_gemini(root.path(), "a@x.com", "sub-A");
run(root.path(), &["add", "old", "--tool", "gemini"]);
let script = r#"#!/bin/sh
case "$1" in --version) echo 1.0.0; exit 0;; esac
mkdir -p "$SWAPDEX_ROOT/.gemini"
printf '%s' '{"access_token":"AT-B","refresh_token":"RT-B","id_token":"h.eyJlbWFpbCI6ImJAeC5jb20iLCJzdWIiOiJzdWItQiJ9.s","expiry_date":9999999999999}' > "$SWAPDEX_ROOT/.gemini/oauth_creds.json"
printf '%s' '{"active":"b@x.com"}' > "$SWAPDEX_ROOT/.gemini/google_accounts.json"
"#;
let bin_dir = fake_tool(root.path(), "gemini", script);
let (o, e, c) = run_login_tty(
root.path(),
&bin_dir,
&["login", "second", "--tool", "gemini"],
"y\n",
);
assert_eq!(c, 0, "{o}{e}");
let (names, _e, _c) = run(root.path(), &["ls", "--names"]);
assert!(names.contains("second"), "B saved: {names}");
let (_o, e, c) = run(root.path(), &["use", "old"]);
assert_eq!(c, 0, "{e}");
let creds = std::fs::read_to_string(root.path().join(".gemini/oauth_creds.json")).unwrap();
assert!(creds.contains("RT-SENTINEL"), "A restored: {creds}");
}
#[test]
fn login_antigravity_adds_a_second_account_in_one_flow() {
let root = tempfile::tempdir().unwrap();
seed_antigravity(root.path(), "RT-A");
run(root.path(), &["add", "old", "--tool", "antigravity"]);
let script = r#"#!/bin/sh
case "$1" in --version) echo 1.0.0; exit 0;; esac
mkdir -p "$SWAPDEX_ROOT/.gemini/antigravity-cli"
printf '%s' '{"token":{"access_token":"AT-B","refresh_token":"RT-B","expiry":"2027-01-01T00:00:00Z","token_type":"Bearer"},"auth_method":"consumer"}' > "$SWAPDEX_ROOT/.gemini/antigravity-cli/antigravity-oauth-token"
"#;
let bin_dir = fake_tool(root.path(), "agy", script);
let (o, e, c) = run_login_tty(
root.path(),
&bin_dir,
&["login", "second", "--tool", "antigravity"],
"y\n",
);
assert_eq!(c, 0, "{o}{e}");
let (names, _e, _c) = run(root.path(), &["ls", "--names"]);
assert!(names.contains("second") && names.contains("old"), "{names}");
let (_o, e, c) = run(root.path(), &["use", "old"]);
assert_eq!(c, 0, "{e}");
let tok = std::fs::read_to_string(
root.path()
.join(".gemini/antigravity-cli/antigravity-oauth-token"),
)
.unwrap();
assert!(tok.contains("RT-A"), "A restored: {tok}");
}
#[test]
fn login_gemini_restores_original_when_no_new_signin() {
let root = tempfile::tempdir().unwrap();
seed_gemini(root.path(), "a@x.com", "sub-A");
run(root.path(), &["add", "old", "--tool", "gemini"]);
let bin_dir = fake_tool(
root.path(),
"gemini",
"#!/bin/sh\ncase \"$1\" in --version) echo 1.0.0; exit 0;; esac\nexit 0\n",
);
let (o, e, c) = run_login_tty(
root.path(),
&bin_dir,
&["login", "second", "--tool", "gemini"],
"y\n",
);
assert_eq!(c, 8, "incomplete: {o}{e}");
let creds = std::fs::read_to_string(root.path().join(".gemini/oauth_creds.json")).unwrap();
assert!(creds.contains("RT-SENTINEL"), "A NEVER lost: {creds}");
}
#[test]
fn login_survives_sigint_and_restores() {
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
run(root.path(), &["add", "old", "--tool", "claude"]);
let script = "#!/bin/sh\ncase \"$1\" in --version) echo 1.0.0; exit 0;; esac\nkill -INT $PPID\nsleep 0.3\nexit 130\n";
let bin_dir = fake_claude(root.path(), script);
let (o, e, c) = run_login_tty(
root.path(),
&bin_dir,
&["login", "second", "--tool", "claude"],
"y\n",
);
assert_eq!(c, 8, "survived SIGINT, reported incomplete: {o}{e}");
assert!(e.contains("was restored"), "restore branch ran: {e}");
assert_eq!(
claude_live_uuid(root.path()),
"uuid-A",
"previous login restored, NOT left signed out"
);
}
#[test]
fn setup_enter_through_attaches_all_tools() {
use std::io::Write;
use std::process::Stdio;
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
seed_codex(root.path(), "acct-A");
let mut child = Command::new(bin())
.arg("setup")
.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"\n\n\nn\n")
.unwrap();
let out = child.wait_with_output().unwrap();
let o = String::from_utf8_lossy(&out.stdout);
assert!(!o.contains("replace it?"), "no replace scare: {o}");
let (l, _e, _c) = run(root.path(), &["ls"]);
assert!(
l.contains("claude-code") && l.contains("codex"),
"both tools attached to one profile: {l}"
);
}
#[test]
fn corrupt_live_claude_config_diagnosed_not_blamed_on_snapshot() {
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
run(root.path(), &["add", "alice", "--tool", "claude"]);
seed_claude(root.path(), "uuid-B", "b@x.com");
run(root.path(), &["add", "bob", "--tool", "claude"]);
std::fs::write(root.path().join(".claude.json"), b"not json{{").unwrap();
std::fs::set_permissions(
root.path().join(".claude.json"),
std::os::unix::fs::PermissionsExt::from_mode(0o600),
)
.unwrap();
let (_o, e, c) = run(root.path(), &["use", "alice", "--tool", "claude"]);
assert_ne!(c, 0);
assert!(
e.contains(".claude.json") && e.to_lowercase().contains("live"),
"blames the LIVE file, not the snapshot: {e}"
);
let (o, _e, c) = run(root.path(), &["doctor"]);
assert_eq!(c, 9, "doctor flags it: {o}");
assert!(o.contains(".claude.json"), "doctor names the file: {o}");
}
#[test]
fn add_with_corrupt_config_is_not_reported_as_not_logged_in() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join(".claude")).unwrap();
std::fs::write(
root.path().join(".claude/.credentials.json"),
br#"{"claudeAiOauth":{"accessToken":"AT","refreshToken":"RT","expiresAt":9999999999999,"subscriptionType":"max"}}"#,
)
.unwrap();
std::fs::write(root.path().join(".claude.json"), b"BROKEN { not json").unwrap();
let (_o, e, c) = run(root.path(), &["add", "p", "--tool", "claude"]);
assert_eq!(
c, 1,
"corrupt config is a hard error, not not-logged-in: {e}"
);
assert!(
!e.contains("not logged in"),
"must not claim the user is logged out when they are: {e}"
);
assert!(
e.contains(".claude.json"),
"points at the corrupt file: {e}"
);
}
#[test]
fn use_refuses_to_overwrite_a_present_uncapturable_login() {
let root = tempfile::tempdir().unwrap();
seed_gemini(root.path(), "sub-a", "a@g.com");
run(root.path(), &["add", "ga", "--tool", "gemini"]);
seed_gemini(root.path(), "sub-b", "b@g.com");
std::fs::write(
root.path().join(".gemini/google_accounts.json"),
b"NOT JSON {",
)
.unwrap();
let before = std::fs::read(root.path().join(".gemini/oauth_creds.json")).unwrap();
let (_o, e, c) = run(root.path(), &["use", "ga", "--tool", "gemini"]);
assert_ne!(c, 0, "the switch must not report success: {e}");
assert!(
e.contains("refusing to overwrite"),
"explains the refusal: {e}"
);
let after = std::fs::read(root.path().join(".gemini/oauth_creds.json")).unwrap();
assert_eq!(before, after, "the live login B is untouched (not lost)");
}
#[test]
fn use_continues_past_a_failing_tool() {
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "alice"]);
seed_claude(root.path(), "uuid-B", "b@x.com");
seed_codex(root.path(), "acct-B");
run(root.path(), &["add", "bob"]);
std::fs::write(
root.path()
.join(".local/share/swapdex/accounts/alice/codex/auth"),
b"broken{{",
)
.unwrap();
let (o, e, c) = run(root.path(), &["use", "alice"]);
assert_eq!(c, 1, "partial switch exits 1: {o}{e}");
assert_eq!(claude_live_uuid(root.path()), "uuid-A", "claude DID switch");
assert!(
e.contains("codex") && e.contains("failed"),
"summary names the failed tool: {e}"
);
assert!(e.contains("restore"), "points at the undo: {e}");
}
#[test]
fn add_reports_a_failing_tool_and_keeps_going() {
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
seed_codex(root.path(), "acct-A");
std::fs::write(root.path().join(".codex/auth.json"), b"broken{{").unwrap();
let (o, e, c) = run(root.path(), &["add", "prof"]);
assert_eq!(c, 1, "{o}{e}");
assert!(
o.contains("saved profile 'prof'"),
"claude still saved: {o}"
);
assert!(
e.contains("codex") && (e.contains("skipped") || e.contains("could not")),
"explains the skipped tool: {e}"
);
}
#[test]
fn login_reserved_name_dash_rejected() {
let root = tempfile::tempdir().unwrap();
let (_o, e, c) = run(root.path(), &["login", "-", "--tool", "codex"]);
assert_ne!(c, 0, "'-' is the toggle, never a profile name: {e}");
}
#[test]
fn use_typo_is_one_line() {
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work"]);
let (o, e, c) = run(root.path(), &["use", "zzz"]);
assert_eq!(c, 5);
assert!(
!o.contains("left unchanged") && !e.contains("left unchanged"),
"no per-tool noise for a typo: {o}{e}"
);
}
#[test]
fn multi_tool_tier_prefers_claude_over_antigravity() {
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
seed_antigravity(root.path(), "RT-A");
run(root.path(), &["add", "all2"]);
let (o, _e, _c) = run(root.path(), &["ls"]);
assert!(
o.contains("[max]"),
"claude's real plan tier, not antigravity's auth_method: {o}"
);
}
#[test]
fn rename_rewrites_timeline_attribution() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "alice", "--tool", "codex"]);
seed_codex(root.path(), "acct-B");
run(root.path(), &["add", "bob", "--tool", "codex"]);
run(root.path(), &["use", "alice"]);
run(root.path(), &["rename", "alice", "corp"]);
let tl =
std::fs::read_to_string(root.path().join(".local/share/swapdex/timeline.jsonl")).unwrap();
assert!(
!tl.contains("\"alice\"") && tl.contains("\"corp\""),
"timeline follows the rename: {tl}"
);
}
#[test]
fn ls_hides_ghost_dirs_and_unknown_tools() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "bee", "--tool", "codex"]);
std::fs::create_dir_all(root.path().join(".local/share/swapdex/accounts/ghosty")).unwrap();
std::fs::create_dir_all(
root.path()
.join(".local/share/swapdex/accounts/bee/fakedir"),
)
.unwrap();
let (o, _e, _c) = run(root.path(), &["ls"]);
assert!(!o.contains("ghosty"), "empty dir is not a profile: {o}");
assert!(!o.contains("fakedir"), "unknown subdir is not a tool: {o}");
}
#[test]
fn whitespace_only_name_rejected() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
let (_o, e, c) = run(root.path(), &["add", " ", "--tool", "codex"]);
assert_eq!(c, 2, "{e}");
}
#[test]
fn doctor_flags_loose_live_cred_perms() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
std::fs::set_permissions(
root.path().join(".codex/auth.json"),
std::fs::Permissions::from_mode(0o644),
)
.unwrap();
let (o, _e, c) = run(root.path(), &["doctor"]);
assert_eq!(c, 9, "{o}");
assert!(
o.contains("auth.json") && o.contains("chmod 600"),
"names the loose live file with the remedy: {o}"
);
}
#[test]
fn login_repoint_guard_holds_for_unreadable_snapshot() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-C");
run(root.path(), &["add", "work", "--tool", "codex"]);
std::fs::write(
root.path()
.join(".local/share/swapdex/accounts/work/codex/auth"),
b"corrupt{{",
)
.unwrap();
seed_codex(root.path(), "acct-A");
let script = r#"#!/bin/sh
case "$1" in --version) echo 1.0.0; exit 0;; esac
mkdir -p "$SWAPDEX_ROOT/.codex"
printf '%s' '{"auth_mode":"chatgpt","tokens":{"id_token":"h.eyJlbWFpbCI6ImJAeC5jb20ifQ.s","access_token":"AT-B","refresh_token":"RT-B","account_id":"acct-B"},"last_refresh":"2026-07-08T00:00:00Z"}' > "$SWAPDEX_ROOT/.codex/auth.json"
"#;
let bin_dir = fake_tool(root.path(), "codex", script);
let (o, e, _c) = run_login_tty(
root.path(),
&bin_dir,
&["login", "work", "--tool", "codex"],
"y\n\nn\n\n",
);
assert!(
o.contains("Repoint"),
"guard must fire even when the stored snapshot is unreadable: {o}{e}"
);
let stored = std::fs::read_to_string(
root.path()
.join(".local/share/swapdex/accounts/work/codex/auth"),
)
.unwrap();
assert!(
!stored.contains("acct-B"),
"refusing must keep 'work' un-repointed: {stored}"
);
}
#[test]
fn login_repoint_refusal_offers_rescue_name() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-C");
run(root.path(), &["add", "work", "--tool", "codex"]);
seed_codex(root.path(), "acct-A");
let script = r#"#!/bin/sh
case "$1" in --version) echo 1.0.0; exit 0;; esac
mkdir -p "$SWAPDEX_ROOT/.codex"
printf '%s' '{"auth_mode":"chatgpt","tokens":{"id_token":"h.eyJlbWFpbCI6ImJAeC5jb20ifQ.s","access_token":"AT-B","refresh_token":"RT-B","account_id":"acct-B"},"last_refresh":"2026-07-08T00:00:00Z"}' > "$SWAPDEX_ROOT/.codex/auth.json"
"#;
let bin_dir = fake_tool(root.path(), "codex", script);
let (o, e, c) = run_login_tty(
root.path(),
&bin_dir,
&["login", "work", "--tool", "codex"],
"y\n\nn\nrescued\n",
);
assert_eq!(c, 0, "{o}{e}");
let (names, _e, _c) = run(root.path(), &["ls", "--names"]);
assert!(
names.contains("rescued"),
"B saved under the rescue name: {names}"
);
assert!(names.contains("work"), "work untouched: {names}");
}
#[test]
fn rename_treats_ghost_dirs_consistently() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
std::fs::create_dir_all(
root.path()
.join(".local/share/swapdex/accounts/legacy/some-old-tool"),
)
.unwrap();
let (_o, e, c) = run(root.path(), &["rename", "legacy", "revived"]);
assert_eq!(c, 5, "hidden source is not a profile: {e}");
std::fs::create_dir_all(root.path().join(".local/share/swapdex/accounts/ghost/junk")).unwrap();
let (_o, e, c) = run(root.path(), &["rename", "work", "ghost"]);
assert_eq!(c, 6, "hidden target collision is a clean 'exists' (6): {e}");
}
#[test]
fn login_survives_sigquit_and_restores() {
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
run(root.path(), &["add", "old", "--tool", "claude"]);
let script = "#!/bin/sh\ncase \"$1\" in --version) echo 1.0.0; exit 0;; esac\nkill -QUIT $PPID\nsleep 0.3\nexit 131\n";
let bin_dir = fake_claude(root.path(), script);
let (o, e, c) = run_login_tty(
root.path(),
&bin_dir,
&["login", "second", "--tool", "claude"],
"y\n",
);
assert_eq!(c, 8, "survived SIGQUIT: {o}{e}");
assert_eq!(claude_live_uuid(root.path()), "uuid-A", "restored");
}
#[test]
fn future_stamped_backup_does_not_hijack_restore() {
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"]);
run(root.path(), &["use", "work"]); let ghost = root
.path()
.join(".local/share/swapdex/backups/codex/1900000000000000000");
std::fs::create_dir_all(&ghost).unwrap();
std::fs::write(
ghost.join("auth"),
serde_json::to_vec(&serde_json::json!({"auth_mode":"chatgpt",
"tokens":{"id_token":"h.eyJlbWFpbCI6ImNAei5jb20ifQ.s","access_token":"AT-C",
"refresh_token":"RT-C","account_id":"acct-C"},
"last_refresh":"2026-07-01T00:00:00Z"}))
.unwrap(),
)
.unwrap();
let (o, e, c) = run(root.path(), &["restore", "--tool", "codex"]);
assert_eq!(c, 0, "{e}");
let live = std::fs::read_to_string(root.path().join(".codex/auth.json")).unwrap();
assert!(
live.contains("acct-B"),
"restore undoes the LAST switch (B), never the future ghost (C): {o}{live}"
);
}
#[test]
fn unwritable_store_reports_the_real_problem() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
let lock = root.path().join(".local/share/swapdex/.lock");
std::fs::write(&lock, b"").ok();
std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o000)).unwrap();
let (_o, e, c) = run(root.path(), &["use", "work"]);
assert_ne!(c, 0);
assert!(
!e.contains("mid-switch"),
"EACCES is not a lock contention: {e}"
);
assert!(
e.to_lowercase().contains("perm") || e.to_lowercase().contains("writable"),
"says the real cause: {e}"
);
std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o600)).unwrap();
}
#[test]
fn legacy_whitespace_profile_is_manageable() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
std::fs::rename(
root.path().join(".local/share/swapdex/accounts/work"),
root.path().join(".local/share/swapdex/accounts/ "),
)
.unwrap();
let (_o, e, c) = run(root.path(), &["rename", " ", "fixed"]);
assert_eq!(c, 0, "legacy name must be renamable: {e}");
let (names, _e, _c) = run(root.path(), &["ls", "--names"]);
assert!(names.contains("fixed"), "{names}");
}
#[test]
fn restore_scopes_to_last_invocation_not_same_second() {
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work"]);
seed_claude(root.path(), "uuid-B", "b@x.com");
seed_codex(root.path(), "acct-B");
run(root.path(), &["add", "personal"]);
run(root.path(), &["use", "work"]);
run(root.path(), &["use", "personal", "--tool", "codex"]);
run(root.path(), &["use", "personal", "--tool", "claude"]);
let (o, e, c) = run(root.path(), &["restore"]);
assert_eq!(c, 0, "{e}");
assert!(
o.contains("claude") && !o.contains("restored codex"),
"only the LAST invocation (claude) is undone: {o}"
);
let auth = std::fs::read_to_string(root.path().join(".codex/auth.json")).unwrap();
assert!(auth.contains("acct-B"), "codex untouched: {auth}");
}
#[test]
fn bare_swapdex_pipe_is_banner_not_tui() {
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(), &[]);
assert_eq!(c, 0);
assert!(o.contains("swapdex"), "banner printed: {o}");
assert!(o.contains("active:"), "shows where you stand: {o}");
}
#[test]
fn bare_swapdex_fresh_machine_is_banner_and_does_not_create_store() {
let root = tempfile::tempdir().unwrap();
let (o, _e, c) = run(root.path(), &[]);
assert_eq!(c, 0);
assert!(o.contains("swapdex setup"), "fresh-machine hint: {o}");
assert!(
!root.path().join(".local/share/swapdex").exists(),
"a bare banner must not create the store"
);
}
#[test]
fn add_refuses_symlinked_profile_dir() {
let root = tempfile::tempdir().unwrap();
let escape = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "seed", "--tool", "codex"]);
let accounts = root.path().join(".local/share/swapdex/accounts");
std::os::unix::fs::symlink(escape.path(), accounts.join("beta")).unwrap();
let (_o, e, c) = run(root.path(), &["add", "beta", "--tool", "codex"]);
assert_ne!(c, 0, "must refuse: {e}");
assert!(
!escape.path().join("codex/auth").exists(),
"no token escaped the store into {}",
escape.path().display()
);
}
#[test]
fn login_same_account_back_does_not_save_duplicate() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
let script = r#"#!/bin/sh
case "$1" in --version) echo 1.0.0; exit 0;; logout) rm -f "$SWAPDEX_ROOT/.codex/auth.json"; exit 0;; esac
mkdir -p "$SWAPDEX_ROOT/.codex"
printf '%s' '{"auth_mode":"chatgpt","tokens":{"id_token":"h.eyJlbWFpbCI6ImFAeC5jb20ifQ.s","access_token":"AT2","refresh_token":"RT2","account_id":"acct-A"},"last_refresh":"2026-07-08T00:00:00Z"}' > "$SWAPDEX_ROOT/.codex/auth.json"
"#;
let bin_dir = fake_tool(root.path(), "codex", script);
let (o, e, c) = run_login_tty(
root.path(),
&bin_dir,
&["login", "second", "--tool", "codex"],
"y\n",
);
assert_eq!(c, 0, "{o}{e}");
assert!(
e.contains("SAME account"),
"explains the same-account outcome: {e}"
);
assert!(
e.to_lowercase().contains("browser"),
"tells how to actually switch: {e}"
);
let (names, _e, _c) = run(root.path(), &["ls", "--names"]);
assert!(
!names.contains("second"),
"no duplicate profile saved: {names}"
);
let auth = std::fs::read_to_string(root.path().join(".codex/auth.json")).unwrap();
assert!(auth.contains("acct-A"), "login restored/intact: {auth}");
}
#[test]
fn login_does_not_hold_lock_during_signin() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
seed_codex(root.path(), "acct-V");
run(root.path(), &["add", "victim", "--tool", "codex"]);
seed_codex(root.path(), "acct-A");
let script = format!(
r#"#!/bin/sh
case "$1" in --version) echo 1.0.0; exit 0;; logout) rm -f "$SWAPDEX_ROOT/.codex/auth.json"; exit 0;; esac
"{}" rename victim moved > "$SWAPDEX_ROOT/rename-rc.txt" 2>&1
echo "rc=$?" >> "$SWAPDEX_ROOT/rename-rc.txt"
mkdir -p "$SWAPDEX_ROOT/.codex"
printf '%s' '{{"auth_mode":"chatgpt","tokens":{{"id_token":"h.eyJlbWFpbCI6ImJAeC5jb20ifQ.s","access_token":"ATB","refresh_token":"RTB","account_id":"acct-B"}},"last_refresh":"2026-07-08T00:00:00Z"}}' > "$SWAPDEX_ROOT/.codex/auth.json"
"#,
bin()
);
let bin_dir = fake_tool(root.path(), "codex", &script);
let (o, e, c) = run_login_tty(
root.path(),
&bin_dir,
&["login", "second", "--tool", "codex"],
"\ny\n",
);
assert_eq!(c, 0, "{o}{e}");
let rc = std::fs::read_to_string(root.path().join("rename-rc.txt")).unwrap_or_default();
assert!(
rc.contains("rc=0"),
"rename during the sign-in succeeded (lock was free): {rc}"
);
let (names, _e, _c) = run(root.path(), &["ls", "--names"]);
assert!(
names.contains("moved") && !names.contains("victim"),
"victim was renamed mid-sign-in: {names}"
);
}
#[test]
fn codex_login_uses_device_auth_by_default() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "work", "--tool", "codex"]);
seed_codex(root.path(), "acct-A"); let script = r#"#!/bin/sh
case "$1" in --version) echo 1.0.0; exit 0;; logout) rm -f "$SWAPDEX_ROOT/.codex/auth.json"; exit 0;; esac
printf '%s\n' "$@" > "$SWAPDEX_ROOT/codex-argv.txt"
mkdir -p "$SWAPDEX_ROOT/.codex"
printf '%s' '{"auth_mode":"chatgpt","tokens":{"id_token":"h.eyJlbWFpbCI6ImJAeC5jb20ifQ.s","access_token":"AT-B","refresh_token":"RT-B","account_id":"acct-B"},"last_refresh":"2026-07-08T00:00:00Z"}' > "$SWAPDEX_ROOT/.codex/auth.json"
"#;
let bin_dir = fake_tool(root.path(), "codex", script);
let (o, e, _c) = run_login_tty(
root.path(),
&bin_dir,
&["login", "second", "--tool", "codex"],
"\ny\n",
);
let argv = std::fs::read_to_string(root.path().join("codex-argv.txt"))
.unwrap_or_else(|_| panic!("fake codex login was never spawned: {o}{e}"));
assert!(
argv.contains("login") && argv.contains("--device-auth"),
"codex login must get --device-auth by default: {argv:?}"
);
}
#[test]
fn codex_switch_is_guarded_while_a_codex_runs() {
let acct = |r: &Path| -> String {
let v: serde_json::Value =
serde_json::from_slice(&std::fs::read(r.join(".codex/auth.json")).unwrap()).unwrap();
v["tokens"]["account_id"].as_str().unwrap().to_string()
};
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_env(
root.path(),
&["use", "work", "--tool", "codex"],
&[("SWAPDEX_TEST_RUNNING", "codex")],
);
assert_ne!(c, 0, "switch must be refused while a codex runs: {o}{e}");
assert!(
format!("{o}{e}").contains("running codex session"),
"the guard message is shown: {o}{e}"
);
assert_eq!(
acct(root.path()),
"acct-B",
"the live login was NOT switched"
);
let (o2, e2, c2) = run_env(
root.path(),
&["use", "work", "--tool", "codex", "--force"],
&[("SWAPDEX_TEST_RUNNING", "codex")],
);
assert_eq!(c2, 0, "--force switches anyway: {o2}{e2}");
assert_eq!(acct(root.path()), "acct-A", "the forced switch applied");
}
#[test]
fn gemini_switch_is_guarded_while_a_gemini_runs() {
let active = |r: &Path| -> String {
let v: serde_json::Value =
serde_json::from_slice(&std::fs::read(r.join(".gemini/google_accounts.json")).unwrap())
.unwrap();
v["active"].as_str().unwrap().to_string()
};
let root = tempfile::tempdir().unwrap();
seed_gemini(root.path(), "sub-a", "a@x.com");
run(root.path(), &["add", "work", "--tool", "gemini"]);
seed_gemini(root.path(), "sub-b", "b@x.com");
let (o, e, c) = run_env(
root.path(),
&["use", "work", "--tool", "gemini"],
&[("SWAPDEX_TEST_RUNNING", "gemini")],
);
assert_ne!(c, 0, "refused while a gemini runs: {o}{e}");
assert!(
format!("{o}{e}").contains("running gemini session"),
"guard msg: {o}{e}"
);
assert_eq!(
active(root.path()),
"b@x.com",
"the live login was NOT switched"
);
let (_o, _e, c2) = run_env(
root.path(),
&["use", "work", "--tool", "gemini", "--force"],
&[("SWAPDEX_TEST_RUNNING", "gemini")],
);
assert_eq!(c2, 0, "--force switches anyway");
assert_eq!(active(root.path()), "a@x.com", "the forced switch applied");
}
#[test]
fn antigravity_switch_is_guarded_while_an_antigravity_runs() {
let rt = |r: &Path| -> String {
let v: serde_json::Value = serde_json::from_slice(
&std::fs::read(r.join(".gemini/antigravity-cli/antigravity-oauth-token")).unwrap(),
)
.unwrap();
v["token"]["refresh_token"].as_str().unwrap().to_string()
};
let root = tempfile::tempdir().unwrap();
seed_antigravity(root.path(), "RT-A");
run(root.path(), &["add", "work", "--tool", "antigravity"]);
seed_antigravity(root.path(), "RT-B");
let (o, e, c) = run_env(
root.path(),
&["use", "work", "--tool", "antigravity"],
&[("SWAPDEX_TEST_RUNNING", "antigravity")],
);
assert_ne!(c, 0, "refused while an antigravity runs: {o}{e}");
assert!(
format!("{o}{e}").contains("running antigravity session"),
"guard msg: {o}{e}"
);
assert_eq!(rt(root.path()), "RT-B", "the live login was NOT switched");
let (_o, _e, c2) = run_env(
root.path(),
&["use", "work", "--tool", "antigravity", "--force"],
&[("SWAPDEX_TEST_RUNNING", "antigravity")],
);
assert_eq!(c2, 0, "--force switches anyway");
assert_eq!(rt(root.path()), "RT-A", "the forced switch applied");
}
#[test]
fn login_claude_signs_out_locally_without_revoking() {
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
run(root.path(), &["add", "old", "--tool", "claude"]);
let script = r#"#!/bin/sh
case "$1 $2" in "auth logout") echo logout >> "$SWAPDEX_ROOT/authcalls.txt"; exit 0 ;; esac
case "$1" in --version) echo 1.0.0; exit 0;; esac
mkdir -p "$SWAPDEX_ROOT/.claude"
printf '%s' '{"claudeAiOauth":{"accessToken":"AT-B","refreshToken":"RT-B","expiresAt":9999999999999,"subscriptionType":"pro"}}' > "$SWAPDEX_ROOT/.claude/.credentials.json"
printf '%s' '{"oauthAccount":{"accountUuid":"uuid-B","emailAddress":"b@x.com","displayName":"B"},"projects":{}}' > "$SWAPDEX_ROOT/.claude.json"
"#;
use std::os::unix::fs::PermissionsExt;
let bin_dir = root.path().join("rawbin");
std::fs::create_dir_all(&bin_dir).unwrap();
std::fs::write(bin_dir.join("claude"), script).unwrap();
std::fs::set_permissions(
bin_dir.join("claude"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
let (o, e, c) = run_login_tty(
root.path(),
&bin_dir,
&["login", "second", "--tool", "claude"],
"y\n",
);
assert_eq!(c, 0, "{o}{e}");
let calls = std::fs::read_to_string(root.path().join("authcalls.txt")).unwrap_or_default();
assert!(
!calls.contains("logout"),
"swapdex must not run `claude auth logout` - it revokes server-side: {calls:?}"
);
let (names, _e, _c) = run(root.path(), &["ls", "--names"]);
assert!(names.contains("second"), "B saved: {names}");
assert!(names.contains("old"), "old profile preserved: {names}");
let old_cred = std::fs::read_to_string(
root.path()
.join(".local/share/swapdex/accounts/old/claude-code/credentials"),
)
.unwrap_or_default();
assert!(
old_cred.contains("\"accessToken\":\"AT\""),
"old account's saved token is untouched (not revoked): {old_cred}"
);
}
fn write_fake_curl(dir: &Path) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let p = dir.join("fake-curl");
std::fs::write(
&p,
r#"#!/bin/sh
cfg=$(cat)
tok=$(printf '%s' "$cfg" | sed -n 's/.*Authorization: Bearer \([^"]*\)".*/\1/p')
case "$tok" in
AT) printf '{"five_hour":{"utilization":25.0,"resets_at":1893456000},"seven_day":{"utilization":50.0}}\n200' ;;
*) printf '{"type":"error","error":{"type":"authentication_error"}}\n401' ;;
esac
"#,
)
.unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
p
}
fn seed_claude_profile(root: &Path, name: &str, uuid: &str, email: &str, token: &str) {
let d = root
.join(".local/share/swapdex/accounts")
.join(name)
.join("claude-code");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(
d.join("credentials"),
format!(r#"{{"claudeAiOauth":{{"accessToken":"{token}","refreshToken":"R"}}}}"#),
)
.unwrap();
std::fs::write(
d.join("oauth_account"),
format!(r#"{{"accountUuid":"{uuid}","emailAddress":"{email}"}}"#),
)
.unwrap();
chmod600(&d.join("credentials"));
chmod600(&d.join("oauth_account"));
}
#[test]
fn quota_json_reports_accounts_with_clean_names() {
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
run(root.path(), &["add", "main", "--tool", "claude"]);
seed_claude_profile(root.path(), "backup", "uuid-B", "b@x.com", "AT-B");
let curl = write_fake_curl(root.path());
let (o, e, c) = run_env(
root.path(),
&["quota", "--json"],
&[("SWAPDEX_CURL", curl.to_str().unwrap())],
);
assert_eq!(c, 0, "{o}{e}");
let v: serde_json::Value = serde_json::from_str(o.trim()).unwrap();
assert!(v["offline"].is_null(), "not offline: {v}");
let accounts = v["accounts"].as_array().unwrap();
assert_eq!(accounts.len(), 2, "{v}");
assert_eq!(accounts[0]["name"], "main", "no ' (active)' suffix: {v}");
assert_eq!(accounts[0]["active"], true);
assert_eq!(accounts[0]["status"], "ok");
assert_eq!(accounts[0]["five_hour"]["remaining_pct"], 75.0);
assert_eq!(accounts[1]["name"], "backup");
assert_eq!(accounts[1]["status"], "expired");
}
fn seed_slot(root: &Path, name: &str, dir: &Path, token: &str, email: &str) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(
dir.join(".credentials.json"),
format!(r#"{{"claudeAiOauth":{{"accessToken":"{token}","refreshToken":"R"}}}}"#),
)
.unwrap();
std::fs::write(
dir.join(".claude.json"),
format!(r#"{{"oauthAccount":{{"accountUuid":"u-{name}","emailAddress":"{email}"}}}}"#),
)
.unwrap();
let store = root.join(".local/share/swapdex");
std::fs::create_dir_all(&store).unwrap();
let file = store.join("slots.json");
let mut list: Vec<serde_json::Value> = std::fs::read(&file)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
list.push(serde_json::json!({
"name": name,
"id": format!("id-{name}"),
"config_dir": dir,
"adopted": true,
}));
std::fs::write(&file, serde_json::to_vec(&list).unwrap()).unwrap();
}
#[test]
fn a_slot_outranks_a_saved_snapshot_of_the_same_name() {
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
run(root.path(), &["add", "main", "--tool", "claude"]);
seed_claude_profile(root.path(), "backup", "uuid-B", "b@x.com", "DEAD-TOKEN");
seed_slot(
root.path(),
"backup",
&root.path().join("slot-backup"),
"AT",
"b@x.com",
);
let curl = write_fake_curl(root.path());
let (o, e, c) = run_env(
root.path(),
&["quota", "--json"],
&[("SWAPDEX_CURL", curl.to_str().unwrap())],
);
assert_eq!(c, 0, "{o}{e}");
let v: serde_json::Value = serde_json::from_str(o.trim()).unwrap();
let accounts = v["accounts"].as_array().unwrap();
assert_eq!(accounts.len(), 2, "one row per account, not two: {v}");
let backup = accounts
.iter()
.find(|a| a["name"] == "backup")
.unwrap_or_else(|| panic!("no backup row: {v}"));
assert_eq!(
backup["status"], "ok",
"the slot's live token answered, not the snapshot's dead one: {v}"
);
assert_eq!(backup["five_hour"]["remaining_pct"], 75.0, "{v}");
}
#[test]
fn quota_unusable_token_does_not_abort_the_run() {
let root = tempfile::tempdir().unwrap();
seed_claude(root.path(), "uuid-A", "a@x.com");
run(root.path(), &["add", "main", "--tool", "claude"]);
seed_claude_profile(root.path(), "backup", "uuid-B", "b@x.com", "AT-B");
std::fs::write(
root.path().join(".claude/.credentials.json"),
r#"{"claudeAiOauth":{"accessToken":"bad\"tok","refreshToken":"R"}}"#,
)
.unwrap();
let curl = write_fake_curl(root.path());
let (o, e, c) = run_env(
root.path(),
&["quota", "--json"],
&[("SWAPDEX_CURL", curl.to_str().unwrap())],
);
assert_eq!(c, 0, "{o}{e}");
let v: serde_json::Value = serde_json::from_str(o.trim()).unwrap();
assert!(
v["offline"].is_null(),
"a local token fault must not report the network down: {v}"
);
let accounts = v["accounts"].as_array().unwrap();
assert_eq!(accounts.len(), 2, "both accounts reported: {v}");
assert_eq!(accounts[0]["status"], "offline");
assert!(
accounts[0]["detail"].as_str().unwrap().contains("unusable"),
"{v}"
);
assert_eq!(accounts[1]["status"], "expired");
}
#[test]
fn plain_menu_offers_only_the_profiles_tools() {
use std::io::Write;
use std::process::Stdio;
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
run(root.path(), &["add", "onlycodex", "--tool", "codex"]);
seed_codex(root.path(), "acct-B");
run(root.path(), &["add", "other", "--tool", "codex"]);
use std::os::unix::fs::PermissionsExt;
let bin_dir = root.path().join("fakebin");
std::fs::create_dir_all(&bin_dir).unwrap();
for (t, msg) in [("claude", "LAUNCHED-CLAUDE"), ("codex", "LAUNCHED-CODEX")] {
let f = bin_dir.join(t);
std::fs::write(&f, format!("#!/bin/sh\necho {msg}\n")).unwrap();
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
let mut child = Command::new(bin())
.arg("ui")
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_ASSUME_TTY", "1")
.env("PATH", &path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.as_mut().unwrap().write_all(b"1\nc\n").unwrap();
let out = child.wait_with_output().unwrap();
let o = String::from_utf8_lossy(&out.stdout);
assert!(o.contains("x new codex"), "offers codex: {o}");
assert!(
!o.contains("c new claude"),
"does NOT offer claude for a codex-only profile: {o}"
);
assert!(
!o.contains("LAUNCHED-CLAUDE"),
"pressing 'c' must not launch claude: {o}"
);
}
#[test]
fn setup_continues_past_an_unreadable_tool() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-A");
std::fs::create_dir_all(root.path().join(".claude")).unwrap();
std::fs::write(root.path().join(".claude/.credentials.json"), b"NOT JSON {").unwrap();
let (o, c) = run_setup(root.path(), "codexname\nskip\n");
assert_eq!(c, 0, "{o}");
let (names, _e, _c) = run(root.path(), &["ls", "--names"]);
assert!(
names.contains("codexname"),
"codex saved despite the claude error: {names} / {o}"
);
}
fn seed_two_claude_profiles(root: &Path) {
seed_claude(root, "uuid-A", "a@x.com");
run(root, &["add", "alpha", "--tool", "claude"]);
seed_claude(root, "uuid-B", "b@x.com");
run(root, &["add", "beta", "--tool", "claude"]);
}
#[test]
fn claude_switch_is_refused_when_a_session_holds_this_slot() {
let root = tempfile::tempdir().unwrap();
seed_two_claude_profiles(root.path());
let (_o, e, c) = run_env(
root.path(),
&["use", "alpha", "--tool", "claude"],
&[("SWAPDEX_TEST_CLAUDE_GUARD", "same-slot")],
);
assert_eq!(c, 1, "refused switch exits 1: {e}");
assert!(
e.contains("running on THIS login slot"),
"explains the refusal: {e}"
);
let (o, _e, _c) = run(root.path(), &["status"]);
assert!(o.contains("b@x.com"), "live login unchanged: {o}");
}
#[test]
fn claude_switch_force_overrides_the_running_session_guard() {
let root = tempfile::tempdir().unwrap();
seed_two_claude_profiles(root.path());
let (o, e, c) = run_env(
root.path(),
&["use", "alpha", "--tool", "claude", "--force"],
&[("SWAPDEX_TEST_CLAUDE_GUARD", "same-slot")],
);
assert_eq!(c, 0, "--force switches through the guard: {o}{e}");
let (s, _e, _c) = run(root.path(), &["status"]);
assert!(
s.contains("a@x.com"),
"switched to alpha despite the session: {s}"
);
}
#[test]
fn claude_switch_fails_closed_when_a_running_session_slot_is_unknown() {
let root = tempfile::tempdir().unwrap();
seed_two_claude_profiles(root.path());
let (_o, e, c) = run_env(
root.path(),
&["use", "alpha", "--tool", "claude"],
&[("SWAPDEX_TEST_CLAUDE_GUARD", "unknown")],
);
assert_eq!(c, 1, "unknown slot fails closed: {e}");
assert!(
e.contains("could not read which login slot"),
"fail-closed message: {e}"
);
let (o, _e, _c) = run(root.path(), &["status"]);
assert!(o.contains("b@x.com"), "live login unchanged: {o}");
}
#[test]
fn switching_a_codex_slot_repoints_instead_of_copying() {
let root = tempfile::tempdir().unwrap();
seed_codex(root.path(), "acct-LIVE");
let live = root.path().join(".codex/auth.json");
let before = std::fs::read(&live).unwrap();
let (o, e, c) = run(
root.path(),
&["run", "other", "--tool", "codex", "--no-launch"],
);
assert_eq!(c, 0, "{o}{e}");
let (o, e, c) = run(root.path(), &["use", "other", "--tool", "codex"]);
assert_eq!(c, 0, "{o}{e}");
assert_eq!(
std::fs::read(&live).unwrap(),
before,
"switching a slot must not write the live auth.json"
);
let store = root.path().join(".local/share/swapdex");
let ptr = std::fs::read_to_string(store.join("active-codex")).expect("codex pointer");
assert!(!ptr.trim().is_empty(), "pointer names the slot dir");
assert!(
!store.join("active-claude").exists(),
"switching Codex must not move where Claude launches"
);
}
#[test]
fn a_slot_switch_says_whether_anything_running_actually_moved() {
use swapdex::commands::switch_outcome_line;
let without = switch_outcome_line("claude-code", "work", false, true);
assert!(
without.contains("NEXT claude you start"),
"says when it applies: {without}"
);
assert!(
without.contains("already open keeps the account"),
"and what does NOT change: {without}"
);
assert!(
without.contains("swapdex proxy"),
"and the one step that would move it: {without}"
);
assert!(
!without.contains("serves this session"),
"never claims the running session moved: {without}"
);
let with = switch_outcome_line("claude-code", "work", true, true);
assert!(
with.contains("serves this session from the next turn"),
"with a proxy it genuinely does: {with}"
);
assert!(
!without.contains("stay with the account"),
"no stale warning once history is shared: {without}"
);
let unshared = switch_outcome_line("claude-code", "work", false, false);
assert!(
unshared.contains("share-history"),
"an un-repaired account is told how to fix it: {unshared}"
);
let codex = switch_outcome_line("codex", "work", false, true);
assert!(codex.contains("NEXT codex you start"), "{codex}");
assert!(codex.contains("swapdex proxy --tool codex"), "{codex}");
}
#[test]
fn renaming_a_codex_slot_actually_renames_it() {
let dir = tempfile::tempdir().unwrap();
let paths = swapdex::paths::Paths::rooted(dir.path());
let mut slots = swapdex::slots::Slots::open_for(&paths, "codex").unwrap();
slots.create("A").unwrap();
assert_eq!(
swapdex::commands::rename(&paths, "A", "codex-main").unwrap(),
0
);
let after = swapdex::slots::Slots::open_for(&paths, "codex").unwrap();
assert!(
after.get("codex-main").is_some(),
"the slot took the new name"
);
assert!(
after.get("A").is_none(),
"and does not answer to the old one"
);
}