use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_swapdex")
}
fn fake_claude(root: &Path) -> std::path::PathBuf {
let dir = root.join("fakebin");
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("claude");
std::fs::write(
&f,
"#!/bin/sh\necho \"CFG=$CLAUDE_CONFIG_DIR\"\necho \"SECURE=${CLAUDE_SECURESTORAGE_CONFIG_DIR-unset}\"\necho \"ARGS=$*\"\n",
)
.unwrap();
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
dir
}
#[test]
fn run_launches_claude_in_the_accounts_slot() {
let root = tempfile::tempdir().unwrap();
let bin_dir = fake_claude(root.path());
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
let out = Command::new(bin())
.args(["run", "work"])
.env("SWAPDEX_ROOT", root.path())
.env("PATH", &path)
.output()
.unwrap();
let o = String::from_utf8_lossy(&out.stdout);
let slots = root.path().join(".local/share/swapdex/slots");
assert!(
o.lines()
.any(|l| l.starts_with("CFG=") && l.contains(slots.to_str().unwrap())),
"claude launched with the slot as CLAUDE_CONFIG_DIR: {o}"
);
}
#[test]
fn managed_claude_run_clears_a_conflicting_secure_storage_override() {
let root = tempfile::tempdir().unwrap();
let bin_dir = fake_claude(root.path());
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
let conflicting = root.path().join("different-claude-login");
let out = Command::new(bin())
.args(["run", "work"])
.env("SWAPDEX_ROOT", root.path())
.env("PATH", &path)
.env("CLAUDE_SECURESTORAGE_CONFIG_DIR", &conflicting)
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "{stdout}");
assert!(
stdout.lines().any(|line| line == "SECURE=unset"),
"managed Claude must use the selected slot's config: {stdout}"
);
}
#[test]
fn run_forwards_extra_args_after_dash_dash() {
let root = tempfile::tempdir().unwrap();
let bin_dir = fake_claude(root.path());
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
let out = Command::new(bin())
.args(["run", "work", "--", "--resume", "abc"])
.env("SWAPDEX_ROOT", root.path())
.env("PATH", &path)
.output()
.unwrap();
let o = String::from_utf8_lossy(&out.stdout);
assert!(
o.lines()
.any(|l| l.starts_with("ARGS=") && l.contains("--resume abc")),
"extra args are forwarded to claude: {o}"
);
}
#[test]
fn slots_lists_created_slots() {
let root = tempfile::tempdir().unwrap();
let bin_dir = fake_claude(root.path());
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
Command::new(bin())
.args(["run", "work"])
.env("SWAPDEX_ROOT", root.path())
.env("PATH", &path)
.output()
.unwrap();
let out = Command::new(bin())
.args(["slots"])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
let o = String::from_utf8_lossy(&out.stdout);
assert!(o.contains("work"), "the slot is listed: {o}");
}
#[test]
fn slots_empty_state_is_friendly() {
let root = tempfile::tempdir().unwrap();
let out = Command::new(bin())
.args(["slots"])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
let o = String::from_utf8_lossy(&out.stdout);
assert!(
o.to_lowercase().contains("no accounts yet"),
"empty-state hint: {o}"
);
assert!(!o.to_lowercase().contains("slot"), "no jargon: {o}");
}
fn run_in(root: &Path, args: &[&str], path_env: &str) -> String {
let home = root.join("home");
std::fs::create_dir_all(&home).unwrap();
let out = Command::new(bin())
.args(args)
.env("SWAPDEX_ROOT", root)
.env("PATH", path_env)
.env("HOME", &home)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).into_owned()
}
#[test]
fn shim_makes_plain_claude_follow_use() {
let root = tempfile::tempdir().unwrap();
let bin_dir = fake_claude(root.path());
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
run_in(root.path(), &["run", "work"], &path);
let used = run_in(root.path(), &["use", "work"], &path);
assert!(
used.contains("default claude account -> work"),
"use repoints: {used}"
);
run_in(root.path(), &["serve", "--off"], &path);
let installed = run_in(root.path(), &["shim"], &path);
assert!(
installed.contains("installed the claude shim"),
"{installed}"
);
let home = root.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let shim = root.path().join(".local/share/swapdex/bin/claude");
let out = Command::new(&shim)
.env("SWAPDEX_ROOT", root.path())
.env("PATH", &path)
.env("HOME", &home)
.env_remove("CLAUDE_CONFIG_DIR")
.env_remove("CODEX_HOME")
.output()
.unwrap();
let o = String::from_utf8_lossy(&out.stdout);
let slots = root.path().join(".local/share/swapdex/slots");
assert!(
o.lines()
.any(|l| l.starts_with("CFG=") && l.contains(slots.to_str().unwrap())),
"the shim launched claude in the default account's slot: {o}"
);
if let Ok(marker) = std::fs::read_to_string(root.path().join(".local/share/swapdex/proxy")) {
if let Some(pid) = marker
.split_whitespace()
.next()
.and_then(|p| p.parse::<i32>().ok())
{
unsafe { libc::kill(pid, libc::SIGTERM) };
}
}
}
#[test]
fn use_on_a_slot_does_not_touch_the_copy_model_credentials() {
let root = tempfile::tempdir().unwrap();
let bin_dir = fake_claude(root.path());
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
run_in(root.path(), &["run", "work"], &path);
run_in(root.path(), &["use", "work"], &path);
assert!(
!root.path().join(".claude/.credentials.json").exists(),
"slot use writes no credential"
);
let ptr =
std::fs::read_to_string(root.path().join(".local/share/swapdex/active-claude")).unwrap();
assert!(ptr.contains("/slots/"), "pointer points at a slot: {ptr}");
}
#[test]
fn adopt_registers_an_existing_config_dir() {
let root = tempfile::tempdir().unwrap();
let existing = root.path().join("dot-claude-company");
std::fs::create_dir_all(&existing).unwrap();
let out = Command::new(bin())
.args(["adopt", "company", existing.to_str().unwrap()])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
let o = String::from_utf8_lossy(&out.stdout);
assert!(o.contains("registered 'company'"), "{o}");
let listed = Command::new(bin())
.args(["slots"])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
assert!(String::from_utf8_lossy(&listed.stdout).contains("company"));
}
fn seed_copy_profile(root: &Path, name: &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"),
br#"{"claudeAiOauth":{"accessToken":"A","refreshToken":"R"}}"#,
)
.unwrap();
std::fs::write(
d.join("oauth_account"),
br#"{"accountUuid":"u","emailAddress":"a@x.com"}"#,
)
.unwrap();
}
#[test]
fn migrate_gives_each_legacy_profile_a_slot() {
let root = tempfile::tempdir().unwrap();
seed_copy_profile(root.path(), "work");
seed_copy_profile(root.path(), "home");
let out = Command::new(bin())
.args(["migrate"])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
let o = String::from_utf8_lossy(&out.stdout);
assert!(
o.contains("work") && o.contains("home"),
"migrated both: {o}"
);
let listed = Command::new(bin())
.args(["slots"])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
let l = String::from_utf8_lossy(&listed.stdout);
assert!(
l.contains("work") && l.contains("home"),
"listed as slots: {l}"
);
let again = Command::new(bin())
.args(["migrate"])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&again.stdout)
.to_lowercase()
.contains("nothing to migrate"),
"idempotent"
);
}
#[test]
fn migrate_renames_a_legacy_profile_whose_name_no_creator_accepts() {
let root = tempfile::tempdir().unwrap();
seed_copy_profile(root.path(), "-");
seed_copy_profile(root.path(), "work");
let out = Command::new(bin())
.args(["migrate"])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
let o = String::from_utf8_lossy(&out.stdout);
let e = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(0), "migrate failed: {o}{e}");
let listed = Command::new(bin())
.args(["slots"])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
let l = String::from_utf8_lossy(&listed.stdout);
assert!(l.contains("work"), "later profile was skipped: {l}");
let rows: Vec<serde_json::Value> =
std::fs::read(root.path().join(".local/share/swapdex/slots.json"))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
assert!(
rows.iter().any(|r| r["name"] == "account"),
"the refused name was not renamed: {rows:?}"
);
}
#[test]
fn the_sign_in_line_migrate_prints_can_be_run() {
let root = tempfile::tempdir().unwrap();
seed_copy_profile(root.path(), "-x");
let out = Command::new(bin())
.args(["migrate"])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
let o = String::from_utf8_lossy(&out.stdout);
let e = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(0), "migrate failed: {o}{e}");
let line = o
.lines()
.map(str::trim)
.find(|l| l.starts_with("swapdex run "))
.unwrap_or_else(|| panic!("migrate printed no sign-in line: {o}"));
let args: Vec<&str> = line.split_whitespace().skip(1).collect();
let ran = Command::new(bin())
.args(&args)
.arg("--no-launch")
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
assert_eq!(
ran.status.code(),
Some(0),
"the line migrate printed does not run: `{line}`\n{}{}",
String::from_utf8_lossy(&ran.stdout),
String::from_utf8_lossy(&ran.stderr)
);
}
#[test]
fn doctor_reports_slots_default_and_shim() {
let root = tempfile::tempdir().unwrap();
let bin_dir = fake_claude(root.path());
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
run_in(root.path(), &["run", "work"], &path);
run_in(root.path(), &["use", "work"], &path);
let out = Command::new(bin())
.args(["doctor"])
.env("SWAPDEX_ROOT", root.path())
.env("PATH", &path)
.output()
.unwrap();
let o = String::from_utf8_lossy(&out.stdout);
assert!(
o.contains("slots") && o.contains("account(s)"),
"slots line: {o}"
);
assert!(
o.contains("default") && o.contains("work"),
"default line: {o}"
);
assert!(o.contains("shim"), "shim line: {o}");
}
#[test]
fn new_slot_symlinks_shared_config_from_bare_claude() {
let root = tempfile::tempdir().unwrap();
let bare = root.path().join(".claude");
std::fs::create_dir_all(&bare).unwrap();
std::fs::write(bare.join("settings.json"), b"{\"theme\":\"dark\"}").unwrap();
std::fs::write(bare.join("CLAUDE.md"), b"# global rules").unwrap();
let bin_dir = fake_claude(root.path());
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
run_in(root.path(), &["run", "work"], &path);
let slots = root.path().join(".local/share/swapdex/slots");
let slot = std::fs::read_dir(&slots)
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
assert_eq!(
std::fs::read_to_string(slot.join("settings.json")).unwrap(),
"{\"theme\":\"dark\"}",
"settings shared into the slot"
);
assert!(slot.join("CLAUDE.md").exists(), "global memory shared");
assert!(
std::fs::symlink_metadata(slot.join("settings.json"))
.unwrap()
.file_type()
.is_symlink(),
"shared via symlink, not a copy"
);
}
#[test]
fn onboard_registers_config_dirs_and_migrates_profiles() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join(".claude-company")).unwrap();
seed_copy_profile(root.path(), "work");
let out = Command::new(bin())
.args(["onboard"])
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_ASSUME_TTY", "1")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn_with_input("y\ny\nn\n");
let listed = Command::new(bin())
.args(["slots"])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
let l = String::from_utf8_lossy(&listed.stdout);
assert!(
l.contains("company"),
"adopted the config dir: {l} / onboard out: {out}"
);
assert!(l.contains("work"), "migrated the legacy profile: {l}");
}
trait SpawnWithInput {
fn spawn_with_input(&mut self, input: &str) -> String;
}
impl SpawnWithInput for Command {
fn spawn_with_input(&mut self, input: &str) -> String {
use std::io::Write;
let mut child = self.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()
}
}
#[test]
fn onboard_marks_itself_done_so_it_does_not_nag() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join(".claude-company")).unwrap();
let marker = root.path().join(".local/share/swapdex/onboarded");
assert!(!marker.exists());
Command::new(bin())
.args(["onboard"])
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_ASSUME_TTY", "1")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn_with_input("n\nn\n");
assert!(marker.exists(), "onboarding marks itself done");
}
#[test]
fn sync_mcp_shares_servers_into_slots_preserving_identity() {
let root = tempfile::tempdir().unwrap();
std::fs::write(
root.path().join(".claude.json"),
br#"{"oauthAccount":{"emailAddress":"bare@x.com"},"mcpServers":{"ctx7":{"command":"c"}}}"#,
)
.unwrap();
let bin_dir = fake_claude(root.path());
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
run_in(root.path(), &["run", "work"], &path);
let slots = root.path().join(".local/share/swapdex/slots");
let slot = std::fs::read_dir(&slots)
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
std::fs::write(
slot.join(".claude.json"),
br#"{"oauthAccount":{"emailAddress":"work@x.com"},"mcpServers":{}}"#,
)
.unwrap();
let out = Command::new(bin())
.args(["sync-mcp"])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&out.stdout).contains("1 MCP server"),
"{}",
String::from_utf8_lossy(&out.stdout)
);
let cfg: serde_json::Value =
serde_json::from_slice(&std::fs::read(slot.join(".claude.json")).unwrap()).unwrap();
assert!(
cfg["mcpServers"]["ctx7"].is_object(),
"shared MCP landed in the slot"
);
assert_eq!(
cfg["oauthAccount"]["emailAddress"], "work@x.com",
"slot's own account preserved"
);
}
#[test]
fn doctor_flags_slots_without_login_and_stale_logins() {
let root = tempfile::tempdir().unwrap();
let bin_dir = fake_claude(root.path());
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
for name in ["empty", "old", "recent", "corrupt"] {
run_in(root.path(), &["run", name], &path);
}
let listing = run_in(root.path(), &["slots"], &path);
let dir_of = |name: &str| -> std::path::PathBuf {
listing
.lines()
.find_map(|l| {
l.trim()
.strip_prefix(name)
.map(|rest| std::path::PathBuf::from(rest.trim()))
})
.unwrap_or_else(|| panic!("slot '{name}' in listing: {listing}"))
};
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let creds = |expires_ms: i64| {
format!(
"{{\"claudeAiOauth\":{{\"accessToken\":\"AT\",\"refreshToken\":\"RT\",\
\"expiresAt\":{expires_ms}}}}}"
)
};
std::fs::write(
dir_of("old").join(".credentials.json"),
creds(now_ms - 40 * 86_400_000),
)
.unwrap();
std::fs::write(
dir_of("recent").join(".credentials.json"),
creds(now_ms - 2 * 3_600_000),
)
.unwrap();
std::fs::write(dir_of("corrupt").join(".credentials.json"), b"not json").unwrap();
{
use std::os::unix::fs::PermissionsExt;
for n in ["old", "recent", "corrupt"] {
let f = dir_of(n).join(".credentials.json");
if f.exists() {
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o600)).unwrap();
}
}
}
let out = Command::new(bin())
.args(["doctor"])
.env("SWAPDEX_ROOT", root.path())
.env("PATH", bin_dir.display().to_string())
.output()
.unwrap();
let o = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "informational, stays exit 0: {o}");
assert!(
o.contains("slot:empty") && o.contains("swapdex run empty"),
"never-signed-in slot named with the run remedy: {o}"
);
assert!(
o.contains("slot:old") && o.contains("swapdex run old"),
"long-idle slot named with the run remedy: {o}"
);
assert!(
!o.contains("slot:recent"),
"routinely-expired slot is not flagged: {o}"
);
assert!(
!o.contains("slot:corrupt"),
"a PRESENT but unparseable credential is not 'no login yet' - doctor \
only flags what it can determine: {o}"
);
}
#[test]
fn doctor_detects_shim_bypassed_and_active() {
let root = tempfile::tempdir().unwrap();
let bin_dir = fake_claude(root.path());
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
run_in(root.path(), &["run", "work"], &path);
run_in(root.path(), &["shim"], &path);
let out = run_in(root.path(), &["doctor"], &path);
assert!(
out.contains("not on THIS shell's PATH") && out.contains("open a new terminal"),
"a shell predating the profile edit is not a broken setup: {out}"
);
assert!(
!out.contains("shim problem"),
"and it is not counted as a fault: {out}"
);
let profile = root.path().join(".bashrc");
if profile.exists() {
std::fs::write(&profile, "").unwrap();
}
let out = run_in(root.path(), &["doctor"], &path);
assert!(
out.contains("NOT taking effect") && out.contains("PATH"),
"an unconfigured shim is still called out with the PATH fix: {out}"
);
let shim_first = format!(
"{}:{}",
root.path().join(".local/share/swapdex/bin").display(),
path
);
let out = run_in(root.path(), &["doctor"], &shim_first);
assert!(
out.contains("shim active"),
"engaged shim reported active: {out}"
);
}
#[test]
fn auto_setting_round_trips_and_rejects_nonsense() {
let root = tempfile::tempdir().unwrap();
let path = std::env::var("PATH").unwrap_or_default();
assert!(
run_in(root.path(), &["auto"], &path).contains("off"),
"off until asked for"
);
assert!(run_in(root.path(), &["auto", "on"], &path).contains("on"));
assert!(
run_in(root.path(), &["auto"], &path).contains("on"),
"the setting persisted"
);
assert!(run_in(root.path(), &["auto", "off"], &path).contains("off"));
let out = Command::new(bin())
.args(["auto", "sometimes"])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2), "a bad value is refused");
}
#[test]
fn names_lists_the_accounts_use_accepts() {
let root = tempfile::tempdir().unwrap();
let dir = root.path().join("slotdir");
std::fs::create_dir_all(&dir).unwrap();
seed_copy_profile(root.path(), "saved");
let path = std::env::var("PATH").unwrap_or_default();
run_in(
root.path(),
&["adopt", "slotonly", dir.to_str().unwrap()],
&path,
);
let used = Command::new(bin())
.args(["use", "slotonly"])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
assert_eq!(used.status.code(), Some(0), "the slot account is usable");
let names = run_in(root.path(), &["ls", "--names"], &path);
assert!(names.contains("saved"), "the profile: {names}");
assert!(
names.lines().any(|l| l.trim() == "slotonly"),
"and the slot account completion would need: {names}"
);
}
#[test]
fn the_json_status_does_not_call_an_hourly_refresh_expired() {
let root = tempfile::tempdir().unwrap();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
std::fs::create_dir_all(root.path().join(".claude")).unwrap();
std::fs::write(
root.path().join(".claude/.credentials.json"),
format!(
r#"{{"claudeAiOauth":{{"accessToken":"T","expiresAt":{},"subscriptionType":"max"}}}}"#,
now_ms - 3_600_000
),
)
.unwrap();
std::fs::write(
root.path().join(".claude.json"),
br#"{"oauthAccount":{"accountUuid":"u-1","emailAddress":"a@x.com"}}"#,
)
.unwrap();
let out = Command::new(bin())
.args(["status", "--json"])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("status --json is JSON");
let row = v
.as_array()
.unwrap()
.iter()
.find(|r| r["tool"] == "claude-code")
.expect("the claude row");
assert_eq!(row["logged_in"], serde_json::json!(true), "{row}");
assert_eq!(
row["expired"],
serde_json::json!(false),
"an hourly refresh is not an expired login: {row}"
);
}
#[test]
fn the_json_listing_says_when_it_is_incomplete() {
let root = tempfile::tempdir().unwrap();
seed_copy_profile(root.path(), "work");
let store = root.path().join(".local/share/swapdex");
std::fs::create_dir_all(&store).unwrap();
std::fs::write(store.join("slots.json"), b"{ not json").unwrap();
let out = Command::new(bin())
.args(["ls", "--json"])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
let parsed: serde_json::Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("stdout is still JSON ({e}): {stdout}"));
assert!(parsed.is_array(), "the shape does not change: {stdout}");
assert!(
stderr.contains("could not be read") || stderr.contains("damaged"),
"and the incompleteness is said out loud: stderr={stderr:?}"
);
}
#[test]
fn rm_drops_the_tool_the_documented_flag_names() {
let root = tempfile::tempdir().unwrap();
seed_copy_profile(root.path(), "work");
let path = std::env::var("PATH").unwrap_or_default();
assert!(
run_in(root.path(), &["ls"], &path).contains("claude-code"),
"precondition: the profile has that login"
);
let out = Command::new(bin())
.args(["rm", "work", "--tool", "claude"])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
let all = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(out.status.code(), Some(0), "{all}");
assert!(
!run_in(root.path(), &["ls"], &path).contains("claude-code"),
"the login is gone"
);
let bad = Command::new(bin())
.args(["rm", "work", "--tool", "banana"])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
assert_eq!(bad.status.code(), Some(2), "invalid value is exit 2");
}
#[test]
fn a_rename_that_is_refused_leaves_the_slot_alone() {
let root = tempfile::tempdir().unwrap();
let dir = root.path().join("dir-a");
std::fs::create_dir_all(&dir).unwrap();
let path = std::env::var("PATH").unwrap_or_default();
run_in(
root.path(),
&["adopt", "bsgong", dir.to_str().unwrap()],
&path,
);
seed_copy_profile(root.path(), "bsgong");
seed_copy_profile(root.path(), "kong");
let out = Command::new(bin())
.args(["rename", "bsgong", "kong"])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
assert_eq!(out.status.code(), Some(6), "the target name is taken");
let slots = run_in(root.path(), &["slots"], &path);
assert!(
slots.contains("bsgong"),
"a refused rename moved nothing: {slots}"
);
assert!(
!slots.contains("kong"),
"the slot must not carry the name the rename was refused: {slots}"
);
}
#[test]
fn rm_says_when_a_saved_profile_of_the_same_name_is_still_there() {
let root = tempfile::tempdir().unwrap();
let existing = root.path().join("dot-claude-kong");
std::fs::create_dir_all(&existing).unwrap();
std::fs::write(existing.join(".credentials.json"), b"{}").unwrap();
let path = std::env::var("PATH").unwrap_or_default();
run_in(
root.path(),
&["adopt", "kong", existing.to_str().unwrap()],
&path,
);
seed_copy_profile(root.path(), "kong");
let out = run_in(root.path(), &["rm", "kong", "--yes"], &path);
assert!(out.contains("stopped managing"), "the slot went: {out}");
assert!(
out.contains("saved profile"),
"and it says the other half is still here: {out}"
);
assert!(
out.contains("rm kong"),
"naming the command that removes it: {out}"
);
assert!(
run_in(root.path(), &["ls"], &path).contains("kong"),
"the profile is still listed"
);
}
#[test]
fn rm_unregisters_a_slot_and_leaves_its_login_alone() {
let root = tempfile::tempdir().unwrap();
let existing = root.path().join("dot-claude-company");
std::fs::create_dir_all(&existing).unwrap();
std::fs::write(existing.join(".credentials.json"), b"{\"keep\":\"me\"}").unwrap();
let path = std::env::var("PATH").unwrap_or_default();
run_in(
root.path(),
&["adopt", "company", existing.to_str().unwrap()],
&path,
);
assert!(run_in(root.path(), &["slots"], &path).contains("company"));
let out = run_in(root.path(), &["rm", "company", "--yes"], &path);
assert!(out.contains("stopped managing"), "{out}");
assert!(
!out.contains("saved profile"),
"no snapshot exists, so none is claimed: {out}"
);
assert!(
!run_in(root.path(), &["slots"], &path).contains("company"),
"the mapping is gone"
);
assert!(
existing.join(".credentials.json").exists(),
"the login was never touched"
);
run_in(
root.path(),
&["adopt", "company", existing.to_str().unwrap()],
&path,
);
assert!(run_in(root.path(), &["slots"], &path).contains("company"));
}
#[test]
fn shim_puts_itself_on_path_via_the_shell_profile() {
let root = tempfile::tempdir().unwrap();
let bin_dir = fake_claude(root.path());
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
let home = root.path().join("home");
std::fs::create_dir_all(&home).unwrap();
std::fs::write(root.path().join(".zshrc"), "ROOT_SENTINEL\n").unwrap();
std::fs::write(home.join(".zshrc"), "HOME_SENTINEL\n").unwrap();
let run_shim = || {
String::from_utf8_lossy(
&Command::new(bin())
.args(["shim"])
.env("SWAPDEX_ROOT", root.path())
.env("PATH", &path)
.env("HOME", &home)
.env("SHELL", "/bin/zsh")
.output()
.unwrap()
.stdout,
)
.into_owned()
};
let out = run_shim();
assert!(
out.contains("added it to"),
"it says what it changed: {out}"
);
let zshrc = std::fs::read_to_string(root.path().join(".zshrc")).expect("profile written");
assert!(
zshrc.contains("ROOT_SENTINEL")
&& zshrc.contains("swapdex")
&& zshrc.contains("export PATH="),
"the line is there: {zshrc}"
);
assert_eq!(
std::fs::read_to_string(home.join(".zshrc")).unwrap(),
"HOME_SENTINEL\n",
"the ambient HOME profile is outside the supplied Paths and stays untouched"
);
run_shim();
let again = std::fs::read_to_string(root.path().join(".zshrc")).unwrap();
assert_eq!(
again.matches("export PATH=").count(),
1,
"idempotent: {again}"
);
let out = String::from_utf8_lossy(
&Command::new(bin())
.args(["shim"])
.env("SWAPDEX_ROOT", root.path())
.env("PATH", &path)
.env("HOME", &home)
.env("SHELL", "/usr/bin/fish")
.output()
.unwrap()
.stdout,
)
.into_owned();
assert!(
out.contains("add this to your shell profile"),
"fish gets instructions rather than a guessed edit: {out}"
);
assert!(
!home.join(".config").exists(),
"nothing was written for a shell we do not handle"
);
}
#[test]
fn shim_anchors_relative_stable_client_paths_to_the_install_directory() {
let root = tempfile::tempdir().unwrap();
let install = root.path().join("install");
let stable = install.join("bin");
let versioned = install.join("versions/v1");
let elsewhere = root.path().join("elsewhere");
std::fs::create_dir_all(&stable).unwrap();
std::fs::create_dir_all(&versioned).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
for (name, marker) in [("claude", "RELATIVE_CLAUDE"), ("codex", "RELATIVE_CODEX")] {
let native = versioned.join(name);
std::fs::write(&native, format!("#!/bin/sh\nprintf '{marker}\\n'\n")).unwrap();
std::fs::set_permissions(&native, std::fs::Permissions::from_mode(0o755)).unwrap();
std::os::unix::fs::symlink(&native, stable.join(name)).unwrap();
}
let installed = Command::new(bin())
.arg("shim")
.current_dir(&install)
.env("SWAPDEX_ROOT", root.path())
.env("HOME", root.path())
.env("SHELL", "/usr/bin/fish")
.env("PATH", "bin:/usr/bin:/bin")
.output()
.unwrap();
assert!(
installed.status.success(),
"shim install failed: {}{}",
String::from_utf8_lossy(&installed.stdout),
String::from_utf8_lossy(&installed.stderr)
);
let shim_dir = root.path().join(".local/share/swapdex/bin");
for (name, marker) in [("claude", "RELATIVE_CLAUDE"), ("codex", "RELATIVE_CODEX")] {
let body = std::fs::read_to_string(shim_dir.join(name)).unwrap();
let exec_line = body
.lines()
.rev()
.find(|line| line.starts_with("exec '") && line.ends_with("' \"$@\""))
.unwrap_or_else(|| panic!("no native exec in generated {name} shim: {body}"));
let embedded = std::path::PathBuf::from(
exec_line
.strip_prefix("exec '")
.unwrap()
.strip_suffix("' \"$@\"")
.unwrap(),
);
assert!(
embedded.is_absolute(),
"the generated {name} shim persisted a relative client: {embedded:?}"
);
assert!(
std::fs::symlink_metadata(&embedded)
.unwrap()
.file_type()
.is_symlink(),
"the generated {name} shim froze a versioned target: {embedded:?}"
);
assert_eq!(
std::fs::canonicalize(&embedded).unwrap(),
std::fs::canonicalize(versioned.join(name)).unwrap(),
"the stable {name} symlink resolves to the native client"
);
let out = Command::new(shim_dir.join(name))
.arg("--help")
.current_dir(&elsewhere)
.env("SWAPDEX_ROOT", root.path())
.env("HOME", root.path())
.env("PATH", "/usr/bin:/bin")
.output()
.unwrap();
assert!(
out.status.success() && String::from_utf8_lossy(&out.stdout).contains(marker),
"the {name} shim did not reach its native client from another cwd: {}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn shim_skips_a_non_executable_path_shadow() {
let root = tempfile::tempdir().unwrap();
let shadow = root.path().join("shadow");
let native = root.path().join("native");
std::fs::create_dir_all(&shadow).unwrap();
std::fs::create_dir_all(&native).unwrap();
std::fs::write(shadow.join("claude"), "not executable\n").unwrap();
std::fs::set_permissions(
shadow.join("claude"),
std::fs::Permissions::from_mode(0o644),
)
.unwrap();
std::fs::write(
native.join("claude"),
"#!/bin/sh\nprintf 'EXECUTABLE_CLAUDE\\n'\n",
)
.unwrap();
std::fs::set_permissions(
native.join("claude"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
let path = format!("{}:{}:/usr/bin:/bin", shadow.display(), native.display());
let installed = Command::new(bin())
.arg("shim")
.env("SWAPDEX_ROOT", root.path())
.env("HOME", root.path())
.env("SHELL", "/usr/bin/fish")
.env("PATH", &path)
.output()
.unwrap();
assert!(installed.status.success());
let out = Command::new(root.path().join(".local/share/swapdex/bin/claude"))
.arg("--help")
.env("SWAPDEX_ROOT", root.path())
.env("HOME", root.path())
.env("PATH", "/usr/bin:/bin")
.output()
.unwrap();
assert!(
out.status.success() && String::from_utf8_lossy(&out.stdout).contains("EXECUTABLE_CLAUDE"),
"the non-executable shadow was persisted: {}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn shim_service_detection_uses_the_supplied_home() {
let root = tempfile::tempdir().unwrap();
let ambient_home = root.path().join("ambient-home");
let supplied_home = root.path().join("supplied-home");
std::fs::create_dir_all(&ambient_home).unwrap();
std::fs::create_dir_all(&supplied_home).unwrap();
let bin_dir = fake_claude(root.path());
let path = format!("{}:/usr/bin:/bin", bin_dir.display());
let unit = |home: &Path| {
if cfg!(target_os = "macos") {
swapdex::service::launchd_path(home, "claude-code")
} else {
swapdex::service::systemd_path(home, "claude-code")
}
};
let decoy = unit(&ambient_home);
std::fs::create_dir_all(decoy.parent().unwrap()).unwrap();
std::fs::write(&decoy, "decoy service\n").unwrap();
let run = || {
Command::new(bin())
.arg("shim")
.env("SWAPDEX_ROOT", &supplied_home)
.env("HOME", &ambient_home)
.env("SHELL", "/usr/bin/fish")
.env("PATH", &path)
.output()
.unwrap()
};
assert!(run().status.success());
assert!(
!supplied_home.join(".claude/settings.json").exists(),
"an unrelated ambient-home service must not authorize a proxy pin"
);
std::fs::remove_file(decoy).unwrap();
let supplied_unit = unit(&supplied_home);
std::fs::create_dir_all(supplied_unit.parent().unwrap()).unwrap();
std::fs::write(supplied_unit, "synthetic service\n").unwrap();
assert!(run().status.success());
let settings = std::fs::read_to_string(supplied_home.join(".claude/settings.json"))
.expect("the supplied service authorizes the synthetic root's proxy pin");
assert!(settings.contains("127.0.0.1:8787"), "{settings}");
}
#[test]
fn doctor_reads_the_shell_profile_from_the_supplied_home() {
let root = tempfile::tempdir().unwrap();
let ambient_home = root.path().join("ambient-home");
std::fs::create_dir_all(&ambient_home).unwrap();
let bin_dir = fake_claude(root.path());
let native_path = format!("{}:/usr/bin:/bin", bin_dir.display());
run_in(root.path(), &["run", "work", "--no-launch"], &native_path);
run_in(root.path(), &["use", "work"], &native_path);
let shim_dir = root.path().join(".local/share/swapdex/bin");
let profile_line = format!("export PATH=\"{}:$PATH\"\n", shim_dir.display());
std::fs::write(root.path().join(".bashrc"), &profile_line).unwrap();
std::fs::write(ambient_home.join(".bashrc"), "AMBIENT_SENTINEL\n").unwrap();
let installed = Command::new(bin())
.arg("shim")
.env("SWAPDEX_ROOT", root.path())
.env("HOME", &ambient_home)
.env("SHELL", "/bin/bash")
.env("PATH", &native_path)
.output()
.unwrap();
assert!(installed.status.success());
std::fs::write(ambient_home.join(".bashrc"), "AMBIENT_SENTINEL\n").unwrap();
let out = Command::new(bin())
.arg("doctor")
.env("SWAPDEX_ROOT", root.path())
.env("HOME", &ambient_home)
.env("SHELL", "/bin/bash")
.env("PATH", &native_path)
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("shim set up in") && stdout.contains("not on THIS shell's PATH"),
"doctor ignored the supplied home profile: {stdout}"
);
}
#[test]
fn slash_installs_a_claude_code_command() {
let root = tempfile::tempdir().unwrap();
let home = root.path().to_path_buf();
let out = String::from_utf8_lossy(
&Command::new(bin())
.args(["slash"])
.env("SWAPDEX_ROOT", root.path())
.env("HOME", &home)
.output()
.unwrap()
.stdout,
)
.into_owned();
assert!(out.contains("/swap"), "it names the command: {out}");
let claude_body = std::fs::read_to_string(home.join(".claude/commands/swap.md"))
.expect("claude command written");
assert!(claude_body.starts_with("---"), "frontmatter: {claude_body}");
assert!(
claude_body.contains("--tool claude-code") && !claude_body.contains("--tool codex"),
"the Claude command switches Claude only: {claude_body}"
);
assert!(
claude_body.contains("tagged `claude-code`"),
"and lists Claude accounts only: {claude_body}"
);
let codex_body =
std::fs::read_to_string(home.join(".codex/skills/swap/SKILL.md")).expect("codex skill");
assert!(
codex_body.contains("name: swap") && codex_body.contains("description:"),
"codex frontmatter: {codex_body}"
);
assert!(
codex_body.contains("--tool codex") && !codex_body.contains("--tool claude-code"),
"the Codex skill switches Codex only: {codex_body}"
);
assert!(
codex_body.contains("AskUserQuestion"),
"a bare /swap still offers a pick-list: {codex_body}"
);
let f = home.join(".claude/commands/swap.md");
let body = claude_body;
Command::new(bin())
.args(["slash"])
.env("SWAPDEX_ROOT", root.path())
.env("HOME", &home)
.output()
.unwrap();
assert_eq!(std::fs::read_to_string(&f).unwrap(), body);
}
#[test]
fn slash_explicit_argument_executes_a_payer_only_switch() {
for (tool, short, instruction) in [
("claude-code", "claude", ".claude/commands/swap.md"),
("codex", "codex", ".codex/skills/swap/SKILL.md"),
] {
let root = tempfile::tempdir().unwrap();
let path = "/usr/bin:/bin";
for name in ["a", "b"] {
run_in(
root.path(),
&["run", name, "--tool", tool, "--no-launch"],
path,
);
let dir = slot_dir(root.path(), name, tool);
if tool == "codex" {
std::fs::write(
dir.join("auth.json"),
format!(
"{{\"tokens\":{{\"access_token\":\"{name}\",\"account_id\":\"{name}\"}}}}"
),
)
.unwrap();
} else {
std::fs::write(
dir.join(".credentials.json"),
format!(
"{{\"claudeAiOauth\":{{\"accessToken\":\"{name}\",\"expiresAt\":32503680000000}}}}"
),
)
.unwrap();
}
}
run_in(root.path(), &["use", "a", "--tool", tool], path);
let store = root.path().join(".local/share/swapdex");
let active = store.join(format!("active-{short}"));
let active_before = std::fs::read(&active).expect("launch-home pointer");
run_in(root.path(), &["slash"], path);
let body = std::fs::read_to_string(root.path().join(instruction)).unwrap();
let generated = body
.split('`')
.nth(1)
.unwrap_or_else(|| panic!("no executable command in {instruction}: {body}"));
let command_dir = root.path().join("command-bin");
std::fs::create_dir_all(&command_dir).unwrap();
std::os::unix::fs::symlink(bin(), command_dir.join("swapdex")).unwrap();
let generated_path = format!("{}:/usr/bin:/bin", command_dir.display());
let out = Command::new("/bin/sh")
.args(["-c", generated])
.env("ARGUMENTS", "b")
.env("SWAPDEX_ROOT", root.path())
.env("HOME", root.path())
.env("PATH", generated_path)
.output()
.unwrap();
assert!(
out.status.success(),
"generated {tool} instruction failed: {}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
std::fs::read(&active).unwrap(),
active_before,
"generated {tool} instruction moved the launch home"
);
assert_eq!(
std::fs::read_to_string(store.join(format!("serving-{short}"))).unwrap(),
slot_dir(root.path(), "b", tool).display().to_string(),
"generated {tool} instruction did not select b as payer"
);
}
}
#[test]
fn threshold_setting_accepts_both_notations_and_off() {
let root = tempfile::tempdir().unwrap();
let path = std::env::var("PATH").unwrap_or_default();
assert!(
run_in(root.path(), &["threshold"], &path).contains("no threshold"),
"off until asked for"
);
assert!(run_in(root.path(), &["threshold", "0.9"], &path).contains("90%"));
assert!(
run_in(root.path(), &["threshold"], &path).contains("90%"),
"it persists"
);
assert!(run_in(root.path(), &["threshold", "80%"], &path).contains("80%"));
assert!(run_in(root.path(), &["threshold", "95"], &path).contains("95%"));
assert!(run_in(root.path(), &["threshold", "off"], &path).contains("threshold off"));
assert!(run_in(root.path(), &["threshold"], &path).contains("no threshold"));
let out = Command::new(bin())
.args(["threshold", "soonish"])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2));
}
#[test]
fn threshold_percentages_keep_their_literal_value_after_reloading() {
let root = tempfile::tempdir().unwrap();
let paths = swapdex::paths::Paths::rooted(root.path());
for (input, expected, displayed) in [
("0.5%", 0.005, "0.5%"),
("1%", 0.01, "1%"),
("0.01%", 0.0001, "0.01%"),
("0.29%", 0.0029, "0.29%"),
(" 0.25% ", 0.0025, "0.25%"),
("29%", 0.29, "29%"),
("99.9%", 0.999, "99.9%"),
("100%", 1.0, "100%"),
("0.5", 0.5, "50%"),
("1", 1.0, "100%"),
("95", 0.95, "95%"),
("1e-8%", 1e-10, "0.00000001%"),
("1e-13%", 1e-15, "1e-13%"),
] {
let output = Command::new(bin())
.args(["threshold", input])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
assert!(output.status.success(), "{input}: {output:?}");
let settings = swapdex::settings::load(&paths);
let stored = settings.proxy_threshold.expect("stored threshold");
assert!(
(stored - expected).abs() < expected * 1e-12,
"{input}: stored {stored}, expected {expected}"
);
assert_eq!(
settings.threshold(),
Some(stored),
"{input}: silently clamped"
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(&format!("at {displayed} used")),
"{input}: {stdout}"
);
let reread = Command::new(bin())
.arg("threshold")
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
assert!(reread.status.success());
let stdout = String::from_utf8_lossy(&reread.stdout);
assert!(
stdout.contains(&format!("at {displayed} used")),
"{input}: {stdout}"
);
let used_percent = stored * 100.0;
assert!(swapdex::proxy::pick::over_threshold(
Some(used_percent),
None,
stored
));
assert!(!swapdex::proxy::pick::over_threshold(
Some(used_percent / 2.0),
None,
stored
));
}
}
#[test]
fn threshold_invalid_values_leave_the_previous_setting_untouched() {
let root = tempfile::tempdir().unwrap();
let paths = swapdex::paths::Paths::rooted(root.path());
swapdex::settings::save(
&paths,
&swapdex::settings::Settings {
proxy_threshold: Some(0.82),
proxy_auto: Some(true),
..Default::default()
},
)
.unwrap();
let settings = paths.store_dir().join("settings.json");
let before = std::fs::read(&settings).unwrap();
for input in [
"50%%", "1%%", "50%junk", "%", "NaN", "inf", "0", "-0.5", "100.1%", "101",
] {
let output = Command::new(bin())
.args(["threshold", "--", input])
.env("SWAPDEX_ROOT", root.path())
.output()
.unwrap();
assert_eq!(
output.status.code(),
Some(2),
"accepted {input}: {output:?}"
);
assert_eq!(
std::fs::read(&settings).unwrap(),
before,
"changed settings for {input}"
);
}
}
#[test]
fn threshold_runtime_preserves_small_values_and_ignores_invalid_stored_values() {
for threshold in [0.005, 0.01, 0.049, 0.05, 0.9, 1.0] {
let settings = swapdex::settings::Settings {
proxy_threshold: Some(threshold),
..Default::default()
};
assert_eq!(settings.threshold(), Some(threshold));
}
for threshold in [0.0, -0.1, 1.01, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let settings = swapdex::settings::Settings {
proxy_threshold: Some(threshold),
..Default::default()
};
assert_eq!(
settings.threshold(),
None,
"invalid stored threshold: {threshold}"
);
}
}
fn fake_codex(root: &Path) -> std::path::PathBuf {
let dir = root.join("fakebin");
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("codex");
std::fs::write(
&f,
"#!/bin/sh\necho \"HOME_DIR=$CODEX_HOME\"\necho \"ARGS=$*\"\n",
)
.unwrap();
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
dir
}
fn slot_dir(root: &Path, name: &str, tool: &str) -> std::path::PathBuf {
let store = root.join(".local/share/swapdex");
let recs: Vec<serde_json::Value> =
serde_json::from_slice(&std::fs::read(store.join("slots.json")).unwrap()).unwrap();
recs.iter()
.find(|record| record["name"] == name && record["tool"] == tool)
.and_then(|record| record["config_dir"].as_str())
.map(std::path::PathBuf::from)
.expect("slot record")
}
#[test]
fn run_steps_over_installed_shims_for_fresh_accounts() {
for (tool_flag, tool, short, home_line) in [
(None, "claude-code", "claude", "CFG="),
(Some("codex"), "codex", "codex", "HOME_DIR="),
] {
let root = tempfile::tempdir().unwrap();
let bin_dir = fake_claude(root.path());
fake_codex(root.path());
let native_path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
let mut existing_args = vec!["run", "existing", "--no-launch"];
if let Some(tool_flag) = tool_flag {
existing_args.extend(["--tool", tool_flag]);
}
run_in(root.path(), &existing_args, &native_path);
let existing_dir = slot_dir(root.path(), "existing", tool);
if tool == "codex" {
std::fs::write(
existing_dir.join("auth.json"),
br#"{"tokens":{"access_token":"existing","account_id":"existing"}}"#,
)
.unwrap();
} else {
std::fs::write(
existing_dir.join(".credentials.json"),
br#"{"claudeAiOauth":{"accessToken":"existing","expiresAt":32503680000000}}"#,
)
.unwrap();
}
let mut use_args = vec!["use", "existing"];
let mut serve_args = vec!["serve", "existing"];
if let Some(tool_flag) = tool_flag {
use_args.extend(["--tool", tool_flag]);
serve_args.extend(["--tool", tool_flag]);
}
run_in(root.path(), &use_args, &native_path);
run_in(root.path(), &serve_args, &native_path);
let store = root.path().join(".local/share/swapdex");
let active = store.join(format!("active-{short}"));
let serving = store.join(format!("serving-{short}"));
let active_before = std::fs::read(&active).expect("active pointer");
let serving_before = std::fs::read(&serving).expect("serving pointer");
let installed = run_in(root.path(), &["shim"], &native_path);
assert!(
installed.contains("installed the claude shim"),
"{installed}"
);
let shim_dir = root.path().join(".local/share/swapdex/bin");
let shim_first_path = format!("{}:{native_path}", shim_dir.display());
let mut args = vec!["run", "fresh"];
if let Some(tool_flag) = tool_flag {
args.extend(["--tool", tool_flag]);
}
let home = root.path().join("home");
let out = Command::new(bin())
.args(args)
.env("SWAPDEX_ROOT", root.path())
.env("PATH", shim_first_path)
.env("HOME", home)
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success() && stdout.lines().any(|line| line.starts_with(home_line)),
"managed {tool} run did not reach the native tool: stdout={stdout:?} stderr={stderr:?}"
);
assert_eq!(std::fs::read(&active).unwrap(), active_before);
assert_eq!(std::fs::read(&serving).unwrap(), serving_before);
}
}
#[test]
fn run_reports_when_an_installed_shim_has_no_native_tool_behind_it() {
for (tool_flag, binary) in [(None, "claude"), (Some("codex"), "codex")] {
let root = tempfile::tempdir().unwrap();
let bin_dir = fake_claude(root.path());
fake_codex(root.path());
let native_path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
run_in(root.path(), &["shim"], &native_path);
std::fs::remove_file(bin_dir.join(binary)).unwrap();
let shim_dir = root.path().join(".local/share/swapdex/bin");
let mut args = vec!["run", "fresh"];
if let Some(tool_flag) = tool_flag {
args.extend(["--tool", tool_flag]);
}
let out = Command::new(bin())
.args(args)
.env("SWAPDEX_ROOT", root.path())
.env("PATH", &shim_dir)
.env("HOME", root.path().join("home"))
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success(), "shim-only PATH must fail");
assert!(
stderr.contains(&format!("real `{binary}` executable")) && stderr.contains("not found"),
"missing-native diagnostic for {binary}: {stderr}"
);
}
}
#[test]
fn run_launches_codex_in_the_accounts_own_home() {
let root = tempfile::tempdir().unwrap();
let bin_dir = fake_codex(root.path());
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
let out = run_in(root.path(), &["run", "work", "--tool", "codex"], &path);
let store = root.path().join(".local/share/swapdex");
let recs: Vec<serde_json::Value> =
serde_json::from_slice(&std::fs::read(store.join("slots.json")).unwrap()).unwrap();
let rec = recs
.iter()
.find(|r| r["name"] == "work")
.expect("the slot was created");
assert_eq!(rec["tool"], "codex", "registered as a Codex account");
let dir = rec["config_dir"].as_str().unwrap();
assert!(
out.contains(&format!("HOME_DIR={dir}")),
"codex was launched with that home: {out}"
);
assert!(!out.contains("CLAUDE_CONFIG_DIR"), "{out}");
}
#[test]
fn rename_works_on_a_slot_account() {
let root = tempfile::tempdir().unwrap();
let bin_dir = fake_claude(root.path());
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
run_in(root.path(), &["run", "work", "--no-launch"], &path);
let out = run_in(root.path(), &["rename", "work", "acme"], &path);
assert!(out.contains("acme"), "the rename reported success: {out}");
let listed = run_in(root.path(), &["slots"], &path);
assert!(listed.contains("acme"), "the new name is listed: {listed}");
assert!(!listed.contains("work"), "the old one is gone: {listed}");
}
#[test]
fn removing_an_account_works_for_either_tool() {
let root = tempfile::tempdir().unwrap();
let bin_dir = fake_claude(root.path());
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
run_in(root.path(), &["run", "onclaude", "--no-launch"], &path);
run_in(
root.path(),
&["run", "oncodex", "--tool", "codex", "--no-launch"],
&path,
);
let out = run_in(root.path(), &["rm", "oncodex", "--yes"], &path);
assert!(
out.contains("oncodex"),
"a Codex account can be removed: {out}"
);
let listed = run_in(root.path(), &["slots"], &path);
assert!(!listed.contains("oncodex"), "it is gone: {listed}");
assert!(
listed.contains("onclaude"),
"the other is untouched: {listed}"
);
run_in(root.path(), &["rm", "onclaude", "--yes"], &path);
let listed = run_in(root.path(), &["slots"], &path);
assert!(!listed.contains("onclaude"), "{listed}");
}
#[test]
fn an_accounts_tool_is_read_the_same_way_everywhere() {
let root = tempfile::tempdir().unwrap();
let bin_dir = fake_claude(root.path());
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
run_in(
root.path(),
&["run", "oncodex", "--tool", "codex", "--no-launch"],
&path,
);
run_in(root.path(), &["run", "onclaude", "--no-launch"], &path);
let slot_of = |name: &str| -> std::path::PathBuf {
let raw = std::fs::read_to_string(root.path().join(".local/share/swapdex/slots.json"))
.expect("slots.json");
let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
let dir = v
.as_array()
.unwrap()
.iter()
.find(|r| r["name"] == name)
.unwrap()["config_dir"]
.as_str()
.unwrap()
.to_string();
std::path::PathBuf::from(dir)
};
std::fs::write(
slot_of("oncodex").join("auth.json"),
br#"{"tokens":{"access_token":"a","account_id":"acc"}}"#,
)
.unwrap();
std::fs::write(
slot_of("onclaude").join(".credentials.json"),
br#"{"claudeAiOauth":{"accessToken":"a","expiresAt":32503680000000}}"#,
)
.unwrap();
let out = run_in(root.path(), &["serve", "oncodex", "--tool", "codex"], &path);
assert!(out.contains("oncodex"), "{out}");
let store = root.path().join(".local/share/swapdex");
assert!(
store.join("serving-codex").exists(),
"a Codex account is served through Codex's own pointer"
);
assert!(
!store.join("serving-claude").exists(),
"and never through Claude's"
);
let out = run_in(root.path(), &["serve", "onclaude"], &path);
assert!(out.contains("onclaude"), "{out}");
assert!(store.join("serving-claude").exists());
}
#[test]
fn doctor_reports_which_swapdex_is_actually_in_use() {
let root = tempfile::tempdir().unwrap();
let bin_dir = fake_claude(root.path());
let decoy_dir = root.path().join("other-installer");
std::fs::create_dir_all(&decoy_dir).unwrap();
std::fs::write(decoy_dir.join("swapdex"), b"#!/bin/sh\nexit 0\n").unwrap();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
decoy_dir.join("swapdex"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
}
let real_dir = std::path::Path::new(bin()).parent().unwrap().to_path_buf();
let path = format!(
"{}:{}:{}",
bin_dir.display(),
decoy_dir.display(),
real_dir.display()
);
let out = Command::new(bin())
.args(["doctor"])
.env("SWAPDEX_ROOT", root.path())
.env("PATH", &path)
.output()
.unwrap();
let o = String::from_utf8_lossy(&out.stdout);
assert!(
o.contains("2 copies on PATH"),
"the shadowed install is named: {o}"
);
assert!(
o.contains("keep one installer"),
"and the fix comes with it: {o}"
);
assert!(
!o.contains("latest is"),
"no network from a sandboxed run: {o}"
);
}
#[test]
fn signing_in_asks_the_tool_to_sign_in_and_bypasses_the_proxy() {
let root = tempfile::tempdir().unwrap();
let bin_dir = fake_claude(root.path());
std::fs::write(
bin_dir.join("codex"),
b"#!/bin/sh\necho \"REAL-CODEX args=$*\" >> \"$SX_PROBE\"\nexit 0\n",
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
bin_dir.join("codex"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
}
let path = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
run_in(
root.path(),
&["run", "work", "--tool", "codex", "--no-launch"],
&path,
);
run_in(root.path(), &["shim"], &path);
let shim_dir = root.path().join(".local/share/swapdex/bin");
assert!(
shim_dir.join("codex").is_file(),
"the codex shim was written"
);
let path = format!("{}:{}", shim_dir.display(), path);
let probe = root.path().join("probe.log");
let out = Command::new(bin())
.args(["login", "work", "--tool", "codex"])
.env("SWAPDEX_ROOT", root.path())
.env("PATH", &path)
.env("SX_PROBE", &probe)
.output()
.unwrap();
let seen = std::fs::read_to_string(&probe).unwrap_or_default();
let o = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
seen.contains("REAL-CODEX"),
"codex was reached at all: probe={seen:?} out={o}"
);
assert!(
!seen.contains("model_provider=swapdex"),
"the sign-in must not go through the proxy: probe={seen:?}"
);
assert!(
seen.contains("args=login"),
"and asks to sign in rather than opening a session: probe={seen:?}"
);
}