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