use assert_cmd::prelude::*;
use predicates::prelude::*;
use serde_json::json;
use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
struct TestEnv {
home_dir: TempDir,
}
impl TestEnv {
fn new() -> Self {
let home_dir = TempDir::new().expect("Failed to create temp directory");
Self { home_dir }
}
fn home_path(&self) -> &Path {
self.home_dir.path()
}
fn claude_config_path(&self) -> std::path::PathBuf {
self.home_dir.path().join(".claude.json")
}
fn claudectx_dir(&self) -> std::path::PathBuf {
self.home_dir.path().join(".claudectx")
}
fn profile_path(&self, name: &str) -> std::path::PathBuf {
self.claudectx_dir().join(format!("{}.claude.json", name))
}
fn create_claude_config(&self, account: &serde_json::Value) {
let config_path = self.claude_config_path();
if config_path.is_symlink() {
fs::remove_file(&config_path).expect("Failed to remove existing symlink");
}
let config = json!({
"oauthAccount": account,
"lastAccountUUID": account["accountUuid"],
"primaryApiKey": "sk-ant-test-key",
"hasCompletedOnboarding": true
});
fs::write(
&config_path,
serde_json::to_string_pretty(&config).expect("serialize"),
)
.expect("Failed to write claude config");
}
fn create_profile(&self, name: &str, account: &serde_json::Value) {
fs::create_dir_all(self.claudectx_dir()).expect("Failed to create claudectx dir");
let config = json!({
"oauthAccount": account,
"lastAccountUUID": account["accountUuid"],
"primaryApiKey": format!("sk-ant-test-key-{}", name),
"hasCompletedOnboarding": true
});
fs::write(
self.profile_path(name),
serde_json::to_string_pretty(&config).expect("serialize"),
)
.expect("Failed to write profile");
}
fn read_profile(&self, name: &str) -> serde_json::Value {
let content = fs::read_to_string(self.profile_path(name)).expect("Failed to read profile");
serde_json::from_str(&content).expect("Failed to parse profile")
}
fn list_profile_files(&self) -> Vec<String> {
if !self.claudectx_dir().exists() {
return vec![];
}
fs::read_dir(self.claudectx_dir())
.expect("Failed to read claudectx dir")
.filter_map(|entry| {
let entry = entry.ok()?;
let name = entry.file_name().to_string_lossy().to_string();
name.strip_suffix(".claude.json").map(String::from)
})
.collect()
}
fn is_symlink_to_profile(&self, profile_name: &str) -> bool {
let config_path = self.claude_config_path();
if !config_path.is_symlink() {
return false;
}
let target = fs::read_link(&config_path).ok();
target
.map(|t| t == self.profile_path(profile_name))
.unwrap_or(false)
}
fn cmd(&self) -> assert_cmd::Command {
let mut cmd = Command::cargo_bin("claudectx").expect("Failed to find binary");
cmd.env("CLAUDECTX_HOME", self.home_path());
assert_cmd::Command::from_std(cmd)
}
}
fn sample_account(suffix: &str) -> serde_json::Value {
json!({
"accountUuid": format!("uuid-{}", suffix),
"emailAddress": format!("user-{}@example.com", suffix),
"organizationUuid": format!("org-uuid-{}", suffix),
"displayName": format!("User {}", suffix),
"organizationRole": "member",
"organizationName": format!("Org {}", suffix),
"hasExtraUsageEnabled": false,
"workspaceRole": null
})
}
#[test]
fn test_help_flag() {
let env = TestEnv::new();
env.cmd()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains(
"Launch Claude Code with different profiles",
))
.stdout(predicate::str::contains("list"))
.stdout(predicate::str::contains("save"))
.stdout(predicate::str::contains("delete"));
}
#[test]
fn test_version_flag() {
let env = TestEnv::new();
env.cmd()
.arg("--version")
.assert()
.success()
.stdout(predicate::str::contains("claudectx"));
}
#[test]
fn test_help_subcommand() {
let env = TestEnv::new();
env.cmd()
.arg("help")
.assert()
.success()
.stdout(predicate::str::contains(
"Launch Claude Code with different profiles",
));
}
#[test]
fn test_list_empty_profiles() {
let env = TestEnv::new();
let account = sample_account("current");
env.create_claude_config(&account);
env.cmd()
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("No profiles found."));
}
#[test]
fn test_list_with_profiles() {
let env = TestEnv::new();
let current_account = sample_account("current");
env.create_claude_config(¤t_account);
env.create_profile("work", &sample_account("work"));
env.create_profile("personal", &sample_account("personal"));
env.cmd()
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("work"))
.stdout(predicate::str::contains("personal"))
.stdout(predicate::str::contains("User work"))
.stdout(predicate::str::contains("User personal"));
}
#[test]
fn test_list_marks_current_profile_with_asterisk() {
let env = TestEnv::new();
env.create_profile("work", &sample_account("work"));
env.create_profile("personal", &sample_account("personal"));
let config_path = env.claude_config_path();
#[cfg(unix)]
std::os::unix::fs::symlink(env.profile_path("work"), &config_path)
.expect("Failed to create symlink");
#[cfg(windows)]
std::os::windows::fs::symlink_file(env.profile_path("work"), &config_path)
.expect("Failed to create symlink");
let output = env.cmd().arg("list").assert().success();
let output_str = String::from_utf8_lossy(&output.get_output().stdout);
assert!(
output_str.contains("work")
&& output_str
.lines()
.any(|l| l.contains("work") && l.contains(" *")),
"Current profile 'work' should be marked with asterisk"
);
}
#[test]
fn test_save_creates_new_profile() {
let env = TestEnv::new();
let account = sample_account("alice");
env.create_claude_config(&account);
env.cmd()
.args(["save", "alice-profile"])
.assert()
.success()
.stdout(predicate::str::contains(
"Saved current config as 'alice-profile'",
));
assert!(env.profile_path("alice-profile").exists());
let profile = env.read_profile("alice-profile");
assert_eq!(
profile["oauthAccount"]["emailAddress"],
"user-alice@example.com"
);
}
#[test]
fn test_save_slugifies_profile_name() {
let env = TestEnv::new();
let account = sample_account("test");
env.create_claude_config(&account);
env.cmd()
.args(["save", "My Work Profile"])
.assert()
.success()
.stdout(predicate::str::contains(
"Saved current config as 'my-work-profile'",
));
assert!(env.profile_path("my-work-profile").exists());
}
#[test]
fn test_save_slugifies_special_characters() {
let env = TestEnv::new();
let account = sample_account("test");
env.create_claude_config(&account);
env.cmd()
.args(["save", "FG@Company"])
.assert()
.success()
.stdout(predicate::str::contains(
"Saved current config as 'fg-company'",
));
assert!(env.profile_path("fg-company").exists());
}
#[test]
fn test_save_fails_without_claude_config() {
let env = TestEnv::new();
env.cmd()
.args(["save", "myprofile"])
.assert()
.failure()
.stderr(predicate::str::contains("Failed to read Claude config"));
}
#[test]
fn test_save_multiple_profiles() {
let env = TestEnv::new();
let account1 = sample_account("first");
env.create_claude_config(&account1);
env.cmd().args(["save", "profile1"]).assert().success();
let account2 = sample_account("second");
env.create_claude_config(&account2);
env.cmd().args(["save", "profile2"]).assert().success();
let profiles = env.list_profile_files();
assert!(profiles.contains(&"profile1".to_string()));
assert!(profiles.contains(&"profile2".to_string()));
}
#[test]
fn test_delete_removes_profile() {
let env = TestEnv::new();
let account = sample_account("current");
env.create_claude_config(&account);
env.create_profile("to-delete", &sample_account("delete-me"));
env.create_profile("to-keep", &sample_account("keep-me"));
env.cmd()
.args(["delete", "to-delete"])
.assert()
.success()
.stdout(predicate::str::contains("Deleted profile 'to-delete'"));
assert!(!env.profile_path("to-delete").exists());
assert!(env.profile_path("to-keep").exists());
}
#[test]
fn test_delete_nonexistent_profile_panics() {
let env = TestEnv::new();
let account = sample_account("current");
env.create_claude_config(&account);
env.cmd()
.args(["delete", "nonexistent"])
.assert()
.failure()
.stderr(predicate::str::contains("Profile 'nonexistent' not found"));
}
#[test]
fn test_no_args_first_launch_no_profiles() {
let env = TestEnv::new();
let account = sample_account("firstuser");
env.create_claude_config(&account);
env.cmd()
.assert()
.success()
.stdout(predicate::str::contains(
"Current account: User firstuser @ Org firstuser",
))
.stdout(predicate::str::contains("No profiles saved yet"))
.stdout(predicate::str::contains("claudectx save"));
}
#[test]
fn test_no_args_fails_without_claude_config() {
let env = TestEnv::new();
env.cmd()
.assert()
.failure()
.stderr(predicate::str::contains("Failed to read Claude config"));
}
#[test]
fn test_launch_nonexistent_profile_panics() {
let env = TestEnv::new();
let account = sample_account("current");
env.create_claude_config(&account);
env.cmd().arg("nonexistent").assert().failure();
}
#[test]
fn test_launch_creates_symlink_then_attempts_claude() {
let env = TestEnv::new();
let account = sample_account("current");
env.create_claude_config(&account);
env.create_profile("work", &sample_account("work"));
let output = env.cmd().arg("work").assert();
assert!(
env.is_symlink_to_profile("work"),
"Symlink should point to work profile"
);
assert!(env.profile_path("work").exists());
let _ = output;
}
#[test]
fn test_launch_switches_symlink_between_profiles() {
let env = TestEnv::new();
env.create_profile("work", &sample_account("work"));
env.create_profile("personal", &sample_account("personal"));
let account = sample_account("initial");
env.create_claude_config(&account);
let _ = env.cmd().arg("work").assert();
assert!(
env.is_symlink_to_profile("work"),
"Should symlink to work profile"
);
let _ = env.cmd().arg("personal").assert();
assert!(
env.is_symlink_to_profile("personal"),
"Should symlink to personal profile"
);
}
#[test]
fn test_malformed_profile_panics() {
let env = TestEnv::new();
fs::create_dir_all(env.claudectx_dir()).expect("Failed to create dir");
fs::write(env.profile_path("bad"), "not valid json {{{")
.expect("Failed to write invalid profile");
env.cmd()
.arg("list")
.assert()
.failure()
.stderr(predicate::str::contains("Failed to parse profile"));
}
#[test]
fn test_workflow_save_list_launch_delete() {
let env = TestEnv::new();
let account = sample_account("workflow");
env.create_claude_config(&account);
env.cmd().args(["save", "test-profile"]).assert().success();
env.cmd()
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("test-profile"))
.stdout(predicate::str::contains("User workflow"));
let _ = env.cmd().arg("test-profile").assert();
assert!(env.is_symlink_to_profile("test-profile"));
let output = env.cmd().arg("list").assert().success();
let stdout = String::from_utf8_lossy(&output.get_output().stdout);
assert!(stdout
.lines()
.any(|l| l.contains("test-profile") && l.contains(" *")));
env.cmd()
.args(["delete", "test-profile"])
.assert()
.success();
env.cmd()
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("No profiles found."));
}
#[test]
fn test_workflow_multiple_accounts() {
let env = TestEnv::new();
let work_account = sample_account("work");
env.create_claude_config(&work_account);
env.cmd().args(["save", "work"]).assert().success();
let personal_account = sample_account("personal");
env.create_claude_config(&personal_account);
env.cmd().args(["save", "personal"]).assert().success();
let side_account = sample_account("side");
env.create_claude_config(&side_account);
env.cmd().args(["save", "side-project"]).assert().success();
let _ = env.cmd().arg("work").assert();
let output = env.cmd().arg("list").assert().success();
let stdout = String::from_utf8_lossy(&output.get_output().stdout);
assert!(stdout.contains("work"));
assert!(stdout.contains("personal"));
assert!(stdout.contains("side-project"));
assert!(stdout
.lines()
.any(|l| l.contains("work") && l.contains(" *")));
}
#[test]
fn test_profiles_persistence_across_commands() {
let env = TestEnv::new();
let account = sample_account("persist");
env.create_claude_config(&account);
env.cmd()
.args(["save", "persistent-profile"])
.assert()
.success();
assert!(env.profile_path("persistent-profile").exists());
env.cmd()
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("persistent-profile"));
}
#[test]
fn test_save_help() {
let env = TestEnv::new();
env.cmd()
.args(["save", "--help"])
.assert()
.success()
.stdout(predicate::str::contains(
"Save current config as a new profile",
))
.stdout(predicate::str::contains("<NAME>"));
}
#[test]
fn test_delete_help() {
let env = TestEnv::new();
env.cmd()
.args(["delete", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("Delete a profile"))
.stdout(predicate::str::contains("<NAME>"));
}
#[test]
fn test_list_help() {
let env = TestEnv::new();
env.cmd()
.args(["list", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("List all saved profiles"));
}
#[test]
fn test_save_requires_name_argument() {
let env = TestEnv::new();
env.cmd()
.arg("save")
.assert()
.failure()
.stderr(predicate::str::contains("required"));
}
#[test]
fn test_delete_requires_name_argument() {
let env = TestEnv::new();
env.cmd()
.arg("delete")
.assert()
.failure()
.stderr(predicate::str::contains("required"));
}
#[test]
fn test_saved_profile_preserves_all_config_fields() {
let env = TestEnv::new();
let account = json!({
"accountUuid": "uuid-integrity",
"emailAddress": "integrity@example.com",
"organizationUuid": "org-uuid-integrity",
"displayName": "Integrity User",
"organizationRole": "admin",
"organizationName": "Integrity Org",
"hasExtraUsageEnabled": true,
"workspaceRole": "owner"
});
let config = json!({
"oauthAccount": account,
"lastAccountUUID": account["accountUuid"],
"primaryApiKey": "sk-ant-test-key",
"hasCompletedOnboarding": true,
"customField": "custom-value",
"nestedField": {
"inner": "value"
}
});
fs::write(
env.claude_config_path(),
serde_json::to_string_pretty(&config).expect("serialize"),
)
.expect("Failed to write config");
env.cmd()
.args(["save", "integrity-test"])
.assert()
.success();
let profile = env.read_profile("integrity-test");
assert_eq!(profile["oauthAccount"]["accountUuid"], "uuid-integrity");
assert_eq!(
profile["oauthAccount"]["emailAddress"],
"integrity@example.com"
);
assert_eq!(profile["customField"], "custom-value");
assert_eq!(profile["nestedField"]["inner"], "value");
}
#[test]
fn test_slugify_uppercase_to_lowercase() {
let env = TestEnv::new();
let account = sample_account("test");
env.create_claude_config(&account);
env.cmd()
.args(["save", "UPPERCASE"])
.assert()
.success()
.stdout(predicate::str::contains("'uppercase'"));
assert!(env.profile_path("uppercase").exists());
}
#[test]
fn test_slugify_handles_multiple_dashes() {
let env = TestEnv::new();
let account = sample_account("test");
env.create_claude_config(&account);
env.cmd()
.args(["save", "test---name"])
.assert()
.success()
.stdout(predicate::str::contains("'test-name'"));
assert!(env.profile_path("test-name").exists());
}
#[test]
fn test_login_help() {
let env = TestEnv::new();
env.cmd()
.args(["login", "--help"])
.assert()
.success()
.stdout(predicate::str::contains(
"Login to a new Claude account and save it as a profile",
));
}
#[test]
fn test_help_includes_login_command() {
let env = TestEnv::new();
env.cmd()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("login"));
}
impl TestEnv {
fn claude_config_backup_path(&self) -> std::path::PathBuf {
self.home_dir.path().join(".claude.json.bak")
}
}
#[test]
fn test_backup_file_location() {
let env = TestEnv::new();
let account = sample_account("backup-test");
env.create_claude_config(&account);
let backup_path = env.claude_config_backup_path();
assert!(backup_path.starts_with(env.home_path()));
assert!(backup_path.ends_with(".claude.json.bak"));
}
#[test]
fn test_list_marks_current_profile_when_config_matches_profile_content() {
let env = TestEnv::new();
let work_account = sample_account("work");
let personal_account = sample_account("personal");
env.create_profile("work", &work_account);
env.create_profile("personal", &personal_account);
env.create_claude_config(&work_account);
assert!(
!env.claude_config_path().is_symlink(),
".claude.json should be a regular file, not a symlink"
);
let output = env.cmd().arg("list").assert().success();
let stdout = String::from_utf8_lossy(&output.get_output().stdout);
assert!(
stdout
.lines()
.any(|l| l.contains("work") && l.contains(" *")),
"Profile 'work' should be marked with asterisk when config content matches. Output:\n{}",
stdout
);
assert!(
stdout
.lines()
.any(|l| l.contains("personal") && !l.contains(" *")),
"Profile 'personal' should NOT be marked with asterisk. Output:\n{}",
stdout
);
}
#[test]
fn test_list_no_asterisk_when_config_matches_no_profile() {
let env = TestEnv::new();
env.create_profile("work", &sample_account("work"));
env.create_profile("personal", &sample_account("personal"));
let different_account = sample_account("different");
env.create_claude_config(&different_account);
let output = env.cmd().arg("list").assert().success();
let stdout = String::from_utf8_lossy(&output.get_output().stdout);
assert!(
!stdout.contains(" *"),
"No profile should be marked when config doesn't match any profile. Output:\n{}",
stdout
);
}
#[test]
fn test_list_asterisk_symlink_takes_precedence() {
let env = TestEnv::new();
let work_account = sample_account("work");
let personal_account = sample_account("personal");
env.create_profile("work", &work_account);
env.create_profile("personal", &personal_account);
#[cfg(unix)]
std::os::unix::fs::symlink(env.profile_path("work"), env.claude_config_path())
.expect("Failed to create symlink");
#[cfg(windows)]
std::os::windows::fs::symlink_file(env.profile_path("work"), env.claude_config_path())
.expect("Failed to create symlink");
let output = env.cmd().arg("list").assert().success();
let stdout = String::from_utf8_lossy(&output.get_output().stdout);
assert!(
stdout
.lines()
.any(|l| l.contains("work") && l.contains(" *")),
"Profile 'work' should be marked via symlink detection. Output:\n{}",
stdout
);
}
#[test]
fn test_save_then_list_shows_asterisk_for_saved_profile() {
let env = TestEnv::new();
let account = sample_account("my-account");
env.create_claude_config(&account);
env.cmd().args(["save", "my-profile"]).assert().success();
assert!(
env.claude_config_path().is_symlink(),
".claude.json should be a symlink after save"
);
assert!(
env.is_symlink_to_profile("my-profile"),
".claude.json should symlink to the saved profile"
);
let output = env.cmd().arg("list").assert().success();
let stdout = String::from_utf8_lossy(&output.get_output().stdout);
assert!(
stdout
.lines()
.any(|l| l.contains("my-profile") && l.contains(" *")),
"Just-saved profile should be marked as current. Output:\n{}",
stdout
);
}
#[test]
fn test_save_when_not_symlink_creates_symlink() {
let env = TestEnv::new();
let account = sample_account("auto-link");
env.create_claude_config(&account);
assert!(!env.claude_config_path().is_symlink());
env.cmd().args(["save", "auto-link"]).assert().success();
assert!(
env.claude_config_path().is_symlink(),
".claude.json should be a symlink after save"
);
assert!(
env.is_symlink_to_profile("auto-link"),
".claude.json should symlink to 'auto-link' profile"
);
let profile = env.read_profile("auto-link");
assert_eq!(profile["oauthAccount"]["accountUuid"], "uuid-auto-link");
}
#[test]
fn test_save_when_already_symlink_does_not_change_symlink_target() {
let env = TestEnv::new();
let account1 = sample_account("first");
env.create_profile("first", &account1);
#[cfg(unix)]
std::os::unix::fs::symlink(env.profile_path("first"), env.claude_config_path())
.expect("Failed to create symlink");
#[cfg(windows)]
std::os::windows::fs::symlink_file(env.profile_path("first"), env.claude_config_path())
.expect("Failed to create symlink");
assert!(env.is_symlink_to_profile("first"));
env.cmd().args(["save", "second"]).assert().success();
assert!(
env.is_symlink_to_profile("first"),
"Symlink should still point to 'first' profile after saving as 'second'"
);
let profile = env.read_profile("second");
assert_eq!(profile["oauthAccount"]["accountUuid"], "uuid-first");
}
#[test]
fn test_switch_merges_portable_settings() {
let env = TestEnv::new();
let current_config = json!({
"oauthAccount": sample_account("current"),
"userID": "current-user-id",
"hasCompletedOnboarding": true,
"primaryApiKey": "sk-current-key",
"customSetting": "my-custom-value",
"editorTheme": "dark"
});
fs::write(
env.claude_config_path(),
serde_json::to_string_pretty(¤t_config).expect("serialize"),
)
.expect("write");
fs::create_dir_all(env.claudectx_dir()).expect("mkdir");
let target_config = json!({
"oauthAccount": sample_account("target"),
"userID": "target-user-id",
"hasCompletedOnboarding": false,
"primaryApiKey": "sk-target-key"
});
fs::write(
env.profile_path("target"),
serde_json::to_string_pretty(&target_config).expect("serialize"),
)
.expect("write");
let _ = env.cmd().arg("target").assert();
let merged = env.read_profile("target");
assert_eq!(merged["oauthAccount"]["accountUuid"], "uuid-target");
assert_eq!(merged["userID"], "target-user-id");
assert_eq!(merged["hasCompletedOnboarding"], true);
assert_eq!(merged["primaryApiKey"], "sk-current-key");
assert_eq!(merged["customSetting"], "my-custom-value");
assert_eq!(merged["editorTheme"], "dark");
}
#[test]
fn test_switch_preserves_account_specific_fields_from_target() {
let env = TestEnv::new();
let current_config = json!({
"oauthAccount": sample_account("current"),
"userID": "current-user-id",
"groveConfigCache": {"current": true},
"cachedChromeExtensionInstalled": true,
"subscriptionNoticeCount": 5,
"s1mAccessCache": {"current": "data"},
"recommendedSubscription": "pro",
"hasAvailableSubscription": true,
"portableSetting": "from-current"
});
fs::write(
env.claude_config_path(),
serde_json::to_string_pretty(¤t_config).expect("serialize"),
)
.expect("write");
fs::create_dir_all(env.claudectx_dir()).expect("mkdir");
let target_config = json!({
"oauthAccount": sample_account("target"),
"userID": "target-user-id",
"groveConfigCache": {"target": true},
"cachedChromeExtensionInstalled": false,
"subscriptionNoticeCount": 0,
"s1mAccessCache": {"target": "data"},
"recommendedSubscription": "free",
"hasAvailableSubscription": false,
"portableSetting": "from-target"
});
fs::write(
env.profile_path("target"),
serde_json::to_string_pretty(&target_config).expect("serialize"),
)
.expect("write");
let _ = env.cmd().arg("target").assert();
let merged = env.read_profile("target");
assert_eq!(merged["oauthAccount"]["accountUuid"], "uuid-target");
assert_eq!(merged["userID"], "target-user-id");
assert_eq!(merged["groveConfigCache"]["target"], true);
assert_eq!(merged["cachedChromeExtensionInstalled"], false);
assert_eq!(merged["subscriptionNoticeCount"], 0);
assert_eq!(merged["s1mAccessCache"]["target"], "data");
assert_eq!(merged["recommendedSubscription"], "free");
assert_eq!(merged["hasAvailableSubscription"], false);
assert_eq!(merged["portableSetting"], "from-current");
}
#[test]
fn test_switch_when_no_current_config_exists() {
let env = TestEnv::new();
assert!(!env.claude_config_path().exists());
env.create_profile("target", &sample_account("target"));
let _ = env.cmd().arg("target").assert();
assert!(
env.is_symlink_to_profile("target"),
"Should create symlink even when no prior config exists"
);
let profile = env.read_profile("target");
assert_eq!(profile["oauthAccount"]["accountUuid"], "uuid-target");
}