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 run_unshimmed(root: &Path, args: &[&str]) -> (String, String, i32) {
let out = Command::new(bin())
.args(args)
.env("SWAPDEX_ROOT", root)
.env("PATH", "/usr/bin:/bin")
.output()
.unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code().unwrap_or(-1),
)
}
fn chmod600(p: &Path) {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o600));
}
fn seed_claude(root: &Path, uuid: &str, email: &str) {
let d = root.join(".claude");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(
d.join(".credentials.json"),
serde_json::to_vec(&serde_json::json!({"claudeAiOauth":{
"accessToken":"AT","refreshToken":"RT","expiresAt":9999999999999i64,
"subscriptionType":"max"}}))
.unwrap(),
)
.unwrap();
std::fs::write(
root.join(".claude.json"),
serde_json::to_vec(&serde_json::json!({
"oauthAccount":{"accountUuid":uuid,"emailAddress":email,"displayName":"X"}}))
.unwrap(),
)
.unwrap();
chmod600(&d.join(".credentials.json"));
chmod600(&root.join(".claude.json"));
}
fn live_claude_email(root: &Path) -> String {
let bytes = std::fs::read(root.join(".claude.json")).expect("read the live .claude.json");
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("parse the live .claude.json");
v["oauthAccount"]["emailAddress"]
.as_str()
.unwrap_or_default()
.to_string()
}
fn seed_slot(root: &Path, name: &str, email: &str) {
let dir = root.join(".local/share/swapdex/slots").join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(".credentials.json"),
serde_json::to_vec(&serde_json::json!({"claudeAiOauth":{
"accessToken":"AT","refreshToken":"RT","expiresAt":9999999999999i64,
"subscriptionType":"max"}}))
.unwrap(),
)
.unwrap();
std::fs::write(
dir.join(".claude.json"),
serde_json::to_vec(&serde_json::json!({
"oauthAccount":{"accountUuid":name,"emailAddress":email}}))
.unwrap(),
)
.unwrap();
chmod600(&dir.join(".credentials.json"));
let reg = root.join(".local/share/swapdex/slots.json");
let mut rows: Vec<serde_json::Value> = std::fs::read(®)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
rows.push(serde_json::json!({
"name": name, "id": name, "config_dir": dir.to_string_lossy(),
"adopted": false, "tool": "claude-code"}));
std::fs::write(®, serde_json::to_vec_pretty(&rows).unwrap()).unwrap();
}
fn fixture() -> tempfile::TempDir {
let t = tempfile::tempdir().unwrap();
std::fs::create_dir_all(t.path().join(".local/share/swapdex")).unwrap();
std::fs::write(t.path().join(".local/share/swapdex/onboarded"), b"1").unwrap();
t
}
fn run_with_native_codex(root: &Path, args: &[&str]) -> (String, i32) {
let native = root.join("native-bin");
std::fs::create_dir_all(&native).unwrap();
let codex = native.join("codex");
if !codex.exists() {
std::fs::write(&codex, "#!/bin/sh\nexit 0\n").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let out = Command::new(bin())
.args(args)
.env("SWAPDEX_ROOT", root)
.env("HOME", root)
.env("PATH", format!("{}:/usr/bin:/bin", native.display()))
.output()
.unwrap();
(
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
),
out.status.code().unwrap_or(-1),
)
}
fn age_the_codex_shim(shim: &Path) -> String {
let current = std::fs::read_to_string(shim).unwrap();
let aged: String = current
.lines()
.filter(|l| !l.contains("chatgpt_base_url=\"https"))
.map(|l| format!("{l}\n"))
.collect();
assert_ne!(
aged, current,
"the fixture shim had no usage route to remove"
);
std::fs::write(shim, &aged).unwrap();
current
}
#[test]
fn a_launch_rewrites_a_shim_an_older_swapdex_wrote() {
let t = fixture();
let root = t.path();
let (out, code) = run_with_native_codex(root, &["shim"]);
assert_eq!(code, 0, "shim failed: {out}");
let shim = root.join(".local/share/swapdex/bin/codex");
let fresh = age_the_codex_shim(&shim);
let _ = run_with_native_codex(root, &["proxy", "--ensure", "--tool", "codex"]);
assert_eq!(
std::fs::read_to_string(&shim).unwrap(),
fresh,
"a launch left the shim an older swapdex wrote"
);
}
#[test]
fn a_launch_leaves_a_shim_that_calls_another_swapdex() {
let t = fixture();
let root = t.path();
let (out, code) = run_with_native_codex(root, &["shim"]);
assert_eq!(code, 0, "shim failed: {out}");
let shim = root.join(".local/share/swapdex/bin/codex");
let fresh = std::fs::read_to_string(&shim).unwrap();
let foreign = fresh.replace(bin(), "/opt/other/swapdex");
assert_ne!(foreign, fresh, "the shim did not embed this binary's path");
std::fs::write(&shim, &foreign).unwrap();
let _ = run_with_native_codex(root, &["proxy", "--ensure", "--tool", "codex"]);
assert_eq!(
std::fs::read_to_string(&shim).unwrap(),
foreign,
"rewrote a shim that belongs to another swapdex"
);
}
#[test]
fn doctor_reports_a_shim_an_older_swapdex_wrote() {
let t = fixture();
let root = t.path();
let (out, code) = run_with_native_codex(root, &["shim"]);
assert_eq!(code, 0, "shim failed: {out}");
let shim = root.join(".local/share/swapdex/bin/codex");
let (fresh_doctor, _) = run_with_native_codex(root, &["doctor"]);
assert!(
!fresh_doctor.contains("older swapdex"),
"a current shim was called stale:\n{fresh_doctor}"
);
age_the_codex_shim(&shim);
let (said, _) = run_with_native_codex(root, &["doctor"]);
assert!(
said.contains("older swapdex"),
"doctor passes a shim an older swapdex wrote:\n{said}"
);
}
fn mark_codex_proxy(root: &Path, usage_port: Option<u16>) {
let store = root.join(".local/share/swapdex");
let tail = usage_port.map_or(String::new(), |p| format!(" {p}"));
std::fs::write(
store.join("proxy-codex"),
format!("{} 8788 test-build{tail}\n", std::process::id()),
)
.unwrap();
}
fn doctor_usage_row(root: &Path, env: &[(&str, &str)]) -> String {
let mut cmd = Command::new(bin());
cmd.arg("doctor")
.env("SWAPDEX_ROOT", root)
.env_remove("CODEX_CA_CERTIFICATE")
.env_remove("SSL_CERT_FILE");
for (k, v) in env {
cmd.env(k, v);
}
let out = cmd.output().unwrap();
let text =
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr);
text.lines()
.find(|l| l.starts_with("usage:codex"))
.unwrap_or_else(|| panic!("doctor has no usage:codex row:\n{text}"))
.to_string()
}
#[test]
fn doctor_says_whether_codex_usage_reads_reach_the_payer() {
let t = fixture();
let root = t.path();
seed_codex_slot(root, "cx", "cx@example.com");
mark_codex_proxy(root, Some(18788));
let on = doctor_usage_row(root, &[]);
assert!(
on.contains(" ok "),
"a working route reported as a problem: {on}"
);
assert!(
on.contains("18788"),
"the row does not name the listener: {on}"
);
mark_codex_proxy(root, None);
let off = doctor_usage_row(root, &[]);
assert!(
off.contains("problem"),
"a proxy with no usage listener passed as fine: {off}"
);
mark_codex_proxy(root, Some(18788));
for var in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] {
let row = doctor_usage_row(root, &[(var, "/etc/corp.pem")]);
assert!(
row.contains(var),
"the route is left off for {var} and doctor does not say why: {row}"
);
}
}
#[test]
fn everything_serve_accepts_is_something_ls_shows() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-a", "a@example.com");
run(root, &["add", "alpha"]);
seed_slot(root, "alpha", "a@example.com");
seed_claude(root, "uuid-b", "b@example.com");
run(root, &["add", "beta"]);
seed_slot(root, "beta", "b@example.com");
let (listing, _, _) = run(root, &["ls"]);
for name in ["alpha", "beta"] {
let (_, err, code) = run(root, &["serve", name]);
assert_ne!(code, 5, "serve rejected '{name}' as unknown: {err}");
assert!(
listing.contains(name),
"serve accepts '{name}' but `ls` never lists it:\n{listing}"
);
}
}
#[test]
fn a_switch_is_visible_in_the_listing_afterwards() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-a", "a@example.com");
run(root, &["add", "alpha"]);
seed_slot(root, "alpha", "a@example.com");
seed_claude(root, "uuid-b", "b@example.com");
run(root, &["add", "beta"]);
seed_slot(root, "beta", "b@example.com");
for name in ["alpha", "beta", "alpha"] {
run(root, &["serve", name]);
let (listing, _, _) = run(root, &["ls"]);
let marked: Vec<&str> = listing.lines().filter(|l| l.contains("pays")).collect();
assert_eq!(
marked.len(),
1,
"after `serve {name}` exactly one row should be marked as paying:\n{listing}"
);
assert!(
marked[0].contains(name),
"after `serve {name}` the paying row names someone else:\n{}",
marked[0]
);
}
}
#[test]
fn the_status_bar_source_agrees_with_the_listing() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-a", "a@example.com");
run(root, &["add", "alpha"]);
seed_slot(root, "alpha", "a@example.com");
seed_claude(root, "uuid-b", "b@example.com");
run(root, &["add", "beta"]);
seed_slot(root, "beta", "b@example.com");
for name in ["beta", "alpha"] {
run(root, &["serve", name]);
let (quiet, _, _) = run(root, &["serve", "--quiet"]);
assert!(
quiet.starts_with(name),
"the status bar reports '{}' after `serve {name}`",
quiet.trim()
);
let (listing, _, _) = run(root, &["ls"]);
let paying = listing
.lines()
.find(|l| l.contains("pays"))
.unwrap_or_default();
assert!(
paying.contains(name),
"bar says '{}' but listing marks:\n{paying}",
quiet.trim()
);
}
}
#[test]
fn a_rename_moves_the_account_in_every_listing() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-a", "a@example.com");
run(root, &["add", "before"]);
seed_slot(root, "before", "a@example.com");
let (_, err, code) = run(root, &["rename", "before", "after"]);
assert_eq!(code, 0, "rename failed: {err}");
let (listing, _, _) = run(root, &["ls"]);
assert!(
listing.contains("after"),
"the new name is missing after a rename:\n{listing}"
);
assert!(
!listing.contains("before"),
"the old name survived the rename:\n{listing}"
);
let (_, err, code) = run(root, &["serve", "after"]);
assert_ne!(code, 5, "serve does not know the new name: {err}");
let (_, _, code) = run(root, &["serve", "before"]);
assert_eq!(code, 5, "serve still answers to the old name");
}
#[test]
fn dropping_one_tool_leaves_the_account_listed() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-a", "a@example.com");
run(root, &["add", "alpha"]);
seed_slot(root, "alpha", "a@example.com");
run(root, &["rm", "alpha", "--tool", "gemini", "--yes"]);
let (listing, _, _) = run(root, &["ls"]);
assert!(
listing.contains("alpha"),
"dropping a tool removed the whole account:\n{listing}"
);
let (_, err, code) = run(root, &["serve", "alpha"]);
assert_ne!(code, 5, "the account stopped existing: {err}");
}
#[test]
fn a_wrong_name_names_the_alternatives() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-a", "a@example.com");
run(root, &["add", "alpha"]);
let (_, err, code) = run(root, &["serve", "alphaa"]);
assert_eq!(code, 5, "a typo should not be accepted");
assert!(
err.contains("alpha"),
"the error does not name the account that exists: {err}"
);
assert!(
!err.contains("swapdex ui"),
"the error still sends the reader to another screen: {err}"
);
}
#[test]
fn a_switch_records_who_asked_for_it() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-a", "a@example.com");
run(root, &["add", "alpha"]);
seed_slot(root, "alpha", "a@example.com");
run(root, &["serve", "alpha"]);
let line = std::fs::read_to_string(root.join(".local/share/swapdex/timeline.jsonl"))
.unwrap_or_default();
let last = line.lines().last().unwrap_or_default();
let v: serde_json::Value = serde_json::from_str(last).unwrap_or_default();
assert!(
v.get("by").and_then(|b| b.as_str()).is_some(),
"the switch does not record who asked for it: {last}"
);
}
#[test]
fn every_listed_account_says_whose_login_it_is() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-a", "alpha@example.com");
run(root, &["add", "alpha"]);
let (listing, _, _) = run(root, &["ls"]);
let row = listing
.lines()
.find(|l| l.contains("alpha "))
.unwrap_or_default();
assert!(
row.contains("alpha@example.com"),
"the row does not name the login it holds:\n{row}"
);
}
#[test]
fn a_slot_without_a_credential_is_not_reported_as_active() {
let t = fixture();
let root = t.path();
let dir = root.join(".local/share/swapdex/slots/codexish");
std::fs::create_dir_all(dir.join("sessions")).unwrap();
std::fs::write(dir.join("config.toml"), b"# nothing\n").unwrap();
let reg = root.join(".local/share/swapdex/slots.json");
std::fs::write(
®,
serde_json::to_vec_pretty(&serde_json::json!([{
"name": "codexish", "id": "codexish",
"config_dir": dir.to_string_lossy(), "adopted": false, "tool": "codex"}]))
.unwrap(),
)
.unwrap();
let (listing, _, _) = run(root, &["ls"]);
let row = listing
.lines()
.find(|l| l.contains("codexish"))
.unwrap_or_default();
let (_, err, code) = run(root, &["serve", "codexish", "--tool", "codex"]);
if code == 5 || err.contains("no codex login") {
assert!(
!row.contains("codex*"),
"listing stars a tool whose login serve says is missing:\n{row}\n{err}"
);
}
}
fn codex_id_token(email: &str) -> String {
let payload = serde_json::json!({"email": email});
let b64 = |b: &[u8]| {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut o = String::new();
for c in b.chunks(3) {
let n = ((c[0] as u32) << 16)
| ((*c.get(1).unwrap_or(&0) as u32) << 8)
| (*c.get(2).unwrap_or(&0) as u32);
for i in 0..(c.len() + 1) {
o.push(T[((n >> (18 - i * 6)) & 63) as usize] as char);
}
}
o
};
format!(
"h.{}.s",
b64(serde_json::to_string(&payload).unwrap().as_bytes())
)
}
fn seed_live_codex(root: &Path, email: &str) {
let dir = root.join(".codex");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("auth.json"),
serde_json::to_vec(&serde_json::json!({
"auth_mode": "chatgpt",
"tokens": {"id_token": codex_id_token(email), "access_token": "AT",
"refresh_token": "RT", "account_id": "acct-live"}}))
.unwrap(),
)
.unwrap();
chmod600(&dir.join("auth.json"));
}
fn rfc3339_utc(secs: u64) -> String {
let (days, rem) = ((secs / 86_400) as i64, secs % 86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era * 400 + yoe + i64::from(m <= 2);
format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
rem / 3600,
(rem % 3600) / 60,
rem % 60
)
}
fn seed_codex_slot(root: &Path, name: &str, email: &str) {
let dir = root.join(".local/share/swapdex/slots").join(name);
std::fs::create_dir_all(dir.join("sessions")).unwrap();
let tok = codex_id_token(email);
std::fs::write(
dir.join("auth.json"),
serde_json::to_vec(&serde_json::json!({
"auth_mode": "chatgpt",
"last_refresh": "2026-08-20T00:00:00Z",
"tokens": {"id_token": tok, "access_token": "AT", "refresh_token": "RT",
"account_id": "acct-1"}}))
.unwrap(),
)
.unwrap();
chmod600(&dir.join("auth.json"));
let reg = root.join(".local/share/swapdex/slots.json");
let mut rows: Vec<serde_json::Value> = std::fs::read(®)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
rows.push(serde_json::json!({
"name": name, "id": name, "config_dir": dir.to_string_lossy(),
"adopted": false, "tool": "codex"}));
std::fs::write(®, serde_json::to_vec_pretty(&rows).unwrap()).unwrap();
}
#[test]
fn a_codex_switch_is_visible_and_agrees_across_commands() {
let t = fixture();
let root = t.path();
seed_codex_slot(root, "cx-one", "one@example.com");
seed_codex_slot(root, "cx-two", "two@example.com");
for name in ["cx-one", "cx-two", "cx-one"] {
let (_, err, code) = run(root, &["serve", name, "--tool", "codex"]);
assert_eq!(code, 0, "serve {name} --tool codex failed: {err}");
let (listing, _, _) = run(root, &["ls"]);
let marked: Vec<&str> = listing.lines().filter(|l| l.contains("pays")).collect();
assert_eq!(
marked.len(),
1,
"after serving {name} exactly one row should pay:\n{listing}"
);
assert!(
marked[0].contains(name),
"the paying row names someone else after serving {name}:\n{}",
marked[0]
);
let (quiet, _, _) = run(root, &["serve", "--quiet", "--tool", "codex"]);
assert!(
quiet.starts_with(name),
"status bar says '{}' after serving {name}",
quiet.trim()
);
}
}
#[test]
fn a_codex_row_names_its_login() {
let t = fixture();
let root = t.path();
seed_codex_slot(root, "cx-one", "one@example.com");
let (listing, _, _) = run(root, &["ls"]);
let row = listing
.lines()
.find(|l| l.contains("cx-one"))
.unwrap_or_default();
assert!(
row.contains("one@example.com"),
"the codex row does not name its login:\n{row}"
);
}
#[test]
fn renaming_a_codex_account_moves_it_everywhere() {
let t = fixture();
let root = t.path();
seed_codex_slot(root, "cx-before", "one@example.com");
let (_, err, code) = run(root, &["rename", "cx-before", "cx-after"]);
assert_eq!(code, 0, "rename failed: {err}");
let (listing, _, _) = run(root, &["ls"]);
assert!(listing.contains("cx-after"), "new name missing:\n{listing}");
assert!(
!listing.contains("cx-before"),
"old name survived:\n{listing}"
);
let (_, err, code) = run(root, &["serve", "cx-after", "--tool", "codex"]);
assert_eq!(code, 0, "serve does not know the new codex name: {err}");
}
#[test]
fn a_rename_moves_every_tool_slot() {
let t = fixture();
let root = t.path();
seed_slot(root, "both", "b@example.com");
seed_codex_slot(root, "both", "b@example.com");
let (_, err, code) = run(root, &["rename", "both", "moved"]);
assert_eq!(code, 0, "rename failed: {err}");
let (listing, _, _) = run(root, &["ls"]);
assert!(
!listing.contains("both"),
"the old name survived on some tool:\n{listing}"
);
let rows = listing.lines().filter(|l| l.contains("moved")).count();
assert_eq!(rows, 1, "the account came out split in two:\n{listing}");
for tool in ["claude-code", "codex"] {
let (_, err, code) = run(root, &["serve", "moved", "--tool", tool]);
assert_eq!(code, 0, "serve --tool {tool} does not know 'moved': {err}");
}
}
#[test]
fn dropping_a_tool_removes_its_slot_too() {
let t = fixture();
let root = t.path();
seed_slot(root, "both", "b@example.com");
seed_codex_slot(root, "both", "b@example.com");
let (out, err, code) = run(root, &["rm", "both", "--tool", "codex", "--yes"]);
assert_eq!(code, 0, "dropping the codex slot failed: {err}{out}");
let (listing, _, _) = run(root, &["ls"]);
assert!(listing.contains("both"), "the account vanished:\n{listing}");
assert!(
listing.contains("claude-code"),
"the claude slot was taken too:\n{listing}"
);
let (_, err, code) = run(root, &["serve", "both", "--tool", "codex"]);
assert_ne!(code, 0, "codex still serves after being dropped: {err}");
}
#[test]
fn ls_marks_a_slot_whose_token_has_expired() {
let t = fixture();
let root = t.path();
let dir = root.join(".local/share/swapdex/slots/old");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(".credentials.json"),
serde_json::to_vec(&serde_json::json!({"claudeAiOauth":{
"accessToken":"AT","refreshToken":"RT",
"expiresAt": 1_600_000_000_000i64, "subscriptionType":"max"}}))
.unwrap(),
)
.unwrap();
std::fs::write(
dir.join(".claude.json"),
br#"{"oauthAccount":{"emailAddress":"old@example.com"}}"#,
)
.unwrap();
chmod600(&dir.join(".credentials.json"));
std::fs::write(
root.join(".local/share/swapdex/slots.json"),
serde_json::to_vec_pretty(&serde_json::json!([{
"name": "old", "id": "old", "config_dir": dir.to_string_lossy(),
"adopted": false, "tool": "claude-code"}]))
.unwrap(),
)
.unwrap();
let (listing, _, _) = run(root, &["ls"]);
let row = listing
.lines()
.find(|l| l.contains("old"))
.unwrap_or_default();
assert!(
row.contains("expired") || row.contains("stale") || row.contains("no login"),
"an expired slot is listed as if it were fine:\n{row}"
);
}
#[test]
fn ls_lists_an_account_that_lives_only_as_a_gemini_slot() {
let td = fixture();
let root = td.path();
seed_gemini_slot(root, "kong", "kong@example.com");
let (out, err, code) = run(root, &["ls"]);
assert_eq!(code, 0, "ls failed: {err}");
assert!(
out.contains("kong"),
"a Gemini-only account is missing from the listing:\n{out}"
);
}
fn seed_gemini_slot(root: &Path, name: &str, email: &str) {
let dir = root.join(".local/share/swapdex/slots").join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("oauth_creds.json"),
serde_json::to_vec(&serde_json::json!({
"access_token": "AT", "refresh_token": "RT",
"expiry_date": 9999999999999i64}))
.unwrap(),
)
.unwrap();
std::fs::write(
dir.join("google_accounts.json"),
serde_json::to_vec(&serde_json::json!({"active": email, "old": []})).unwrap(),
)
.unwrap();
chmod600(&dir.join("oauth_creds.json"));
let reg = root.join(".local/share/swapdex/slots.json");
let mut rows: Vec<serde_json::Value> = std::fs::read(®)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
rows.push(serde_json::json!({
"name": name, "id": name, "config_dir": dir.to_string_lossy(),
"adopted": false, "tool": "gemini"}));
std::fs::write(®, serde_json::to_vec_pretty(&rows).unwrap()).unwrap();
}
#[test]
fn the_status_bar_says_when_it_has_no_usage_reading() {
let td = fixture();
let root = td.path();
seed_slot(root, "rnd", "rnd@example.com");
let (_, err, code) = run(root, &["serve", "rnd"]);
assert_eq!(code, 0, "serve failed: {err}");
let (out, err, code) = run(root, &["serve", "--quiet"]);
assert_eq!(code, 0, "serve --quiet failed: {err}");
assert!(
out.contains("usage unread"),
"the bar went silent about usage instead of saying it had no reading:\n{out:?}"
);
}
#[test]
fn a_damaged_registry_is_not_reported_as_having_no_accounts() {
let td = fixture();
let root = td.path();
let reg = root.join(".local/share/swapdex/slots.json");
std::fs::create_dir_all(reg.parent().unwrap()).unwrap();
std::fs::write(®, b"[{\"name\":\"rnd\",").unwrap();
let (out, err, _) = run(root, &["ls"]);
let all = format!("{out}{err}").to_lowercase();
assert!(
!all.contains("no accounts saved yet"),
"a damaged registry was reported as an empty one:\n{out}{err}"
);
assert!(
all.contains("registry") || all.contains("corrupt") || all.contains("could not be read"),
"nothing said the registry could not be read:\n{out}{err}"
);
}
#[test]
fn importing_an_account_for_an_unknown_tool_refuses_instead_of_guessing() {
let td = fixture();
let root = td.path();
let manifest = root.join("in.json");
std::fs::write(
&manifest,
br#"{"version":1,"accounts":[{"name":"zed","tool":"nosuchtool"},
{"name":"ok","tool":"claude-code"}]}"#,
)
.unwrap();
let (out, err, _) = run(root, &["import", manifest.to_str().unwrap()]);
let all = format!("{out}{err}");
assert!(
all.contains("nosuchtool"),
"the unknown tool was never named:\n{all}"
);
assert!(
!all.contains("created zed"),
"an account was created for a tool this build cannot serve:\n{all}"
);
assert!(
all.contains("created ok"),
"a known account was skipped:\n{all}"
);
}
#[test]
fn every_command_we_tell_the_user_to_run_exists() {
fn words(line: &str) -> Vec<(String, Option<String>)> {
let mut out = Vec::new();
let mut rest = line;
while let Some(i) = rest.find("`swapdex ") {
rest = &rest[i + "`swapdex ".len()..];
let mut it = rest.split_whitespace();
let word = |w: &str| {
if w.starts_with('-') {
return None;
}
let t: String = w
.chars()
.take_while(|c| c.is_ascii_lowercase() || *c == '-')
.collect();
(!t.is_empty()).then_some(t)
};
let Some(first) = it.next().and_then(word) else {
continue;
};
out.push((first, it.next().and_then(word)));
}
out
}
fn rs_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
for e in std::fs::read_dir(dir).unwrap().flatten() {
let p = e.path();
if p.is_dir() {
rs_files(&p, out);
} else if p.extension().is_some_and(|x| x == "rs") {
out.push(p);
}
}
}
let mut files = Vec::new();
rs_files(
Path::new(env!("CARGO_MANIFEST_DIR")).join("src").as_path(),
&mut files,
);
assert!(!files.is_empty(), "no source to read");
let help = |args: &[&str]| {
Command::new(bin())
.args(args)
.output()
.unwrap()
.status
.success()
};
let mut missing: Vec<String> = Vec::new();
for f in &files {
let text = std::fs::read_to_string(f).unwrap();
for (cmd, sub) in words(&text) {
if !help(&["help", &cmd]) {
missing.push(format!("{}: swapdex {cmd}", f.display()));
continue;
}
let listing = Command::new(bin()).args(["help", &cmd]).output().unwrap();
let listing = String::from_utf8_lossy(&listing.stdout).into_owned();
let Some(sub) = sub.filter(|_| listing.contains("Commands:")) else {
continue;
};
if !help(&[&cmd, &sub, "--help"]) {
missing.push(format!("{}: swapdex {cmd} {sub}", f.display()));
}
}
}
missing.sort();
missing.dedup();
assert!(
missing.is_empty(),
"swapdex tells the user to run commands it does not have:\n {}",
missing.join("\n ")
);
}
#[test]
fn the_status_bar_says_a_reading_stopped_arriving_not_that_it_is_late() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-a", "a@example.com");
run(root, &["add", "alpha"]);
seed_slot(root, "alpha", "a@example.com");
run(root, &["serve", "alpha"]);
let cache = root.join(".local/share/swapdex/quota-cache.json");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
std::fs::write(
&cache,
serde_json::to_vec(&serde_json::json!({
"alpha": {"seven_d": 5.0, "at": now - 6300}
}))
.unwrap(),
)
.unwrap();
let (late, _, _) = run(root, &["serve", "--quiet"]);
assert!(
late.contains("7d 95%") && late.contains("old"),
"a merely late reading still shows its number and its age: {late}"
);
std::fs::write(
&cache,
serde_json::to_vec(&serde_json::json!({
"alpha": {"seven_d": 5.0, "at": now - 6300, "token_rejected_at": now - 60}
}))
.unwrap(),
)
.unwrap();
let (rejected, _, _) = run(root, &["serve", "--quiet"]);
assert!(
rejected.contains("token rejected"),
"the bar must say why the number stopped: {rejected}"
);
assert!(
!rejected.contains("95%"),
"and must not keep offering a number nothing can refresh: {rejected}"
);
}
fn refuse_or_die(root: &Path, tool: &str) -> (String, i32) {
use std::process::Stdio;
let mut child = Command::new(bin())
.args(["proxy", "--tool", tool, "--port", "39999"])
.env("SWAPDEX_ROOT", root)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
if let Some(status) = child.try_wait().unwrap() {
let out = child.wait_with_output().unwrap();
return (
String::from_utf8_lossy(&out.stderr).into_owned(),
status.code().unwrap_or(-1),
);
}
if std::time::Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait();
panic!(
"`proxy --tool {tool}` is still running - it started a proxy instead of refusing"
);
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
#[test]
fn the_proxy_refuses_a_tool_it_has_no_relay_for() {
let t = fixture();
let root = t.path();
for tool in ["gemini", "antigravity"] {
let (err, code) = refuse_or_die(root, tool);
assert_ne!(code, 0, "`proxy --tool {tool}` must not start: {err}");
assert!(
err.contains(&format!("no {tool} relay")),
"and must say why, naming the tool: {err}"
);
assert!(
!err.contains("ANTHROPIC_BASE_URL"),
"it must never hand out Claude's variable for another tool: {err}"
);
let (out, err, code) = run(root, &["service", "install", "--tool", tool]);
assert_ne!(
code, 0,
"`service install --tool {tool}` must refuse: {out}{err}"
);
let unit = root.join(format!(".config/systemd/user/swapdex-{tool}.service"));
let plist = root.join(format!(
"Library/LaunchAgents/io.github.youdie006.swapdex.{tool}.plist"
));
assert!(
!unit.exists() && !plist.exists(),
"and must leave no unit behind"
);
}
for (tool, unit) in [("claude", "swapdex-claude"), ("codex", "swapdex-codex")] {
run(root, &["service", "install", "--tool", tool]);
let systemd = root.join(format!(".config/systemd/user/{unit}.service"));
let launchd = root.join(format!(
"Library/LaunchAgents/io.github.youdie006.swapdex.{tool}.plist"
));
assert!(
systemd.exists() || launchd.exists(),
"`service install --tool {tool}` must still write a unit"
);
}
}
#[test]
fn the_rollback_reads_the_same_keychain_item_it_writes_back_to() {
let src = std::fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR")).join("src/adapters/claude.rs"),
)
.unwrap();
let at = src
.find("fn keychain_prior()")
.expect("the rollback's prior read");
let end = src[at..].find("\n}\n").expect("the end of that function") + at;
let body: String = src[at..end]
.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
let body = body.as_str();
assert!(
body.contains("effective_computed_service()"),
"prior must read the env-derived item, the one the rollback writes: {body}"
);
assert!(
!body.contains("keychain_service()"),
"not through the fallback that can return another profile's item: {body}"
);
}
#[test]
fn the_sign_out_check_asks_the_narrow_question() {
let src =
std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/commands.rs"))
.unwrap();
let at = src
.find("sign_out_locally(paths, tool);")
.expect("the local sign-out");
let after = &src[at..];
let check = after
.find("if still_same ||")
.expect("the verification that it took");
let line: String = after[check..].lines().next().unwrap().to_string();
assert!(
line.contains("managed_present"),
"the check must ask about the item the delete targeted: {line}"
);
assert!(
!line.contains(".present("),
"not whether ANY credential can be read: {line}"
);
}
#[test]
fn no_printed_message_has_a_hole_in_the_middle_of_it() {
fn rs_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
for e in std::fs::read_dir(dir).unwrap().flatten() {
let p = e.path();
if p.is_dir() {
rs_files(&p, out);
} else if p.extension().is_some_and(|x| x == "rs")
&& p.file_name().is_some_and(|n| n != "banner.rs")
{
out.push(p);
}
}
}
let mut files = Vec::new();
rs_files(
Path::new(env!("CARGO_MANIFEST_DIR")).join("src").as_path(),
&mut files,
);
let mut bad: Vec<String> = Vec::new();
for f in &files {
for (i, line) in std::fs::read_to_string(f).unwrap().lines().enumerate() {
let t = line.trim_start();
if t.starts_with("//") || !line.contains('"') {
continue;
}
if line.contains("{:") {
continue;
}
let b = line.as_bytes();
for w in 1..b.len().saturating_sub(7) {
let prev = b[w];
let sentence = prev.is_ascii_alphabetic() || matches!(prev, b'.' | b',' | b')');
if !sentence || b[w - 1] == b'\\' {
continue;
}
let run = b[w + 1..].iter().take_while(|&&c| c == b' ').count();
if run >= 6 && b.get(w + 1 + run).is_some_and(|&c| c != b'/') {
bad.push(format!("{}:{}: {}", f.display(), i + 1, line.trim()));
break;
}
}
}
}
assert!(
bad.is_empty(),
"a printed string carries source indentation inside it:\n {}",
bad.join("\n ")
);
}
#[test]
fn a_name_no_command_would_accept_cannot_be_registered_by_another() {
let t = fixture();
let root = t.path();
let dir = root.join("adopted");
std::fs::create_dir_all(&dir).unwrap();
for bad in ["../evil", "a/b", ".hidden", "good\rEVIL"] {
let (out, err, code) = run(root, &["adopt", bad, dir.to_str().unwrap()]);
assert_ne!(code, 0, "adopt accepted {bad:?}: {out}{err}");
let (out, err, code) = run(root, &["run", bad, "--no-launch"]);
assert_ne!(code, 0, "run accepted {bad:?}: {out}{err}");
}
let listing = std::fs::read_to_string(root.join(".local/share/swapdex/slots.json"))
.unwrap_or_else(|_| "[]".into());
assert!(
!listing.contains("evil"),
"a refused name was registered: {listing}"
);
assert!(
!listing.contains("EVIL"),
"a refused name was registered: {listing}"
);
let (_, err, _) = run(root, &["adopt", "good\rEVIL", dir.to_str().unwrap()]);
assert!(
!err.contains('\r'),
"the refusal echoed the control character: {err:?}"
);
}
#[test]
fn a_name_reserved_at_creation_is_refused_by_every_creator() {
let t = fixture();
let root = t.path();
let dir = root.join("adopted");
std::fs::create_dir_all(&dir).unwrap();
let (out, err, code) = run(root, &["adopt", "-", dir.to_str().unwrap()]);
assert_ne!(code, 0, "adopt accepted '-': {out}{err}");
let (out, err, code) = run(root, &["run", "-", "--no-launch"]);
assert_ne!(code, 0, "run accepted '-': {out}{err}");
let (out, err, code) = run(root, &["adopt", "--", "-x", dir.to_str().unwrap()]);
assert_ne!(code, 0, "adopt accepted '-x': {out}{err}");
let (out, err, code) = run(root, &["run", "--no-launch", "--", "-x"]);
assert_ne!(code, 0, "run accepted '-x': {out}{err}");
let (out, err, code) = run(root, &["add", "--", "-x"]);
assert_ne!(code, 0, "add accepted '-x': {out}{err}");
let rows: Vec<serde_json::Value> = std::fs::read(root.join(".local/share/swapdex/slots.json"))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
for r in &rows {
let n = r["name"].as_str().unwrap_or_default();
assert!(
!n.starts_with('-'),
"a name reserved at creation was registered: {rows:?}"
);
}
}
#[test]
fn an_account_already_named_dash_can_still_be_renamed_and_removed() {
let t = fixture();
let root = t.path();
seed_slot(root, "-", "dash@example.com");
let (out, err, code) = run(root, &["rename", "-", "dash"]);
assert_eq!(code, 0, "a legacy '-' could not be renamed: {out}{err}");
seed_slot(root, "-", "dash2@example.com");
let (out, err, code) = run(root, &["rm", "-", "--yes"]);
assert_eq!(code, 0, "a legacy '-' could not be removed: {out}{err}");
}
#[test]
fn rotation_preferences_follow_a_rename_and_leave_with_a_removal() {
let t = fixture();
let root = t.path();
let settings = root.join(".local/share/swapdex/settings.json");
seed_claude(root, "uuid-a", "a@example.com");
run(root, &["add", "alpha"]);
seed_slot(root, "alpha", "a@example.com");
std::fs::write(
&settings,
br#"{"disabled":["alpha"],"priority":["other","alpha"]}"#,
)
.unwrap();
let (_, err, code) = run(root, &["rename", "alpha", "alpha2"]);
assert_eq!(code, 0, "rename failed: {err}");
let after = std::fs::read_to_string(&settings).unwrap();
assert!(
!after.contains("\"alpha\""),
"the old name still holds the preferences after a rename: {after}"
);
assert!(
after.contains("alpha2"),
"the pause did not follow the account: {after}"
);
let (out, err, code) = run(root, &["rm", "alpha2", "--yes"]);
assert_eq!(code, 0, "rm failed: {err}");
assert!(
out.contains("still here"),
"expected a partial removal: {out}"
);
let after = std::fs::read_to_string(&settings).unwrap();
assert!(
after.contains("alpha2"),
"a partial removal dropped preferences the profile still owns: {after}"
);
let (_, err, code) = run(root, &["rm", "alpha2", "--yes"]);
assert_eq!(code, 0, "rm failed: {err}");
let after = std::fs::read_to_string(&settings).unwrap();
assert!(
!after.contains("alpha2"),
"a removed account left its preferences for the next account of that name: {after}"
);
}
#[test]
fn remembered_usage_follows_a_rename_and_leaves_with_a_removal() {
let t = fixture();
let root = t.path();
let store = root.join(".local/share/swapdex");
let claude_cache = store.join("quota-cache.json");
let codex_cache = store.join("codex-quota-cache.json");
seed_claude(root, "uuid-a", "a@example.com");
run(root, &["add", "alpha"]);
seed_slot(root, "alpha", "a@example.com");
let reading = |pct: f64| {
serde_json::to_vec(&serde_json::json!({"alpha": {
"five_h": pct, "five_h_reset": 9_000_000_000i64,
"seven_d": pct, "seven_d_reset": 9_000_000_000i64, "at": 1_700_000_000i64}}))
.unwrap()
};
std::fs::write(&claude_cache, reading(22.0)).unwrap();
std::fs::write(&codex_cache, reading(44.0)).unwrap();
let (_, err, code) = run(root, &["rename", "alpha", "alpha2"]);
assert_eq!(code, 0, "rename failed: {err}");
for f in [&claude_cache, &codex_cache] {
let after = std::fs::read_to_string(f).unwrap();
assert!(
!after.contains("\"alpha\""),
"{}: the old name still holds the reading: {after}",
f.display()
);
assert!(
after.contains("alpha2"),
"{}: the reading did not follow the account: {after}",
f.display()
);
}
let (out, err, code) = run(root, &["rm", "alpha2", "--yes"]);
assert_eq!(code, 0, "rm failed: {err}");
assert!(
out.contains("still here"),
"expected a partial removal: {out}"
);
assert!(
std::fs::read_to_string(&claude_cache)
.unwrap()
.contains("alpha2"),
"a partial removal dropped a reading the profile still owns"
);
let (_, err, code) = run(root, &["rm", "alpha2", "--yes"]);
assert_eq!(code, 0, "rm failed: {err}");
for f in [&claude_cache, &codex_cache] {
let after = std::fs::read_to_string(f).unwrap();
assert!(
!after.contains("alpha2"),
"{}: a removed account left its numbers for the next account of that name: {after}",
f.display()
);
}
}
#[test]
fn dropping_one_tool_keeps_the_side_state_the_account_still_owns() {
let t = fixture();
let root = t.path();
let store = root.join(".local/share/swapdex");
let settings = store.join("settings.json");
let claude_cache = store.join("quota-cache.json");
seed_slot(root, "both", "b@example.com");
seed_codex_slot(root, "both", "b@example.com");
std::fs::write(
&settings,
br#"{"disabled":["both"],"priority":["other","both"]}"#,
)
.unwrap();
std::fs::write(
&claude_cache,
serde_json::to_vec(&serde_json::json!({"both": {
"five_h": 22.0, "five_h_reset": 9_000_000_000i64,
"seven_d": 22.0, "seven_d_reset": 9_000_000_000i64, "at": 1_700_000_000i64}}))
.unwrap(),
)
.unwrap();
let (out, err, code) = run(root, &["rm", "both", "--tool", "codex", "--yes"]);
assert_eq!(code, 0, "dropping the codex slot failed: {err}{out}");
let (listing, _, _) = run(root, &["ls"]);
assert!(listing.contains("both"), "the account vanished:\n{listing}");
let after = std::fs::read_to_string(&settings).unwrap();
assert!(
after.contains("both"),
"dropping one tool un-paused an account that is still listed: {after}"
);
let after = std::fs::read_to_string(&claude_cache).unwrap();
assert!(
after.contains("both"),
"dropping one tool blanked a reading the account still owns: {after}"
);
}
#[test]
fn removing_an_account_removes_it_under_every_tool() {
let t = fixture();
let root = t.path();
let store = root.join(".local/share/swapdex");
let settings = store.join("settings.json");
seed_slot(root, "both", "b@example.com");
seed_codex_slot(root, "both", "b@example.com");
std::fs::write(
&settings,
br#"{"disabled":["both"],"priority":["other","both"]}"#,
)
.unwrap();
let (out, err, code) = run(root, &["rm", "both", "--yes"]);
assert_eq!(code, 0, "removing the account failed: {err}{out}");
let (listing, _, _) = run(root, &["ls"]);
assert!(
!listing.contains("both"),
"one `rm` left half the account listed under the other tool:\n{listing}"
);
let (slots, _, _) = run(root, &["slots"]);
assert!(
!slots.contains("both"),
"the slot survived under a tool `rm` never opened:\n{slots}"
);
let after = std::fs::read_to_string(&settings).unwrap();
assert!(
!after.contains("both"),
"a fully removed account kept its rotation preferences: {after}"
);
}
#[test]
fn the_ledger_follows_a_slot_only_rename() {
let t = fixture();
let root = t.path();
let timeline = root.join(".local/share/swapdex/timeline.jsonl");
seed_claude(root, "uuid-live", "live@example.com");
seed_slot(root, "before", "b@example.com");
let (_, err, code) = run(root, &["use", "before"]);
assert_eq!(code, 0, "switching to the slot failed: {err}");
let ledger = std::fs::read_to_string(&timeline).unwrap();
assert!(
ledger.contains("\"before\""),
"the switch left no record to carry: {ledger}"
);
let (_, err, code) = run(root, &["rename", "before", "after"]);
assert_eq!(code, 0, "rename failed: {err}");
let (listing, _, _) = run(root, &["ls"]);
assert!(
!listing.contains("before") && listing.contains("after"),
"the listing did not follow the rename:\n{listing}"
);
let ledger = std::fs::read_to_string(&timeline).unwrap();
assert!(
!ledger.contains("\"before\""),
"the ledger still credits a name nothing answers to: {ledger}"
);
assert!(
ledger.contains("\"after\""),
"the ledger did not follow the account: {ledger}"
);
}
#[test]
fn a_removed_account_does_not_hand_its_usage_to_the_next_account_of_that_name() {
let t = fixture();
let root = t.path();
let timeline = root.join(".local/share/swapdex/timeline.jsonl");
seed_claude(root, "uuid-live", "live@example.com");
seed_slot(root, "keeper", "keeper@example.com");
seed_slot(root, "ghost", "ghost@example.com");
for name in ["keeper", "ghost"] {
let (_, err, code) = run(root, &["use", name]);
assert_eq!(code, 0, "switching to {name} failed: {err}");
}
let ledger = std::fs::read_to_string(&timeline).unwrap();
let last: serde_json::Value = serde_json::from_str(ledger.lines().last().unwrap()).unwrap();
let spent_at = last["ts"].as_i64().unwrap() + 60;
let proj = root.join(".claude/projects/p");
std::fs::create_dir_all(&proj).unwrap();
std::fs::write(
proj.join("s.jsonl"),
serde_json::to_vec(&serde_json::json!({
"timestamp": swapdex::refresh::rfc3339_utc(spent_at),
"message": {"usage": {"input_tokens": 12000, "output_tokens": 3000}}}))
.unwrap(),
)
.unwrap();
let (out, _, _) = run(root, &["usage"]);
assert!(
out.contains("@ghost"),
"the switch did not attribute the tokens to ghost:\n{out}"
);
let (_, err, code) = run(root, &["rm", "ghost", "--yes"]);
assert_eq!(code, 0, "rm failed: {err}");
seed_slot(root, "ghost", "stranger@example.com");
let (out, _, _) = run(root, &["usage"]);
assert!(
!out.contains("@ghost"),
"a removed account handed its tokens to the next account of that name:\n{out}"
);
assert!(
!out.contains("@keeper"),
"erasing the removed account moved its tokens onto the account before it:\n{out}"
);
}
#[test]
fn one_directory_is_one_account() {
let t = fixture();
let root = t.path();
let shared = root.join("home/.claude-shared");
std::fs::create_dir_all(&shared).unwrap();
let shared = shared.to_str().unwrap();
let (out, err, code) = run(root, &["adopt", "alpha", shared]);
assert_eq!(code, 0, "the first adopt was refused: {out}{err}");
let (out, err, code) = run(root, &["adopt", "beta", shared]);
assert_ne!(
code, 0,
"a second account was registered on alpha's directory: {out}{err}"
);
assert!(
err.contains("alpha"),
"the refusal has to name the account already holding it: {err}"
);
let (listing, _, _) = run(root, &["ls"]);
assert!(
!listing.contains("beta"),
"a refused adopt still left a row:\n{listing}"
);
run(root, &["use", "alpha"]);
let (quiet, _, _) = run(root, &["serve", "--quiet"]);
assert!(
quiet.starts_with("alpha"),
"the status bar credits '{}' after `use alpha`",
quiet.trim()
);
}
#[test]
fn each_tool_may_adopt_the_same_directory() {
let t = fixture();
let root = t.path();
let shared = root.join("home/.shared");
std::fs::create_dir_all(&shared).unwrap();
let shared = shared.to_str().unwrap();
let (out, err, code) = run(root, &["adopt", "alpha", shared]);
assert_eq!(code, 0, "the claude adopt was refused: {out}{err}");
let (out, err, code) = run(root, &["adopt", "alpha", shared, "--tool", "codex"]);
assert_eq!(
code, 0,
"codex could not adopt the directory claude holds: {out}{err}"
);
}
#[test]
fn a_codex_only_account_is_reachable_by_a_bare_use() {
let t = fixture();
let root = t.path();
seed_codex_slot(root, "cxonly", "cx@example.com");
let (listing, _, _) = run(root, &["ls"]);
assert!(
listing.contains("cxonly"),
"`ls` does not list it:\n{listing}"
);
let (_, err, code) = run(root, &["use", "cxonly"]);
assert_eq!(
code, 0,
"`use cxonly` rejected an account `ls` shows: {err}"
);
let (after, _, _) = run(root, &["ls"]);
let marked: Vec<&str> = after.lines().filter(|l| l.contains("pays")).collect();
assert_eq!(
marked.len(),
1,
"after `use cxonly` exactly one row should pay:\n{after}"
);
assert!(
marked[0].contains("cxonly"),
"the paying row names someone else:\n{}",
marked[0]
);
}
#[test]
fn use_moves_every_tool_of_an_account_that_spans_two() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-mix", "mix@example.com");
run(root, &["add", "mix"]);
seed_codex_slot(root, "mix", "mix@example.com");
seed_claude(root, "uuid-oth", "oth@example.com");
run(root, &["add", "other"]);
let (_, err, code) = run(root, &["use", "mix"]);
assert_eq!(code, 0, "`use mix` failed: {err}");
let (after, _, _) = run(root, &["ls"]);
let row = after
.lines()
.find(|l| l.trim_start_matches(['*', ' ']).starts_with("mix "))
.unwrap_or_else(|| panic!("no row for mix:\n{after}"));
assert!(
row.contains("claude-code*"),
"the claude half did not move:\n{after}"
);
assert!(
row.contains("pays"),
"the codex half did not move:\n{after}"
);
}
#[test]
fn a_slot_switch_does_not_copy_over_the_live_login() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-dual", "dual@example.com");
seed_live_codex(root, "dual@example.com");
run(root, &["add", "dual"]);
run(root, &["migrate", "--tool", "claude"]);
seed_claude(root, "uuid-oth", "oth@example.com");
run(root, &["add", "other"]);
let (_, err, code) = run(root, &["use", "dual"]);
assert_eq!(code, 0, "`use dual` failed: {err}");
let (after, _, _) = run(root, &["ls"]);
let marked: Vec<&str> = after.lines().filter(|l| l.contains("pays")).collect();
assert_eq!(marked.len(), 1, "exactly one row should pay:\n{after}");
assert!(
marked[0].contains("dual"),
"the paying row names someone else:\n{}",
marked[0]
);
assert_eq!(
live_claude_email(root),
"oth@example.com",
"the live claude login was overwritten:\n{after}"
);
}
#[test]
fn restore_undoes_a_slot_switch_not_an_older_backup() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-a", "alpha@example.com");
run(root, &["add", "alpha"]);
seed_claude(root, "uuid-b", "beta@example.com");
run(root, &["add", "beta"]);
run(root, &["migrate", "--tool", "claude"]);
seed_claude(root, "uuid-g", "gamma@example.com");
run(root, &["add", "gamma"]);
seed_claude(root, "uuid-d", "delta@example.com");
run(root, &["use", "gamma"]);
run(root, &["use", "beta"]);
run(root, &["use", "alpha"]);
let (_, err, code) = run(root, &["restore"]);
assert_eq!(code, 0, "`restore` failed: {err}");
let (after, _, _) = run(root, &["ls"]);
let marked: Vec<&str> = after.lines().filter(|l| l.contains("pays")).collect();
assert_eq!(marked.len(), 1, "exactly one row should pay:\n{after}");
assert!(
marked[0].contains("beta"),
"the slot switch was not undone:\n{after}"
);
assert_eq!(
live_claude_email(root),
"gamma@example.com",
"the live login was overwritten from an older backup:\n{after}"
);
}
#[test]
fn restore_of_a_copy_model_switch_still_uses_the_backup() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-a", "alpha@example.com");
run(root, &["add", "alpha"]);
seed_claude(root, "uuid-b", "beta@example.com");
run(root, &["add", "beta"]);
run(root, &["migrate", "--tool", "claude"]);
seed_claude(root, "uuid-g", "gamma@example.com");
run(root, &["add", "gamma"]);
seed_claude(root, "uuid-d", "delta@example.com");
let (before, _, _) = run(root, &["ls"]);
let payer_before: Vec<&str> = before.lines().filter(|l| l.contains("pays")).collect();
run(root, &["use", "gamma"]);
let (out, err, code) = run(root, &["restore"]);
assert_eq!(code, 0, "`restore` failed: {err}");
assert!(
out.contains("delta@example.com"),
"the backed-up login did not come back:\n{out}"
);
let (after, _, _) = run(root, &["ls"]);
let payer_after: Vec<&str> = after.lines().filter(|l| l.contains("pays")).collect();
assert_eq!(
payer_before, payer_after,
"a copy-model restore moved the default pointer:\n{after}"
);
}
#[test]
fn restore_reaches_past_a_slot_switch_that_changed_nothing() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-a", "alpha@example.com");
run(root, &["add", "alpha"]);
seed_claude(root, "uuid-b", "beta@example.com");
run(root, &["add", "beta"]);
run(root, &["migrate", "--tool", "claude"]);
run(root, &["use", "beta"]);
run(root, &["use", "alpha"]);
run(root, &["use", "alpha"]);
let (_, err, code) = run(root, &["restore"]);
assert_eq!(code, 0, "`restore` failed: {err}");
let (after, _, _) = run(root, &["ls"]);
let marked: Vec<&str> = after.lines().filter(|l| l.contains("pays")).collect();
assert_eq!(marked.len(), 1, "exactly one row should pay:\n{after}");
assert!(
marked[0].contains("beta"),
"`restore` stopped at the switch that changed nothing:\n{after}"
);
}
#[test]
fn a_slot_only_account_answers_to_its_prefix() {
let t = fixture();
let root = t.path();
seed_slot(root, "personal", "personal@example.com");
seed_slot(root, "workacct", "workacct@example.com");
let (listed, _, _) = run(root, &["ls"]);
assert!(
listed.contains("personal"),
"the slot is listed under this name:\n{listed}"
);
let (out, err, code) = run(root, &["use", "pers"]);
assert_eq!(code, 0, "a unique prefix should switch: {err}{out}");
let (after, _, _) = run(root, &["ls"]);
let pays: Vec<&str> = after.lines().filter(|l| l.contains("pays")).collect();
assert_eq!(pays.len(), 1, "exactly one row should pay:\n{after}");
assert!(
pays[0].contains("personal"),
"the prefix should have switched the account it matched:\n{after}"
);
}
#[test]
fn the_toggle_reaches_a_slot_only_account() {
let t = fixture();
let root = t.path();
seed_slot(root, "personal", "personal@example.com");
seed_slot(root, "workacct", "workacct@example.com");
let (_, err, code) = run(root, &["use", "-"]);
assert_ne!(
code, 0,
"nothing has been switched yet, so '-' means nothing"
);
assert!(
err.contains("personal") && err.contains("workacct"),
"the remedy has to name the accounts that exist:\n{err}"
);
run(root, &["use", "personal"]);
run(root, &["use", "workacct"]);
let (out, err, code) = run(root, &["use", "-"]);
assert_eq!(
code, 0,
"'-' should go back to the previous slot: {err}{out}"
);
let (after, _, _) = run(root, &["ls"]);
let pays: Vec<&str> = after.lines().filter(|l| l.contains("pays")).collect();
assert_eq!(pays.len(), 1, "exactly one row should pay:\n{after}");
assert!(
pays[0].contains("personal"),
"'-' should have gone back to the account before this one:\n{after}"
);
}
#[test]
fn a_prefix_on_a_migrated_account_repoints_its_slot() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-a", "alpha@example.com");
run(root, &["add", "alpha"]);
seed_claude(root, "uuid-b", "beta@example.com");
run(root, &["add", "beta"]);
run(root, &["migrate", "--tool", "claude"]);
let (exact, _, _) = run(root, &["use", "alpha"]);
let (prefix, err, code) = run(root, &["use", "bet"]);
assert_eq!(code, 0, "a unique prefix should switch: {err}{prefix}");
assert_eq!(
prefix.lines().next().map(|l| l.replace("beta", "alpha")),
exact.lines().next().map(|l| l.to_string()),
"the prefix took a different path than the name it expands to:\n\
exact: {exact}\nprefix: {prefix}"
);
}
#[test]
fn a_prefix_resolves_only_against_the_selected_tool() {
let t = fixture();
let root = t.path();
seed_slot(root, "shared-claude", "claude@example.com");
seed_codex_slot(root, "shared-codex", "codex@example.com");
let (out, err, code) = run(root, &["use", "shared", "--tool", "codex"]);
assert_eq!(code, 0, "'shared' names one codex account: {err}{out}");
let (after, _, _) = run(root, &["ls"]);
assert!(
after
.lines()
.any(|l| l.contains("shared-codex") && l.contains("pays")),
"the codex account the prefix names should be paying:\n{after}"
);
}
fn fake_tool(root: &Path, bin_name: &str, marker: &str) -> String {
use std::os::unix::fs::PermissionsExt;
let dir = root.join("fakebin");
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join(bin_name);
std::fs::write(&p, format!("#!/bin/sh\necho {marker}\n")).unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
format!(
"{}:{}",
dir.display(),
std::env::var("PATH").unwrap_or_default()
)
}
fn run_on_path(root: &Path, args: &[&str], path: &str) -> (String, String, i32) {
let out = Command::new(bin())
.args(args)
.env("SWAPDEX_ROOT", root)
.env("PATH", path)
.stdin(std::process::Stdio::null())
.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 open_reaches_a_slot_only_account() {
let t = fixture();
let root = t.path();
seed_slot(root, "personal", "personal@example.com");
seed_slot(root, "workacct", "workacct@example.com");
let path = fake_tool(root, "claude", "CLAUDE-OPENED");
let (out, err, code) = run_on_path(
root,
&["use", "workacct", "--tool", "claude", "--open"],
&path,
);
assert_eq!(code, 0, "`use --open` on a listed account: {err}{out}");
assert!(
out.contains("CLAUDE-OPENED"),
"claude should launch after the switch:\n{out}{err}"
);
let (after, _, _) = run(root, &["ls"]);
assert!(
after
.lines()
.any(|l| l.contains("workacct") && l.contains("pays")),
"`--open` must make the switch `use` makes:\n{after}"
);
}
#[test]
fn open_does_not_copy_over_the_slot_it_switched() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-dual", "dual@example.com");
run(root, &["add", "dual"]);
run(root, &["migrate", "--tool", "claude"]);
seed_claude(root, "uuid-oth", "oth@example.com");
run(root, &["add", "other"]);
let path = fake_tool(root, "claude", "CLAUDE-OPENED");
let (out, err, code) = run_on_path(root, &["use", "dual", "--tool", "claude", "--open"], &path);
assert_eq!(code, 0, "`use dual --open` failed: {err}{out}");
let (after, _, _) = run(root, &["ls"]);
let marked: Vec<&str> = after.lines().filter(|l| l.contains("pays")).collect();
assert_eq!(marked.len(), 1, "exactly one row should pay:\n{after}");
assert!(
marked[0].contains("dual"),
"the paying row names someone else:\n{}",
marked[0]
);
assert_eq!(
live_claude_email(root),
"oth@example.com",
"the live claude login was overwritten:\n{after}"
);
}
#[test]
fn open_does_not_launch_a_failed_switch() {
use std::os::unix::fs::PermissionsExt;
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-a", "a@example.com");
run(root, &["add", "alpha"]);
seed_claude(root, "uuid-b", "b@example.com");
run(root, &["add", "beta"]);
let path = fake_tool(root, "claude", "CLAUDE-OPENED");
let live = root.join(".claude");
std::fs::set_permissions(&live, std::fs::Permissions::from_mode(0o500)).unwrap();
let (out, err, code) =
run_on_path(root, &["use", "alpha", "--tool", "claude", "--open"], &path);
std::fs::set_permissions(&live, std::fs::Permissions::from_mode(0o700)).unwrap();
assert_ne!(
code, 0,
"a switch that could not write should not report success:\n{out}{err}"
);
assert!(
!out.contains("CLAUDE-OPENED"),
"claude launched after a failed switch:\n{out}{err}"
);
}
fn run_ui(root: &Path, keys: &str) -> (String, String, i32) {
use std::io::Write;
let mut child = Command::new(bin())
.arg("ui")
.env("SWAPDEX_ROOT", root)
.env("SWAPDEX_ASSUME_TTY", "1")
.env("TERM", "dumb")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(keys.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),
)
}
#[cfg(unix)]
fn run_fullscreen_ui(root: &Path, keys: &[u8]) -> (String, i32) {
use std::io::{Read, Write};
use std::os::fd::FromRawFd;
use std::os::unix::process::CommandExt;
let mut master = -1;
let mut slave = -1;
let mut size = libc::winsize {
ws_row: 40,
ws_col: 120,
ws_xpixel: 0,
ws_ypixel: 0,
};
let rc = unsafe {
libc::openpty(
&mut master,
&mut slave,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::addr_of_mut!(size),
)
};
assert_eq!(rc, 0, "openpty failed");
let mut master = unsafe { std::fs::File::from_raw_fd(master) };
let slave = unsafe { std::fs::File::from_raw_fd(slave) };
let mut cmd = Command::new(bin());
cmd.arg("ui")
.env("SWAPDEX_ROOT", root)
.env("TERM", "xterm")
.stdin(std::process::Stdio::from(slave.try_clone().unwrap()))
.stdout(std::process::Stdio::from(slave.try_clone().unwrap()))
.stderr(std::process::Stdio::from(slave));
unsafe {
cmd.pre_exec(|| {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
if libc::ioctl(0, libc::TIOCSCTTY as _, 0) == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
let mut child = cmd.spawn().unwrap();
drop(cmd);
unsafe {
let fd = std::os::fd::AsRawFd::as_raw_fd(&master);
let fl = libc::fcntl(fd, libc::F_GETFL);
assert_ne!(fl, -1, "F_GETFL on the pty master");
assert_ne!(
libc::fcntl(fd, libc::F_SETFL, fl | libc::O_NONBLOCK),
-1,
"F_SETFL O_NONBLOCK on the pty master"
);
}
let mut seen: Vec<u8> = Vec::new();
let drain = |m: &mut std::fs::File, out: &mut Vec<u8>| {
let mut buf = [0u8; 4096];
loop {
match m.read(&mut buf) {
Ok(0) => break,
Ok(n) => out.extend_from_slice(&buf[..n]),
Err(_) => break,
}
}
};
let mut input = master.try_clone().unwrap();
let keys = keys.to_vec();
let writer = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(300));
input.write_all(&keys).unwrap();
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let status = loop {
drain(&mut master, &mut seen);
if let Some(status) = child.try_wait().unwrap() {
break status;
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
drain(&mut master, &mut seen);
let text = String::from_utf8_lossy(&seen);
panic!(
"full-screen ui did not exit after the supplied keys; \
the pty received {} bytes:\n{}",
text.len(),
text.chars().take(2000).collect::<String>()
);
}
std::thread::sleep(std::time::Duration::from_millis(20));
};
writer.join().unwrap();
drain(&mut master, &mut seen);
(
String::from_utf8_lossy(&seen).into_owned(),
status.code().unwrap_or(-1),
)
}
#[cfg(unix)]
#[test]
fn the_fullscreen_menu_lists_a_gemini_slot_only_account() {
let t = fixture();
let root = t.path();
seed_gemini_slot(root, "kong", "kong@example.com");
let (out, code) = run_fullscreen_ui(root, b"q");
assert_eq!(code, 0, "full-screen menu failed:\n{out}");
assert!(
out.contains("kong"),
"the Gemini account listed by ls is missing from the full-screen menu:\n{out}"
);
}
fn menu_number(out: &str, name: &str) -> String {
out.lines()
.find(|l| l.contains(name))
.and_then(|l| l.trim().split(')').next().map(str::to_string))
.unwrap_or_else(|| panic!("no menu row for {name}:\n{out}"))
}
#[test]
fn the_menu_lists_a_slot_only_account() {
let t = fixture();
let root = t.path();
seed_slot(root, "personal", "personal@example.com");
let (ls_out, _, _) = run(root, &["ls"]);
assert!(
ls_out.contains("personal"),
"fixture check - `ls` should list the slot:\n{ls_out}"
);
let (out, err, code) = run_ui(root, "\n");
assert_eq!(code, 0, "menu should open:\n{out}{err}");
assert!(
!out.contains("No accounts saved yet"),
"an install with a registered slot was told it has nothing:\n{out}{err}"
);
assert!(
out.contains("personal"),
"the account `ls` lists is missing from the menu:\n{out}{err}"
);
assert!(
out.contains("personal@example.com"),
"the slot row named nobody, so a switch to it is unverifiable:\n{out}{err}"
);
}
#[test]
fn the_menu_numbers_every_account_ls_shows() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-a", "a@example.com");
run(root, &["add", "alpha"]);
seed_slot(root, "beta", "b@example.com");
let (out, err, code) = run_ui(root, "\n");
assert_eq!(code, 0, "menu should open:\n{out}{err}");
assert!(
out.contains("alpha"),
"the snapshot account is missing from the menu:\n{out}{err}"
);
assert!(
out.contains("beta"),
"the slot account is missing from the menu:\n{out}{err}"
);
let n = menu_number(&out, "beta");
let (out2, err2, code2) = run_ui(root, &format!("{n}\n"));
assert_eq!(code2, 0, "picking the slot row failed:\n{out2}{err2}");
let (ls_out, _, _) = run(root, &["ls"]);
assert!(
ls_out
.lines()
.any(|l| l.contains("beta") && l.contains("<- pays")),
"the menu pick did not switch to the slot account:\n{ls_out}\n{out2}{err2}"
);
}
#[test]
fn the_menu_offers_the_tool_held_by_a_slot_only_account() {
let t = fixture();
let root = t.path();
seed_slot(root, "personal", "personal@example.com");
let (out, err, code) = run_ui(root, "1\n\n");
assert_eq!(code, 0, "menu switch failed:\n{out}{err}");
assert!(
out.contains("c new claude"),
"the slot's tool disappeared from the post-switch menu:\n{out}{err}"
);
}
#[test]
fn add_refuses_a_name_a_different_account_already_holds() {
let t = fixture();
let root = t.path();
seed_slot(root, "personal", "personal@example.com");
seed_claude(root, "work-uuid", "work@example.com");
let (out, err, code) = run(root, &["add", "personal", "--tool", "claude-code"]);
assert_ne!(
code, 0,
"add saved over a name a different account's slot already holds:\n{out}{err}"
);
let (ls_out, ls_err, _) = run(root, &["ls"]);
assert!(
!ls_err.contains("DIFFERENT"),
"add created the very state `ls` warns about:\n{ls_out}{ls_err}"
);
}
#[test]
fn add_still_snapshots_a_slot_holding_the_same_account() {
let t = fixture();
let root = t.path();
seed_slot(root, "personal", "personal@example.com");
seed_claude(root, "personal", "personal@example.com");
let (out, err, code) = run(root, &["add", "personal", "--tool", "claude-code"]);
assert_eq!(
code, 0,
"add refused to snapshot the account its own slot holds:\n{out}{err}"
);
}
#[test]
fn add_accepts_a_name_whose_slot_holds_nothing_readable() {
let t = fixture();
let root = t.path();
seed_slot(root, "personal", "personal@example.com");
let dir = root.join(".local/share/swapdex/slots/personal");
std::fs::remove_file(dir.join(".credentials.json")).unwrap();
std::fs::remove_file(dir.join(".claude.json")).unwrap();
seed_claude(root, "work-uuid", "work@example.com");
let (out, err, code) = run(root, &["add", "personal", "--tool", "claude-code"]);
assert_eq!(
code, 0,
"add refused over a slot holding no readable account:\n{out}{err}"
);
}
#[test]
fn login_rescues_a_new_account_from_a_slot_name_collision() {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
let t = fixture();
let root = t.path();
seed_slot(root, "personal", "personal@example.com");
seed_claude(root, "old-uuid", "old@example.com");
let (_, err, code) = run(root, &["add", "old", "--tool", "claude-code"]);
assert_eq!(code, 0, "could not save the outgoing account: {err}");
let dir = root.join("fakebin-login-collision");
std::fs::create_dir_all(&dir).unwrap();
let fake = dir.join("claude");
std::fs::write(
&fake,
r#"#!/bin/sh
mkdir -p "$SWAPDEX_ROOT/.claude"
printf '%s' '{"claudeAiOauth":{"accessToken":"AT-NEW","refreshToken":"RT-NEW","expiresAt":9999999999999,"subscriptionType":"max"}}' > "$SWAPDEX_ROOT/.claude/.credentials.json"
printf '%s' '{"oauthAccount":{"accountUuid":"new-uuid","emailAddress":"new@example.com"}}' > "$SWAPDEX_ROOT/.claude.json"
"#,
)
.unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = format!(
"{}:{}",
dir.display(),
std::env::var("PATH").unwrap_or_default()
);
let mut child = Command::new(bin())
.args(["login", "personal", "--tool", "claude-code"])
.env("SWAPDEX_ROOT", root)
.env("SWAPDEX_ASSUME_TTY", "1")
.env("PATH", path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(b"y\nrescued\n")
.unwrap();
let output = child.wait_with_output().unwrap();
let out = String::from_utf8_lossy(&output.stdout);
let err = String::from_utf8_lossy(&output.stderr);
assert_eq!(
output.status.code().unwrap_or(-1),
0,
"login failed:\n{out}{err}"
);
assert!(
out.contains("saved profile 'rescued'"),
"the new login was not rescued under a free name:\n{out}{err}"
);
let (listing, listing_err, _) = run(root, &["ls"]);
assert!(
!listing_err.contains("DIFFERENT"),
"login minted the crossed name that ls warns about:\n{listing}{listing_err}"
);
assert!(
listing.contains("personal") && listing.contains("rescued"),
"both accounts should remain reachable under distinct names:\n{listing}{listing_err}"
);
}
fn run_stdin(root: &Path, args: &[&str], input: &str) -> (String, String, i32) {
use std::io::Write;
let mut child = Command::new(bin())
.args(args)
.env("SWAPDEX_ROOT", root)
.env("SWAPDEX_ASSUME_TTY", "1")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.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 setup_does_not_auto_attach_over_a_different_slots_name() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "personal@example.com");
seed_live_codex(root, "codex@example.com");
let (_, err, code) = run(root, &["add", "work", "--tool", "codex"]);
assert_eq!(code, 0, "could not seed the Codex profile: {err}");
seed_claude(root, "work-uuid", "work@example.com");
let (out, err, code) = run_stdin(root, &["setup"], "safe\n\n");
assert_eq!(code, 0, "setup failed:\n{out}{err}");
assert!(
out.contains("saved as 'safe'"),
"setup did not ask for a name clear of the existing slot:\n{out}{err}"
);
let (listing, listing_err, _) = run(root, &["ls"]);
assert!(
!listing_err.contains("DIFFERENT"),
"setup auto-attached over a different slot account:\n{listing}{listing_err}"
);
}
#[test]
fn setup_recognises_an_existing_slot_only_account() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "work@example.com");
let (out, err, code) = run_stdin(root, &["setup"], "n\n");
assert_eq!(code, 0, "setup failed:\n{out}{err}");
assert!(
!out.contains("No accounts saved yet"),
"setup called the slot account nonexistent:\n{out}{err}"
);
assert!(
out.contains("You're set - saved: work."),
"setup did not acknowledge the account every other command sees:\n{out}{err}"
);
}
#[test]
fn onboard_recognises_a_snapshot_account_that_was_not_migrated() {
let t = fixture();
let root = t.path();
seed_claude(root, "work-uuid", "work@example.com");
let (_, err, code) = run(root, &["add", "work", "--tool", "claude-code"]);
assert_eq!(code, 0, "could not seed the saved account: {err}");
let (out, err, code) = run(root, &["onboard"]);
assert_eq!(code, 0, "onboard failed:\n{out}{err}");
assert!(
!out.contains("No accounts yet"),
"onboard called the snapshot account nonexistent:\n{out}{err}"
);
assert!(
out.contains("swapdex ui` shows your accounts"),
"onboard did not acknowledge the account every other command sees:\n{out}{err}"
);
}
#[test]
fn setup_refuses_a_name_a_different_account_already_holds() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "personal@example.com");
seed_claude(root, "work-uuid", "work@example.com");
let (out, err, code) = run_stdin(root, &["setup"], "\nskip\n");
assert_eq!(code, 0, "setup failed:\n{out}{err}");
let (ls_out, ls_err, _) = run(root, &["ls"]);
assert!(
!ls_err.contains("DIFFERENT"),
"setup minted the crossed name ls warns about:\n{out}\n--- ls ---\n{ls_out}{ls_err}"
);
}
#[test]
fn setup_still_saves_under_a_slot_holding_the_same_account() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "work@example.com");
seed_claude(root, "work", "work@example.com");
let (out, err, code) = run_stdin(root, &["setup"], "\n");
assert_eq!(code, 0, "setup failed:\n{out}{err}");
assert!(
out.contains("saved as 'work'"),
"setup refused a slot holding the SAME account:\n{out}{err}"
);
}
#[test]
fn setup_accepts_a_name_whose_slot_holds_nothing_readable() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "work@example.com");
let dir = root.join(".local/share/swapdex/slots/work");
std::fs::remove_file(dir.join(".credentials.json")).unwrap();
std::fs::remove_file(dir.join(".claude.json")).unwrap();
seed_claude(root, "work-uuid", "work@example.com");
let (out, err, code) = run_stdin(root, &["setup"], "\n");
assert_eq!(code, 0, "setup failed:\n{out}{err}");
assert!(
out.contains("saved as 'work'"),
"setup refused over a slot holding no readable account:\n{out}{err}"
);
}
fn mcp_list_accounts(root: &Path) -> Vec<serde_json::Value> {
let input = concat!(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#,
"\n",
r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_accounts"}}"#,
"\n",
);
let (out, err, code) = run_stdin(root, &["mcp"], input);
assert_eq!(code, 0, "mcp exited {code}:\n{out}{err}");
let reply = out
.lines()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.find(|v| v["id"] == 2)
.unwrap_or_else(|| panic!("no reply to list_accounts:\n{out}{err}"));
let text = reply["result"]["content"][0]["text"]
.as_str()
.unwrap_or_else(|| panic!("list_accounts returned no text: {reply}"));
serde_json::from_str(text).unwrap_or_else(|e| panic!("not a JSON array: {text}: {e}"))
}
#[test]
fn mcp_lists_a_slot_only_account() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "work@example.com");
let rows = mcp_list_accounts(root);
assert!(
rows.iter().any(|r| r["name"] == "work"),
"list_accounts hid a slot-only account: {rows:?}"
);
}
#[test]
fn mcp_lists_an_account_held_both_ways_once() {
let t = fixture();
let root = t.path();
seed_claude(root, "work-uuid", "work@example.com");
let (o, e, c) = run(root, &["add", "work"]);
assert_eq!(c, 0, "add failed:\n{o}{e}");
seed_slot(root, "work", "work@example.com");
let rows = mcp_list_accounts(root);
let held: Vec<_> = rows.iter().filter(|r| r["name"] == "work").collect();
assert_eq!(held.len(), 1, "'work' listed twice: {rows:?}");
}
#[test]
fn mcp_still_reports_active_tools_for_a_live_account() {
let t = fixture();
let root = t.path();
seed_claude(root, "home-uuid", "me@example.com");
let (o, e, c) = run(root, &["add", "home"]);
assert_eq!(c, 0, "add failed:\n{o}{e}");
let rows = mcp_list_accounts(root);
let row = rows
.iter()
.find(|r| r["name"] == "home")
.unwrap_or_else(|| panic!("'home' missing: {rows:?}"));
assert_eq!(
row["active_tools"],
serde_json::json!(["claude-code"]),
"active_tools lost: {row}"
);
}
fn seed_codex_slot_as(root: &Path, name: &str, email: &str, account_id: &str, refresh: &str) {
let dir = root.join(".local/share/swapdex/slots").join(name);
std::fs::create_dir_all(dir.join("sessions")).unwrap();
std::fs::write(
dir.join("auth.json"),
serde_json::to_vec(&serde_json::json!({
"auth_mode": "chatgpt",
"last_refresh": "2026-09-03T02:56:06Z",
"tokens": {"id_token": codex_id_token(email), "access_token": "AT",
"refresh_token": refresh, "account_id": account_id}}))
.unwrap(),
)
.unwrap();
chmod600(&dir.join("auth.json"));
let reg = root.join(".local/share/swapdex/slots.json");
let mut rows: Vec<serde_json::Value> = std::fs::read(®)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
rows.push(serde_json::json!({
"name": name, "id": name, "config_dir": dir.to_string_lossy(),
"adopted": false, "tool": "codex"}));
std::fs::write(®, serde_json::to_vec_pretty(&rows).unwrap()).unwrap();
}
fn ls_with_path(root: &Path, path: &str) -> String {
let out = Command::new(bin())
.arg("ls")
.env("SWAPDEX_ROOT", root)
.env("HOME", root)
.env("PATH", path)
.env_remove("CODEX_HOME")
.output()
.unwrap();
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
}
#[test]
fn a_shimmed_codex_is_not_said_to_launch_on_the_leftover_login() {
let t = fixture();
let root = t.path();
seed_codex_slot_as(root, "one", "one@example.com", "acct-one", "rt-one");
seed_codex_snapshot(root, "two", "two@example.com", "acct-two", "rt-two");
std::fs::create_dir_all(root.join(".codex")).unwrap();
std::fs::copy(
root.join(".local/share/swapdex/accounts/two/codex/auth"),
root.join(".codex/auth.json"),
)
.unwrap();
std::fs::write(
root.join(".local/share/swapdex/active-codex"),
root.join(".local/share/swapdex/slots/one")
.to_string_lossy()
.as_bytes(),
)
.unwrap();
let (said, code) = run_with_native_codex(root, &["shim"]);
assert_eq!(code, 0, "{said}");
let native = root.join("native-bin");
let shims = root.join(".local/share/swapdex/bin");
let bare = ls_with_path(root, &format!("{}:/usr/bin:/bin", native.display()));
assert!(
bare.contains("would launch on 'two'"),
"without the shim on PATH the leftover login is what launches:\n{bare}"
);
let shimmed = ls_with_path(
root,
&format!("{}:{}:/usr/bin:/bin", shims.display(), native.display()),
);
assert!(
!shimmed.contains("would launch on"),
"the shim is first on PATH, so a plain launch follows the pointer:\n{shimmed}"
);
}
fn doctor_with_path(root: &Path, path: &str) -> String {
let out = Command::new(bin())
.arg("doctor")
.env("SWAPDEX_ROOT", root)
.env("HOME", root)
.env("PATH", path)
.output()
.unwrap();
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
}
#[test]
fn doctor_says_whether_the_codex_shim_is_what_path_reaches() {
let t = fixture();
let root = t.path();
seed_codex_slot(root, "cx", "cx@example.com");
let (said, code) = run_with_native_codex(root, &["shim"]);
assert_eq!(code, 0, "{said}");
let native = root.join("native-bin");
let shims = root.join(".local/share/swapdex/bin");
let row = |path: String| -> String {
let text = doctor_with_path(root, &path);
text.lines()
.find(|l| l.starts_with("shim:codex"))
.unwrap_or_else(|| panic!("doctor has no shim:codex row:\n{text}"))
.to_string()
};
let shadowed = row(format!(
"{}:{}:/usr/bin:/bin",
native.display(),
shims.display()
));
assert!(shadowed.contains("problem"), "{shadowed}");
assert!(
shadowed.contains(&native.join("codex").display().to_string()),
"the row does not say what runs instead: {shadowed}"
);
let first = row(format!(
"{}:{}:/usr/bin:/bin",
shims.display(),
native.display()
));
assert!(first.contains(" ok "), "{first}");
}
#[test]
fn the_stale_note_names_a_command_that_resaves_that_tool() {
let t = fixture();
let root = t.path();
seed_codex_snapshot(root, "old", "old@example.com", "acct-old", "rt-old");
let auth = root.join(".local/share/swapdex/accounts/old/codex/auth");
let text = std::fs::read_to_string(&auth).unwrap();
std::fs::write(
&auth,
text.replace("2026-09-03T02:56:06Z", "2026-01-01T00:00:00Z"),
)
.unwrap();
let (out, err, _) = run(root, &["ls"]);
let said = format!("{out}{err}");
assert!(
said.contains("stale"),
"the fixture copy is not stale:\n{said}"
);
assert!(
said.contains("swapdex add old --tool codex --update"),
"the note does not say how to re-save this codex copy:\n{said}"
);
}
#[test]
fn a_one_tool_command_refuses_tool_all_instead_of_meaning_claude() {
let t = fixture();
let root = t.path();
seed_codex_slot(root, "cx", "cx@example.com");
for args in [
&["serve", "cx", "--tool", "all"][..],
&["share-history", "--tool", "all", "--dry-run"],
&["share-history", "--tool", "gemini", "--dry-run"],
] {
let (out, err, code) = run(root, args);
let said = format!("{out}{err}");
assert_ne!(code, 0, "{args:?} was accepted:\n{said}");
assert!(
said.contains("--tool claude") && said.contains("--tool codex"),
"{args:?} does not say which tool to name:\n{said}"
);
}
let (out, err, code) = run(root, &["share-history", "--dry-run"]);
assert_eq!(code, 0, "{out}{err}");
}
fn default_store_row(root: &Path) -> Option<String> {
let out = Command::new(bin())
.arg("doctor")
.env("SWAPDEX_ROOT", root)
.env("HOME", root)
.env("PATH", "/usr/bin:/bin")
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout)
.lines()
.find(|l| l.starts_with("default store"))
.map(str::to_string)
}
#[test]
fn doctor_does_not_call_shared_history_unreachable() {
let t = fixture();
let root = t.path();
std::fs::create_dir_all(root.join(".claude/projects/-work-repo")).unwrap();
seed_slot(root, "work", "work@example.com");
let unset = default_store_row(root);
assert!(unset.is_none(), "nothing redirects `claude` yet: {unset:?}");
let (_, err, code) = run(root, &["use", "work"]);
assert_eq!(code, 0, "{err}");
let apart = default_store_row(root);
assert!(
apart.as_deref().is_some_and(|r| r.contains("problem")),
"a store that shares nothing really is out of reach: {apart:?}"
);
let projects = root.join(".local/share/swapdex/slots/work/projects");
let _ = std::fs::remove_dir_all(&projects);
std::os::unix::fs::symlink(root.join(".claude/projects"), &projects).unwrap();
let shared = default_store_row(root);
assert!(
shared.is_none(),
"the account reads ~/.claude/projects itself, so nothing is out of reach: {shared:?}"
);
}
#[test]
fn the_not_saved_note_names_the_tool_to_save() {
let t = fixture();
let root = t.path();
seed_live_codex(root, "loose@example.com");
for cmd in ["status", "doctor"] {
let (out, err, _) = run(root, &[cmd]);
let said = format!("{out}{err}");
let row = said
.lines()
.find(|l| l.starts_with("codex") && l.contains("not saved"))
.unwrap_or_else(|| panic!("{cmd} has no unsaved codex row:\n{said}"));
assert!(
row.contains("swapdex add <name> --tool codex"),
"{cmd} would save every tool: {row}"
);
}
}
#[test]
fn the_outgoing_login_note_keeps_the_login_it_names() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-a", "a@example.com");
let (o, e, c) = run(root, &["add", "a", "--tool", "claude"]);
assert_eq!(c, 0, "{o}{e}");
seed_claude(root, "uuid-b", "b@example.com");
let (out, err, code) = run(root, &["use", "a", "--tool", "claude"]);
let said = format!("{out}{err}");
assert_eq!(code, 0, "{said}");
let note = said
.lines()
.find(|l| l.contains("is not saved as a profile"))
.unwrap_or_else(|| panic!("no note about the unsaved login:\n{said}"));
let restore = note.find("swapdex restore --tool claude");
let keep = note.find("swapdex add <name> --tool claude");
assert!(
restore.is_some() && keep.is_some() && restore < keep,
"the note must bring the login back, then save it, for Claude only: {note}"
);
let (o, e, c) = run(root, &["restore", "--tool", "claude"]);
assert_eq!(c, 0, "{o}{e}");
let (o, e, c) = run(root, &["add", "kept", "--tool", "claude"]);
assert_eq!(c, 0, "{o}{e}");
let (listing, _, _) = run(root, &["ls"]);
assert!(
listing
.lines()
.any(|l| l.contains("kept") && l.contains("b@example.com")),
"following the note did not keep the outgoing login:\n{listing}"
);
}
#[test]
fn ls_marks_the_payer_of_each_tool() {
let t = fixture();
let root = t.path();
seed_slot(root, "cl", "cl@example.com");
seed_slot(root, "idle", "idle@example.com");
seed_codex_slot(root, "cx", "cx@example.com");
for args in [&["serve", "cl"][..], &["serve", "cx", "--tool", "codex"]] {
let (o, e, c) = run(root, args);
assert_eq!(c, 0, "{args:?}: {o}{e}");
}
let (listing, _, _) = run(root, &["ls"]);
let row = |name: &str| -> String {
listing
.lines()
.find(|l| l.trim_start_matches(['*', ' ']).starts_with(name))
.unwrap_or_else(|| panic!("no row for {name}:\n{listing}"))
.to_string()
};
let cl = row("cl");
assert!(cl.trim_end().ends_with("<- pays"), "{cl}");
let idle = row("idle");
assert!(
!idle.contains("pays"),
"an account paying for nothing was marked: {idle}"
);
let cx = row("cx");
assert!(
cx.contains("<- pays (codex)"),
"the codex payer is not marked:\n{listing}"
);
}
#[cfg(target_os = "linux")]
#[test]
fn service_status_points_a_systemd_machine_at_the_journal() {
let t = fixture();
let root = t.path();
let logs_line = |root: &Path| -> String {
let (out, err, code) = run(root, &["service", "status"]);
assert_eq!(code, 0, "{out}{err}");
out.lines()
.find(|l| l.trim_start().starts_with("logs:"))
.unwrap_or_else(|| panic!("no logs line:\n{out}"))
.to_string()
};
let none = logs_line(root);
assert!(
!none.contains("journalctl"),
"no service is installed: {none}"
);
let units = root.join(".config/systemd/user");
std::fs::create_dir_all(&units).unwrap();
std::fs::write(
units.join("swapdex-codex.service"),
"[Service]\nExecStart=/bin/true proxy --tool codex\n",
)
.unwrap();
let installed = logs_line(root);
assert!(
installed.contains("journalctl --user -u 'swapdex-*'"),
"a systemd service logs to the journal: {installed}"
);
}
#[test]
fn serve_with_no_name_names_every_tools_payer() {
let t = fixture();
let root = t.path();
seed_slot(root, "cl", "cl@example.com");
seed_codex_slot(root, "cx", "cx@example.com");
let (o, e, c) = run(root, &["serve", "cl"]);
assert_eq!(c, 0, "{o}{e}");
let (before, _, _) = run(root, &["serve"]);
assert!(before.contains("'cl' (claude)"), "{before}");
assert!(
before.contains("no account is directing turns (codex)"),
"the codex side is not answered:\n{before}"
);
assert!(
!before.lines().any(|l| l.starts_with(" ")),
"a line carries the source's indentation:\n{before}"
);
let (o, e, c) = run(root, &["serve", "cx", "--tool", "codex"]);
assert_eq!(c, 0, "{o}{e}");
let (after, _, _) = run(root, &["serve"]);
assert!(
after.contains("'cl' (claude)") && after.contains("'cx' (codex)"),
"both payers must be named:\n{after}"
);
let (one, _, _) = run(root, &["serve", "--tool", "codex"]);
assert!(
one.contains("'cx' (codex)") && !one.contains("'cl'"),
"{one}"
);
}
#[cfg(target_os = "linux")]
#[test]
fn doctor_names_a_codex_window_started_without_the_usage_route() {
let t = fixture();
let root = t.path();
seed_codex_slot(root, "cx", "cx@example.com");
mark_codex_proxy(root, Some(18788));
let bin = root.join("fake-bin");
std::fs::create_dir_all(&bin).unwrap();
let fake = bin.join("codex");
std::os::unix::fs::symlink("/bin/sh", &fake).unwrap();
let spawn = |extra: &[&str]| {
let mut args = vec![
"-c",
"sleep 30",
"codex",
"-c",
"openai_base_url=http://127.0.0.1:8788/v1",
];
args.extend_from_slice(extra);
Command::new(&fake)
.args(&args)
.env("SWAPDEX_ROOT", root)
.spawn()
.unwrap()
};
let mut stale = spawn(&[]);
let mut current = spawn(&[
"-c",
"chatgpt_base_url=https://127.0.0.1:18788/backend-api/",
]);
std::thread::sleep(std::time::Duration::from_millis(300));
let row = doctor_usage_row(root, &[]);
let _ = stale.kill();
let _ = current.kill();
let _ = stale.wait();
let _ = current.wait();
assert!(row.contains("problem"), "{row}");
assert!(
row.contains(&stale.id().to_string()),
"the window reading its own account is not named: {row}"
);
assert!(
!row.contains(¤t.id().to_string()),
"a window that reads the payer was named: {row}"
);
}
#[test]
fn whereis_lists_a_shared_conversation_once() {
let t = fixture();
let root = t.path();
let shared = root.join(".claude/projects/-work-repo");
std::fs::create_dir_all(&shared).unwrap();
std::fs::write(
shared.join("11111111-2222-3333-4444-555555555555.jsonl"),
"{}\n",
)
.unwrap();
for name in ["one", "two"] {
seed_slot(root, name, &format!("{name}@example.com"));
let projects = root
.join(".local/share/swapdex/slots")
.join(name)
.join("projects");
let _ = std::fs::remove_dir_all(&projects);
std::os::unix::fs::symlink(root.join(".claude/projects"), &projects).unwrap();
}
let (out, err, code) = run(root, &["whereis"]);
assert_eq!(code, 0, "{out}{err}");
let resumes = out
.lines()
.filter(|l| l.contains("11111111-2222-3333-4444-555555555555"))
.count();
assert_eq!(resumes, 1, "one file, listed once:\n{out}");
let row = out
.lines()
.find(|l| l.contains("-work-repo"))
.unwrap_or_else(|| panic!("no row:\n{out}"));
assert!(
row.contains("one") && row.contains("two"),
"the row does not name the accounts sharing it: {row}"
);
let day = root.join(".codex/sessions/2026/09/30");
std::fs::create_dir_all(&day).unwrap();
std::fs::write(
day.join("rollout-2026-09-30T00-00-00-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl"),
"{\"type\":\"session_meta\",\"payload\":{\"cwd\":\"/work/cx-repo\"}}\n",
)
.unwrap();
for name in ["cx1", "cx2"] {
seed_codex_slot(root, name, &format!("{name}@example.com"));
let sessions = root
.join(".local/share/swapdex/slots")
.join(name)
.join("sessions");
let _ = std::fs::remove_dir_all(&sessions);
std::os::unix::fs::symlink(root.join(".codex/sessions"), &sessions).unwrap();
}
let (out, err, code) = run(root, &["whereis"]);
assert_eq!(code, 0, "{out}{err}");
let resumes = out
.lines()
.filter(|l| l.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"))
.count();
assert_eq!(resumes, 1, "one rollout, listed once:\n{out}");
}
#[test]
fn an_idle_claude_account_is_not_called_expired_until_its_refresh_is_refused() {
let t = fixture();
let root = t.path();
seed_slot(root, "idle", "idle@example.com");
let slot = root.join(".local/share/swapdex/slots/idle");
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let blob = serde_json::to_vec(&serde_json::json!({"claudeAiOauth":{
"accessToken":"AT","refreshToken":"RT-IDLE","expiresAt": now_ms - 3_600_000,
"refreshTokenExpiresAt": now_ms + 20 * 86_400_000i64, "subscriptionType":"max"}}))
.unwrap();
std::fs::write(slot.join(".credentials.json"), &blob).unwrap();
chmod600(&slot.join(".credentials.json"));
let row = |listing: &str| -> String {
listing
.lines()
.find(|l| l.trim_start_matches(['*', ' ']).starts_with("idle"))
.unwrap_or_else(|| panic!("no idle row:\n{listing}"))
.to_string()
};
let (listing, _, _) = run(root, &["ls"]);
let idle = row(&listing);
assert!(
!idle.contains("expired"),
"an idle account with a live refresh token was called expired: {idle}"
);
let fingerprint = swapdex::refresh_health::claude_refresh_fingerprint(&blob).unwrap();
swapdex::refresh_health::record_claude_rejection(&slot, &fingerprint, now_ms).unwrap();
let (listing, _, _) = run(root, &["ls"]);
let refused = row(&listing);
assert!(
refused.contains("refresh rejected - re-login required"),
"a refused refresh token is not reported: {refused}"
);
let (out, err, _) = run(root, &["doctor"]);
let said = format!("{out}{err}");
assert!(
said.lines()
.any(|l| l.starts_with("slot:idle") && l.contains("refused")),
"doctor passes a refused Claude login:\n{said}"
);
}
#[test]
fn a_claude_login_with_its_tokens_emptied_is_not_listed_as_fine() {
let t = fixture();
let root = t.path();
seed_slot(root, "blank", "blank@example.com");
let slot = root.join(".local/share/swapdex/slots/blank");
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
std::fs::write(
slot.join(".credentials.json"),
serde_json::to_vec(&serde_json::json!({"claudeAiOauth":{
"accessToken":"","refreshToken":"","expiresAt": 0,
"refreshTokenExpiresAt": now_ms + 17 * 86_400_000i64, "subscriptionType":"team"}}))
.unwrap(),
)
.unwrap();
chmod600(&slot.join(".credentials.json"));
let (listing, _, _) = run(root, &["ls"]);
let row = listing
.lines()
.find(|l| l.trim_start_matches(['*', ' ']).starts_with("blank"))
.unwrap_or_else(|| panic!("no blank row:\n{listing}"));
assert!(
row.contains("expired"),
"a login with no tokens is listed as if it were fine: {row}"
);
}
fn seed_codex_snapshot(root: &Path, name: &str, email: &str, account_id: &str, refresh: &str) {
let d = root
.join(".local/share/swapdex/accounts")
.join(name)
.join("codex");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(
d.join("auth"),
serde_json::to_vec(&serde_json::json!({
"auth_mode": "chatgpt",
"last_refresh": "2026-09-03T02:56:06Z",
"tokens": {"id_token": codex_id_token(email), "access_token": "AT",
"refresh_token": refresh, "account_id": account_id}}))
.unwrap(),
)
.unwrap();
chmod600(&d.join("auth"));
}
#[test]
fn use_refuses_a_codex_snapshot_whose_refresh_token_was_rotated_away() {
let t = fixture();
let root = t.path();
seed_codex_slot_as(root, "live", "shared@example.com", "acct-shared", "RT-NEW");
seed_codex_snapshot(root, "stale", "shared@example.com", "acct-shared", "RT-OLD");
let (out, err, code) = run(root, &["use", "stale", "--tool", "codex"]);
let said = format!("{out}{err}");
assert_ne!(
code, 0,
"a retired snapshot was restored as a success:\n{said}"
);
assert!(
said.contains("rotated") || said.contains("retired") || said.contains("revoked"),
"nothing said the refresh token was retired by another holder:\n{said}"
);
}
#[test]
fn a_codex_snapshot_holding_the_live_refresh_token_still_switches() {
let t = fixture();
let root = t.path();
seed_codex_slot_as(root, "live", "shared@example.com", "acct-shared", "RT-SAME");
seed_codex_snapshot(root, "twin", "shared@example.com", "acct-shared", "RT-SAME");
let (out, err, code) = run(root, &["use", "twin", "--tool", "codex"]);
assert_eq!(
code, 0,
"a snapshot holding the live token was refused:\n{out}{err}"
);
}
#[test]
fn the_codex_active_marker_follows_the_slot_pointer() {
let t = fixture();
let root = t.path();
seed_live_codex(root, "orphan@example.com");
let (o, e, c) = run(root, &["add", "orphan"]);
assert_eq!(c, 0, "add failed:\n{o}{e}");
seed_codex_slot(root, "cx-one", "one@example.com");
let (_, err, code) = run(root, &["use", "cx-one", "--tool", "codex"]);
assert_eq!(code, 0, "use failed: {err}");
let (out, _, _) = run(root, &["ls"]);
let row = |n: &str| {
out.lines()
.find(|l| l.contains(n))
.unwrap_or("")
.to_string()
};
assert!(
row("cx-one").contains("codex*"),
"the pointer names cx-one but ls does not mark it:\n{out}"
);
assert!(
!row("orphan").contains("codex*"),
"ls marks an orphaned tool dir as the active codex account:\n{out}"
);
}
#[test]
fn a_tool_dir_that_disagrees_with_the_pointer_is_reported() {
let t = fixture();
let root = t.path();
seed_live_codex(root, "orphan@example.com");
let (o, e, c) = run(root, &["add", "orphan"]);
assert_eq!(c, 0, "add failed:\n{o}{e}");
seed_codex_slot(root, "cx-one", "one@example.com");
let (_, err, code) = run(root, &["use", "cx-one", "--tool", "codex"]);
assert_eq!(code, 0, "use failed: {err}");
let (out, err, _) = run_unshimmed(root, &["ls"]);
let said = format!("{out}{err}");
assert!(
said.contains("orphan"),
"the abandoned login in the tool's own dir is never mentioned:\n{said}"
);
assert!(
said.contains("not the account") || said.contains("would launch") || said.contains("stale"),
"nothing explains that an unshimmed launch lands somewhere else:\n{said}"
);
}
#[test]
fn a_tool_dir_that_agrees_with_the_pointer_is_not_reported() {
let t = fixture();
let root = t.path();
seed_live_codex(root, "same@example.com");
let (o, e, c) = run(root, &["add", "same"]);
assert_eq!(c, 0, "add failed:\n{o}{e}");
seed_codex_slot(root, "same", "same@example.com");
let (_, err, code) = run(root, &["use", "same", "--tool", "codex"]);
assert_eq!(code, 0, "use failed: {err}");
let (out, err, _) = run_unshimmed(root, &["ls"]);
let said = format!("{out}{err}");
assert!(
!said.contains("would launch"),
"the note fired although the pointer and the tool's dir agree:\n{said}"
);
}
#[test]
fn every_listing_names_the_same_active_tool() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "work@example.com");
let (_, err, code) = run(root, &["use", "work", "--tool", "claude"]);
assert_eq!(code, 0, "use failed: {err}");
let (human, _, _) = run(root, &["ls"]);
assert!(
human.contains("claude-code*"),
"the human listing marks no active tool:\n{human}"
);
let (json, _, _) = run(root, &["ls", "--json"]);
let rows: serde_json::Value = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("ls --json is not json ({e}): {json}"));
let row = rows
.as_array()
.and_then(|a| a.iter().find(|r| r["name"] == "work"))
.unwrap_or_else(|| panic!("no row for work: {json}"));
assert_eq!(
row["active_tools"],
serde_json::json!(["claude-code"]),
"`ls --json` does not name the tool the human listing marks: {row}"
);
let mcp = mcp_list_accounts(root);
let mrow = mcp
.iter()
.find(|r| r["name"] == "work")
.unwrap_or_else(|| panic!("no mcp row for work: {mcp:?}"));
assert_eq!(
mrow["active_tools"],
serde_json::json!(["claude-code"]),
"the MCP account list does not name it either: {mrow}"
);
}
#[test]
fn status_names_the_account_ls_marks_active() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "work@example.com");
let (_, err, code) = run(root, &["use", "work", "--tool", "claude"]);
assert_eq!(code, 0, "use failed: {err}");
let (human, _, _) = run(root, &["ls"]);
assert!(
human.contains("claude-code*"),
"ls marks no active account:\n{human}"
);
let (st, _, _) = run(root, &["status"]);
assert!(
!st.contains("claude-code: not logged in"),
"status says not logged in while ls marks an account active:\n{st}"
);
assert!(
st.contains("work"),
"status does not name the active account:\n{st}"
);
}
#[test]
fn status_short_names_the_slot_account() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "work@example.com");
let (_, err, code) = run(root, &["use", "work", "--tool", "claude"]);
assert_eq!(code, 0, "use failed: {err}");
let (short, _, _) = run(root, &["status", "--short"]);
assert!(
short.contains("claude:work"),
"the compact line does not name the active account: {short:?}"
);
}
#[test]
fn status_json_reports_the_slot_account() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "work@example.com");
let (_, err, code) = run(root, &["use", "work", "--tool", "claude"]);
assert_eq!(code, 0, "use failed: {err}");
let (json, _, _) = run(root, &["status", "--json"]);
let rows: serde_json::Value = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("status --json is not json ({e}): {json}"));
let row = rows
.as_array()
.and_then(|a| a.iter().find(|r| r["tool"] == "claude-code"))
.unwrap_or_else(|| panic!("no claude-code row: {json}"));
assert_eq!(
row["logged_in"],
serde_json::json!(true),
"status --json calls an active slot account logged out: {row}"
);
assert_eq!(
row["profile"],
serde_json::json!("work"),
"status --json does not name the active profile: {row}"
);
}
#[test]
fn status_still_reports_a_live_login_when_no_slot_points_anywhere() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-live", "live@example.com");
let (o, e, c) = run(root, &["add", "live"]);
assert_eq!(c, 0, "add failed:\n{o}{e}");
let (st, _, _) = run(root, &["status"]);
assert!(
st.contains("live@example.com"),
"status lost the live login on a machine with no slots:\n{st}"
);
let (short, _, _) = run(root, &["status", "--short"]);
assert!(
short.contains("claude:live"),
"the compact line lost it too: {short:?}"
);
}
#[test]
fn status_names_the_abandoned_login_too() {
let t = fixture();
let root = t.path();
seed_live_codex(root, "orphan@example.com");
let (o, e, c) = run(root, &["add", "orphan"]);
assert_eq!(c, 0, "add failed:\n{o}{e}");
seed_codex_slot(root, "cx-one", "one@example.com");
let (_, err, code) = run(root, &["use", "cx-one", "--tool", "codex"]);
assert_eq!(code, 0, "use failed: {err}");
let (st, _, _) = run_unshimmed(root, &["status"]);
assert!(
st.contains("cx-one"),
"status does not name the account the pointer serves:\n{st}"
);
assert!(
st.contains("orphan"),
"status hides the login the tool's own dir still holds:\n{st}"
);
}
fn mcp_call(root: &Path, tool: &str) -> serde_json::Value {
let input = format!(
"{}\n{{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{{\"name\":\"{tool}\"}}}}\n",
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#
);
let (out, err, code) = run_stdin(root, &["mcp"], &input);
assert_eq!(code, 0, "mcp exited {code}:\n{out}{err}");
let reply = out
.lines()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.find(|v| v["id"] == 2)
.unwrap_or_else(|| panic!("no reply to {tool}:\n{out}{err}"));
let text = reply["result"]["content"][0]["text"]
.as_str()
.unwrap_or_else(|| panic!("{tool} returned no text: {reply}"));
serde_json::from_str(text).unwrap_or_else(|e| panic!("{tool} text is not JSON: {text}: {e}"))
}
fn mcp_whoami(root: &Path) -> Vec<serde_json::Value> {
mcp_call(root, "whoami")
.as_array()
.cloned()
.unwrap_or_else(|| panic!("whoami did not return an array"))
}
#[test]
fn mcp_whoami_agrees_with_mcp_list_accounts() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "work@example.com");
let (_, err, code) = run(root, &["use", "work", "--tool", "claude"]);
assert_eq!(code, 0, "use failed: {err}");
let rows = mcp_list_accounts(root);
let listed = rows
.iter()
.find(|r| r["name"] == "work")
.unwrap_or_else(|| panic!("no row for work: {rows:?}"));
assert_eq!(
listed["active_tools"],
serde_json::json!(["claude-code"]),
"list_accounts does not call it active: {listed}"
);
let who = mcp_whoami(root);
let claude = who
.iter()
.find(|r| r["tool"] == "claude-code")
.unwrap_or_else(|| panic!("whoami has no claude-code row: {who:?}"));
assert_ne!(
claude["display"],
serde_json::json!("not logged in"),
"whoami denies the account list_accounts calls active: {claude}"
);
assert!(
claude.to_string().contains("work"),
"whoami does not name the active account: {claude}"
);
}
#[test]
fn mcp_whoami_still_reports_a_live_login_when_no_slot_points_anywhere() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-live", "live@example.com");
let (o, e, c) = run(root, &["add", "live"]);
assert_eq!(c, 0, "add failed:\n{o}{e}");
let who = mcp_whoami(root);
let claude = who
.iter()
.find(|r| r["tool"] == "claude-code")
.unwrap_or_else(|| panic!("whoami has no claude-code row: {who:?}"));
assert_ne!(
claude["display"],
serde_json::json!("not logged in"),
"whoami lost the live login on a machine with no slots: {claude}"
);
assert_eq!(
claude["email"],
serde_json::json!("live@example.com"),
"whoami does not name the live login: {claude}"
);
}
#[test]
fn doctor_does_not_call_a_freshly_refreshed_codex_login_idle() {
let t = fixture();
let root = t.path();
seed_codex_slot(root, "cx", "cx@example.com");
let cred = root.join(".local/share/swapdex/slots/cx/auth.json");
let mut v: serde_json::Value = serde_json::from_slice(&std::fs::read(&cred).unwrap()).unwrap();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
v["last_refresh"] = serde_json::json!(rfc3339_utc(now));
std::fs::write(&cred, serde_json::to_vec(&v).unwrap()).unwrap();
let (out, err, _) = run(root, &["doctor"]);
let said = format!("{out}{err}");
assert!(
!said.contains("idle"),
"doctor calls a login refreshed just now idle:\n{said}"
);
}
#[test]
fn doctor_flags_a_codex_login_that_has_sat_unrefreshed() {
let t = fixture();
let root = t.path();
seed_codex_slot(root, "cx", "cx@example.com");
let cred = root.join(".local/share/swapdex/slots/cx/auth.json");
let mut v: serde_json::Value = serde_json::from_slice(&std::fs::read(&cred).unwrap()).unwrap();
let long_ago = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
- 200 * 86_400;
v["last_refresh"] = serde_json::json!(rfc3339_utc(long_ago));
std::fs::write(&cred, serde_json::to_vec(&v).unwrap()).unwrap();
let (out, err, _) = run(root, &["doctor"]);
let said = format!("{out}{err}");
assert!(
said.contains("slot:cx") && said.contains("idle"),
"doctor says nothing about a Codex login unrefreshed for 200 days:\n{said}"
);
}
#[test]
fn doctor_does_not_call_an_unparseable_codex_credential_a_missing_login() {
let t = fixture();
let root = t.path();
seed_codex_slot(root, "cx", "cx@example.com");
let cred = root.join(".local/share/swapdex/slots/cx/auth.json");
assert!(cred.exists(), "the fixture wrote no credential");
std::fs::write(&cred, b"{ this is not json").unwrap();
let (out, err, _) = run(root, &["doctor"]);
let said = format!("{out}{err}");
assert!(
!said.contains("no login yet"),
"doctor calls a present-but-damaged credential a missing login:\n{said}"
);
}
fn seed_empty_codex_slot(root: &Path, name: &str) {
seed_codex_slot(root, name, "unused@example.com");
let dir = root.join(".local/share/swapdex/slots").join(name);
std::fs::remove_file(dir.join("auth.json")).unwrap();
}
#[test]
fn status_reads_a_gemini_slots_recorded_email() {
let t = fixture();
let root = t.path();
seed_gemini_slot(root, "gwork", "g@example.com");
let (_, err, code) = run(root, &["use", "gwork", "--tool", "gemini"]);
assert_eq!(code, 0, "use failed: {err}");
let (out, err, code) = run(root, &["status"]);
assert_eq!(code, 0, "status failed: {err}");
assert!(
out.contains("gemini: g@example.com (profile 'gwork')"),
"status calls a named Gemini login unreadable:\n{out}{err}"
);
}
#[test]
fn mcp_whoami_does_not_call_an_old_gemini_account_active() {
let t = fixture();
let root = t.path();
seed_gemini_slot(root, "gwork", "current@example.com");
let accounts = root.join(".local/share/swapdex/slots/gwork/google_accounts.json");
std::fs::write(accounts, br#"{"old":["old@example.com"]}"#).unwrap();
let (_, err, code) = run(root, &["use", "gwork", "--tool", "gemini"]);
assert_eq!(code, 0, "use failed: {err}");
let who = mcp_whoami(root);
let gemini = who
.iter()
.find(|r| r["tool"] == "gemini")
.unwrap_or_else(|| panic!("whoami has no Gemini row: {who:?}"));
assert_eq!(
gemini["email"],
serde_json::Value::Null,
"whoami called an old Gemini login active: {gemini}"
);
}
#[test]
fn doctor_names_codex_in_the_remedy_for_an_idle_codex_login() {
let t = fixture();
let root = t.path();
seed_codex_slot(root, "cx", "cx@example.com");
let cred = root.join(".local/share/swapdex/slots/cx/auth.json");
let mut v: serde_json::Value = serde_json::from_slice(&std::fs::read(&cred).unwrap()).unwrap();
let old = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
- 200 * 86_400;
v["last_refresh"] = serde_json::json!(rfc3339_utc(old));
std::fs::write(&cred, serde_json::to_vec(&v).unwrap()).unwrap();
let (out, err, _) = run(root, &["doctor"]);
let said = format!("{out}{err}");
assert!(
said.contains("`swapdex run cx --tool codex` once refreshes it"),
"doctor gives the wrong action for an idle Codex login:\n{said}"
);
}
#[test]
fn doctor_leaves_claudes_default_out_of_an_idle_login_remedy() {
let t = fixture();
let root = t.path();
seed_slot(root, "cl", "cl@example.com");
let cred = root.join(".local/share/swapdex/slots/cl/.credentials.json");
let mut v: serde_json::Value = serde_json::from_slice(&std::fs::read(&cred).unwrap()).unwrap();
v["claudeAiOauth"]["expiresAt"] = serde_json::json!(1_600_000_000_000i64);
std::fs::write(&cred, serde_json::to_vec(&v).unwrap()).unwrap();
let (out, err, _) = run(root, &["doctor"]);
let said = format!("{out}{err}");
assert!(
said.contains("`swapdex run cl` once refreshes it"),
"doctor no longer names the default Claude refresh:\n{said}"
);
assert!(
!said.contains("`swapdex run cl --tool"),
"doctor redundantly spells Claude's default on the idle branch:\n{said}"
);
}
#[test]
fn ls_does_not_say_an_account_with_no_login_pays() {
let t = fixture();
let root = t.path();
seed_empty_codex_slot(root, "cxwork");
let (_, err, code) = run(root, &["use", "cxwork", "--tool", "codex"]);
assert_eq!(code, 0, "use failed: {err}");
let (out, err2, _) = run(root, &["ls"]);
let said = format!("{out}{err2}");
assert!(
said.contains("cxwork"),
"ls no longer lists the account, so this measures nothing:\n{said}"
);
assert!(
said.contains("pays"),
"ls no longer marks a payer, so this measures nothing:\n{said}"
);
assert!(
said.contains("no login"),
"ls says an account with no login pays, without saying it cannot:\n{said}"
);
}
#[test]
fn ls_does_not_claim_a_signed_in_payer_has_no_login() {
let t = fixture();
let root = t.path();
seed_codex_slot(root, "cxwork", "cx@example.com");
let (_, err, code) = run(root, &["use", "cxwork", "--tool", "codex"]);
assert_eq!(code, 0, "use failed: {err}");
let (out, err2, _) = run(root, &["ls"]);
let said = format!("{out}{err2}");
assert!(said.contains("pays"), "ls no longer marks a payer:\n{said}");
assert!(
!said.contains("no login"),
"ls calls a signed-in payer loginless:\n{said}"
);
}
#[test]
fn the_short_line_does_not_name_an_account_with_no_login() {
let t = fixture();
let root = t.path();
seed_empty_codex_slot(root, "cxwork");
let (_, err, code) = run(root, &["use", "cxwork", "--tool", "codex"]);
assert_eq!(code, 0, "use failed: {err}");
let (out, err2, _) = run(root, &["status", "--short"]);
let said = format!("{out}{err2}");
assert!(
!said.contains("cxwork"),
"the prompt line names an account with no login:\n{said}"
);
assert!(
said.contains("not signed in"),
"nothing is signed in on this machine; the line should say so:\n{said}"
);
}
#[test]
fn the_short_line_names_a_slot_that_is_signed_in() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "work@example.com");
let (_, err, code) = run(root, &["use", "work", "--tool", "claude"]);
assert_eq!(code, 0, "use failed: {err}");
let (out, err2, _) = run(root, &["status", "--short"]);
let said = format!("{out}{err2}");
assert!(
said.contains("claude:work"),
"the prompt line dropped a slot that is signed in:\n{said}"
);
}
#[test]
fn status_shows_the_identity_of_a_slot_that_is_signed_in() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "work@example.com");
let (_, err, code) = run(root, &["use", "work", "--tool", "claude"]);
assert_eq!(code, 0, "use failed: {err}");
let (out, err2, _) = run(root, &["status"]);
let said = format!("{out}{err2}");
assert!(
said.contains("work@example.com"),
"status hides the email of a slot that is signed in:\n{said}"
);
assert!(
!said.contains("not signed in"),
"status calls a signed-in slot unsigned:\n{said}"
);
}
#[test]
fn status_does_not_call_an_empty_slot_signed_in() {
let t = fixture();
let root = t.path();
seed_empty_codex_slot(root, "cxwork");
let (_, err, code) = run(root, &["use", "cxwork", "--tool", "codex"]);
assert_eq!(code, 0, "use failed: {err}");
let (so, se, _) = run(root, &["serve", "cxwork", "--tool", "codex"]);
assert!(
format!("{so}{se}").contains("no codex login"),
"serve no longer reports the missing login, so there is nothing to disagree with"
);
let (out, err2, _) = run(root, &["status"]);
let said = format!("{out}{err2}");
assert!(
!said.contains("login unreadable in the slot"),
"status calls an empty slot's login unreadable rather than absent:\n{said}"
);
let (jout, jerr, _) = run(root, &["status", "--json"]);
let rows: serde_json::Value =
serde_json::from_str(&jout).unwrap_or_else(|e| panic!("status --json: {e}\n{jout}{jerr}"));
let codex = rows
.as_array()
.and_then(|a| a.iter().find(|r| r["tool"] == "codex"))
.unwrap_or_else(|| panic!("no codex row:\n{jout}"));
assert_eq!(
codex["logged_in"], false,
"status --json reports an empty slot as logged in:\n{codex}"
);
}
#[test]
fn doctor_does_not_contradict_itself_about_an_empty_slot() {
let t = fixture();
let root = t.path();
seed_empty_codex_slot(root, "cxwork");
let (_, err, code) = run(root, &["use", "cxwork", "--tool", "codex"]);
assert_eq!(code, 0, "use failed: {err}");
let (out, err2, _) = run(root, &["doctor"]);
let said = format!("{out}{err2}");
assert!(
said.contains("slot:cxwork"),
"the per-slot row is gone, so this measures nothing:\n{said}"
);
let codex_row = said
.lines()
.find(|l| l.starts_with("codex"))
.unwrap_or_else(|| panic!("doctor has no codex row:\n{said}"));
assert!(
codex_row.contains("not signed in"),
"doctor's codex row claims a login one row above reporting none:\n{said}"
);
}
#[test]
fn the_slot_login_row_names_the_tool() {
let t = fixture();
let root = t.path();
seed_empty_codex_slot(root, "cxwork");
let (out, err, _) = run(root, &["doctor"]);
let said = format!("{out}{err}");
assert!(
said.contains("swapdex run cxwork"),
"the row no longer names `run`, so this measures nothing:\n{said}"
);
assert!(
!said.contains("swapdex run cxwork`"),
"the Codex slot's remedy names a command that runs Claude:\n{said}"
);
}
#[test]
fn the_import_sign_in_line_names_each_accounts_tool() {
let t = fixture();
let root = t.path();
let manifest = root.join("setup.json");
std::fs::write(
&manifest,
br#"{"version":1,"accounts":[{"name":"cxwork","tool":"codex"},{"name":"clwork","tool":"claude-code"}]}"#,
)
.unwrap();
let (out, err, code) = run(root, &["import", manifest.to_str().unwrap()]);
assert_eq!(code, 0, "import failed:\n{out}{err}");
let said = format!("{out}{err}");
assert!(
said.contains("still needs its own sign-in"),
"import no longer mentions signing in, so this measures nothing:\n{said}"
);
assert!(
said.contains("swapdex run cxwork --tool codex"),
"the sign-in named for an imported Codex account runs Claude:\n{said}"
);
assert!(
said.contains("swapdex run clwork"),
"the Claude account was not named at all:\n{said}"
);
assert!(
!said.contains("swapdex run clwork --tool"),
"Claude is run's default; spelling it out is noise:\n{said}"
);
}
#[test]
fn the_pause_note_names_a_serve_that_works_for_this_account() {
let t = fixture();
let root = t.path();
seed_codex_slot(root, "cxwork", "cx@example.com");
let (out, err, code) = run(root, &["pause", "cxwork"]);
assert_eq!(code, 0, "pause failed: {err}");
let said = format!("{out}{err}");
assert!(
said.contains("swapdex serve cxwork"),
"the note no longer names `serve`, so this measures nothing:\n{said}"
);
assert!(
said.contains("swapdex serve cxwork --tool codex"),
"pause recommends a `serve` that cannot find this account:\n{said}"
);
}
#[test]
fn the_pause_note_leaves_claudes_default_unspelled() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "work@example.com");
let (out, err, code) = run(root, &["pause", "work"]);
assert_eq!(code, 0, "pause failed: {err}");
let said = format!("{out}{err}");
assert!(
said.contains("swapdex serve work"),
"the note no longer names `serve`:\n{said}"
);
assert!(
!said.contains("--tool"),
"Claude is the default for both commands; spelling it out is noise:\n{said}"
);
}
#[test]
fn the_switch_tip_names_the_tool_it_just_switched() {
let t = fixture();
let root = t.path();
seed_codex_slot(root, "work", "work@example.com");
let (out, err, code) = run(root, &["use", "work", "--tool", "codex"]);
assert_eq!(code, 0, "use failed: {err}");
let said = format!("{out}{err}");
assert!(
said.contains("swapdex run work"),
"the tip no longer names `run`, so this test measures nothing:\n{said}"
);
assert!(
said.contains("swapdex run work --tool codex"),
"the tip after a Codex switch names a command that runs Claude:\n{said}"
);
}
#[test]
fn switching_a_tool_with_no_shim_does_not_offer_one() {
let t = fixture();
let root = t.path();
seed_gemini_slot(root, "gwork", "g@example.com");
let (out, err, code) = run(root, &["use", "gwork", "--tool", "gemini"]);
assert_eq!(code, 0, "use failed: {err}");
let said = format!("{out}{err}");
assert!(
said.contains("gwork"),
"the switch printed nothing, so this measures nothing:\n{said}"
);
assert!(
!said.contains("swapdex shim"),
"promises a gemini shim that `swapdex shim` never installs:\n{said}"
);
assert!(
!said.contains("swapdex run gwork"),
"names `run`, which refuses gemini and builds a Claude account instead:\n{said}"
);
}
#[test]
fn serve_refuses_a_tool_the_proxy_cannot_carry() {
let t = fixture();
let root = t.path();
seed_gemini_slot(root, "gwork", "g@example.com");
let (pout, perr, _) = run(root, &["proxy", "--tool", "gemini"]);
assert!(
format!("{pout}{perr}").contains("no gemini relay"),
"`proxy` no longer refuses gemini, so serve has nothing to disagree with"
);
let (out, err2, code2) = run(root, &["serve", "gwork", "--tool", "gemini"]);
let said = format!("{out}{err2}");
assert_ne!(
code2, 0,
"serve accepted a tool the proxy cannot carry:\n{said}"
);
assert!(
!root.join(".local/share/swapdex/serving-gemini").exists(),
"serve wrote a serving pointer for a relay that does not exist"
);
}
#[test]
fn switching_a_tool_without_a_relay_does_not_offer_the_proxy() {
let t = fixture();
let root = t.path();
seed_gemini_slot(root, "gwork", "g@example.com");
let (out, err2, code2) = run(root, &["use", "gwork", "--tool", "gemini"]);
assert_eq!(code2, 0, "use failed: {err2}");
let said = format!("{out}{err2}");
assert!(
said.contains("gwork"),
"the switch printed nothing about the account, so this measures nothing:\n{said}"
);
assert!(
!said.contains("swapdex proxy"),
"switching a gemini account points at a proxy that refuses to start:\n{said}"
);
}
#[test]
fn doctor_checks_a_codex_slot_login_the_way_it_checks_a_claude_one() {
let t = fixture();
let root = t.path();
seed_slot(root, "cl", "cl@example.com");
seed_codex_slot(root, "cx", "cx@example.com");
std::fs::remove_file(root.join(".local/share/swapdex/slots/cl/.credentials.json")).unwrap();
std::fs::remove_file(root.join(".local/share/swapdex/slots/cx/auth.json")).unwrap();
let (out, err, _) = run(root, &["doctor"]);
let said = format!("{out}{err}");
assert!(
said.contains("slot:cl"),
"the Claude half of the comparison is gone, so this test measures nothing:\n{said}"
);
assert!(
said.contains("slot:cx"),
"doctor reports a Claude slot with no login and says nothing about a Codex one:\n{said}"
);
}
#[test]
fn doctor_reports_a_default_pointer_naming_no_account() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "work@example.com");
let (_, err, code) = run(root, &["use", "work", "--tool", "claude"]);
assert_eq!(code, 0, "use failed: {err}");
let ptr = root.join(".local/share/swapdex/active-claude");
assert!(ptr.exists(), "use wrote no default pointer at {ptr:?}");
let orphan = root.join(".local/share/swapdex/slots/not-in-the-registry");
std::fs::write(&ptr, orphan.to_string_lossy().as_bytes()).unwrap();
let (out, err2, code2) = run(root, &["doctor"]);
let said = format!("{out}{err2}");
assert!(
!said.contains("(unknown)"),
"doctor prints an unresolvable default as if it were an account name:\n{said}"
);
assert_ne!(
code2, 0,
"doctor exited 0 with a default naming no account:\n{said}"
);
}
#[test]
fn doctor_agrees_with_the_default_it_reports() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "work@example.com");
let (_, err, code) = run(root, &["use", "work", "--tool", "claude"]);
assert_eq!(code, 0, "use failed: {err}");
let (out, _, _) = run(root, &["doctor"]);
assert!(
out.contains("-> 'work'"),
"doctor does not report the default it is being checked against:\n{out}"
);
let row = out
.lines()
.find(|l| l.starts_with("claude-code"))
.unwrap_or_else(|| panic!("doctor has no claude-code row:\n{out}"));
assert!(
!row.contains("not logged in"),
"doctor calls the tool logged out while naming its default account:\n{out}"
);
assert!(
row.contains("work"),
"doctor's claude-code row does not name the active account: {row:?}"
);
}
#[test]
fn doctor_scopes_the_missing_default_to_its_tool() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "work@example.com");
seed_codex_slot(root, "cx-one", "one@example.com");
let (_, err, code) = run(root, &["use", "cx-one", "--tool", "codex"]);
assert_eq!(code, 0, "use failed: {err}");
let (out, _, _) = run(root, &["doctor"]);
let row = out
.lines()
.find(|l| l.starts_with("default "))
.unwrap_or_else(|| panic!("doctor has no default row:\n{out}"));
assert!(
row.contains("no default"),
"this machine should have no CLAUDE default: {row:?}"
);
assert!(
row.contains("claude"),
"the missing-default row does not say which tool it is about: {row:?}"
);
}
#[test]
fn the_help_names_the_slash_command_that_gets_installed() {
let t = fixture();
let root = t.path();
let (out, err, code) = run(root, &["slash", "--help"]);
assert_eq!(code, 0, "slash --help failed:\n{out}{err}");
assert!(
out.contains("/swap"),
"the help does not name the command the installer writes:\n{out}"
);
assert!(
!out.contains("/sx"),
"the help still names a command nothing installs:\n{out}"
);
}
#[test]
fn the_command_reference_lists_every_command() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let doc = std::fs::read_to_string(root.join("docs/COMMANDS.md")).expect("read COMMANDS.md");
let t = fixture();
let (help, err, code) = run(t.path(), &["--help"]);
assert_eq!(code, 0, "--help failed:\n{help}{err}");
let mut missing: Vec<String> = Vec::new();
let mut in_commands = false;
for line in help.lines() {
if line.starts_with("Commands:") {
in_commands = true;
continue;
}
if !in_commands {
continue;
}
if line.starts_with("Options:") {
break;
}
let Some(name) = line
.strip_prefix(" ")
.and_then(|l| l.split_whitespace().next())
else {
continue;
};
if name == "help" || !name.chars().all(|c| c.is_ascii_lowercase() || c == '-') {
continue;
}
if !doc.contains(&format!("`swapdex {name}")) {
missing.push(name.to_string());
}
}
assert!(
missing.is_empty(),
"docs/COMMANDS.md is the reference and does not list: {}",
missing.join(", ")
);
}
#[test]
fn export_carries_the_accounts_the_listing_shows() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-w", "w@example.com");
let (o, e, c) = run(root, &["add", "work"]);
assert_eq!(c, 0, "add failed:\n{o}{e}");
seed_slot(root, "slotted", "s@example.com");
let (listed, _, _) = run(root, &["ls"]);
for name in ["work", "slotted"] {
assert!(listed.contains(name), "ls does not show {name}:\n{listed}");
}
let out = root.join("setup.json");
let (o, e, c) = run(root, &["export", out.to_str().unwrap()]);
assert_eq!(c, 0, "export failed:\n{o}{e}");
let text = std::fs::read_to_string(&out).expect("read the export");
let v: serde_json::Value = serde_json::from_str(&text).expect("export is json");
let names: Vec<&str> = v["accounts"]
.as_array()
.map(|a| a.iter().filter_map(|x| x["name"].as_str()).collect())
.unwrap_or_default();
for name in ["work", "slotted"] {
assert!(
names.contains(&name),
"the export drops an account the listing shows: {name} not in {names:?}"
);
}
}
#[test]
fn import_recognises_a_snapshot_account_already_here() {
let t = fixture();
let root = t.path();
seed_claude(root, "uuid-w", "w@example.com");
let (out, err, code) = run(root, &["add", "work", "--tool", "claude-code"]);
assert_eq!(code, 0, "add failed:\n{out}{err}");
let manifest = root.join("setup.json");
std::fs::write(
&manifest,
br#"{"version":1,"accounts":[{"name":"work","tool":"claude-code"}]}"#,
)
.unwrap();
let (out, err, code) = run(root, &["import", manifest.to_str().unwrap()]);
assert_eq!(code, 0, "import failed:\n{out}{err}");
assert!(
out.contains("every account in that file is already here"),
"import did not recognise the snapshot account:\n{out}{err}"
);
assert!(
!out.contains("created work"),
"import created an empty slot over an account already here:\n{out}{err}"
);
}
#[test]
fn use_refuses_a_claude_snapshot_whose_refresh_token_was_rotated_away() {
let t = fixture();
let root = t.path();
let slot = root.join(".local/share/swapdex/slots/live");
std::fs::create_dir_all(&slot).unwrap();
std::fs::write(
slot.join(".credentials.json"),
br#"{"claudeAiOauth":{"accessToken":"AT","refreshToken":"RT-NEW","expiresAt":9999999999999,"subscriptionType":"max"}}"#,
)
.unwrap();
std::fs::write(
slot.join(".claude.json"),
br#"{"oauthAccount":{"accountUuid":"u-shared","emailAddress":"shared@example.com"}}"#,
)
.unwrap();
std::fs::write(
root.join(".local/share/swapdex/slots.json"),
serde_json::to_vec(&serde_json::json!([{
"name": "live", "id": "live", "config_dir": slot,
"adopted": false, "tool": "claude-code"}]))
.unwrap(),
)
.unwrap();
let snap = root.join(".local/share/swapdex/accounts/stale/claude-code");
std::fs::create_dir_all(&snap).unwrap();
std::fs::write(
snap.join("credentials"),
br#"{"claudeAiOauth":{"accessToken":"AT-OLD","refreshToken":"RT-OLD"}}"#,
)
.unwrap();
std::fs::write(
snap.join("oauth_account"),
br#"{"accountUuid":"u-shared","emailAddress":"shared@example.com"}"#,
)
.unwrap();
let (out, err, code) = run(root, &["use", "stale", "--tool", "claude"]);
let said = format!("{out}{err}");
assert_ne!(code, 0, "a retired Claude snapshot was applied:\n{said}");
assert!(
said.contains("retired") || said.contains("rotated"),
"nothing said the token was retired elsewhere:\n{said}"
);
}
#[test]
fn doctor_reports_a_settings_file_that_will_not_parse() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "work@example.com");
let (o, e, c) = run(root, &["pause", "work"]);
assert_eq!(c, 0, "pause failed:\n{o}{e}");
let settings = root.join(".local/share/swapdex/settings.json");
assert!(settings.exists(), "pause wrote no settings file");
std::fs::write(&settings, b"NOT JSON {{{").unwrap();
let (out, err, _) = run(root, &["doctor"]);
let said = format!("{out}{err}");
assert!(
said.to_lowercase().contains("settings"),
"doctor says nothing about a settings file it cannot read:\n{said}"
);
}
#[test]
fn doctor_checks_the_permissions_of_credentials_in_slots() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "work@example.com");
let cred = root
.join(".local/share/swapdex/slots/work")
.join(".credentials.json");
assert!(cred.exists(), "the fixture wrote no slot credential");
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&cred, std::fs::Permissions::from_mode(0o644)).unwrap();
let (out, err, _) = run(root, &["doctor"]);
let said = format!("{out}{err}");
assert!(
said.contains("group/world-readable"),
"doctor passed a world-readable slot credential:\n{said}"
);
}
#[test]
fn doctor_stays_quiet_about_a_registry_that_parses() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "work@example.com");
let reg = root.join(".local/share/swapdex/slots.json");
assert!(reg.exists(), "the fixture wrote no registry");
let (out, err, _) = run(root, &["doctor"]);
let said = format!("{out}{err}");
assert!(
!said.contains("will not parse"),
"doctor called a readable registry damaged:\n{said}"
);
}
#[test]
fn doctor_reports_a_slot_registry_that_will_not_parse() {
let t = fixture();
let root = t.path();
seed_slot(root, "work", "work@example.com");
let reg = root.join(".local/share/swapdex/slots.json");
assert!(reg.exists(), "the fixture wrote no registry");
std::fs::write(®, b"").unwrap();
let (listed, lerr, _) = run(root, &["ls"]);
assert!(
format!("{listed}{lerr}").contains("could not be read"),
"ls no longer reports a damaged registry, so this test is measuring the wrong thing"
);
let (out, err, code) = run(root, &["doctor"]);
let said = format!("{out}{err}");
assert!(
said.contains("registry") || said.contains("slots.json"),
"doctor called a damaged account registry healthy:\n{said}"
);
assert_ne!(code, 0, "doctor exited 0 with a damaged registry:\n{said}");
}