use crate::error::{Error, Result};
use crate::{atomic, claude, home, time};
use serde_json::{Map, Value};
use std::path::{Path, PathBuf};
const BACKUPS_KEPT: usize = 10;
fn is_partitioned(value: &Value) -> bool {
let Some(map) = value.as_object() else {
return false;
};
!map.is_empty() && map.keys().all(|k| is_identifier(k))
}
fn is_identifier(key: &str) -> bool {
let k = key.strip_prefix("acct:").unwrap_or(key);
if let Some(rest) = k.strip_prefix("bi1-") {
return rest.len() == 16 && rest.bytes().all(|b| b.is_ascii_hexdigit());
}
k.len() == 36
&& k.split('-').map(str::len).eq([8, 4, 4, 4, 12])
&& k.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-')
}
fn mentions(value: &Value, identifiers: &[&str]) -> bool {
let is_one = |v: &Value| {
v.as_str()
.is_some_and(|s| identifiers.iter().any(|id| !id.is_empty() && s == *id))
};
match value {
Value::Object(fields) => fields.values().any(is_one),
other => is_one(other),
}
}
fn belongs_to(value: &Value, outgoing: &[&str]) -> bool {
!is_partitioned(value) && mentions(value, outgoing)
}
pub fn splice_identity(config: &mut Value, oauth_account: &Value, outgoing: &[&str]) {
let Some(root) = config.as_object_mut() else {
return;
};
let stale: Vec<String> = root
.iter()
.filter(|(k, v)| k.as_str() != "oauthAccount" && belongs_to(v, outgoing))
.map(|(k, _)| k.clone())
.collect();
for key in stale {
root.remove(&key);
}
let mut incoming = oauth_account.as_object().cloned().unwrap_or_else(Map::new);
incoming.remove("profileFetchedAt");
root.insert("oauthAccount".into(), Value::Object(incoming));
}
fn backups_dir() -> PathBuf {
home::dir().join("backups")
}
pub fn backup(path: &Path) -> Result<PathBuf> {
let fail = |source| Error::ConfigBackupFailed {
path: path.to_path_buf(),
source,
};
let dir = backups_dir();
home::create_private(&dir).map_err(fail)?;
let target = dir.join(format!("claude.json.{}", time::now()));
std::fs::copy(path, &target).map_err(fail)?;
let mut existing: Vec<PathBuf> = std::fs::read_dir(&dir)
.map_err(fail)?
.filter_map(std::result::Result::ok)
.map(|entry| entry.path())
.filter(|p| {
p.file_name()
.is_some_and(|n| n.to_string_lossy().starts_with("claude.json."))
})
.collect();
existing.sort();
for old in existing.iter().rev().skip(BACKUPS_KEPT) {
let _ = std::fs::remove_file(old);
}
Ok(target)
}
pub fn write(path: &Path, config: &Value) -> Result<()> {
let body = serde_json::to_string(config).expect("a loaded config is always serialisable");
atomic::write(path, body.as_bytes(), atomic::Perms::MatchExisting).map_err(|e| {
Error::ConfigWriteFailed {
path: path.to_path_buf(),
detail: e.to_string(),
}
})
}
pub fn path() -> PathBuf {
claude::config_file()
}
#[cfg(test)]
mod tests {
use super::*;
const OLD_ACCOUNT: &str = "8499da91-744e-452b-ab4d-cca7cc448a36";
const OLD_ORG: &str = "b8b291b2-2645-4cf3-a69f-d3256e996a9b";
const NEW_ACCOUNT: &str = "9aeb9c89-316c-4344-84c5-603d71dc5c9a";
fn config() -> Value {
serde_json::json!({
"oauthAccount": {"accountUuid": OLD_ACCOUNT, "emailAddress": "old@x.com",
"profileFetchedAt": 1789871209282i64},
"groveConfigCache": {OLD_ACCOUNT: {"a": 1}, NEW_ACCOUNT: {"a": 2}},
"passesEligibilityCache": {OLD_ORG: {"eligible": true}},
"clientDataCacheSlots": {"bi1-0123456789abcdef": {"x": 1}},
"cachedArtifactRoster": {"org": OLD_ORG, "items": []},
"cachedUsageUtilization": {"accountUuid": OLD_ACCOUNT, "utilization": {}},
"numStartups": 412,
"autoUpdates": true,
"projects": {"/Users/x/code": {"allowedTools": []}}
})
}
#[test]
fn partitioned_caches_survive_a_switch() {
let mut c = config();
splice_identity(
&mut c,
&serde_json::json!({"accountUuid": NEW_ACCOUNT}),
&[OLD_ACCOUNT, OLD_ORG],
);
assert!(c.get("groveConfigCache").is_some());
assert!(c.get("passesEligibilityCache").is_some());
assert!(c.get("clientDataCacheSlots").is_some());
}
#[test]
fn unpartitioned_caches_of_the_outgoing_account_are_dropped() {
let mut c = config();
splice_identity(
&mut c,
&serde_json::json!({"accountUuid": NEW_ACCOUNT}),
&[OLD_ACCOUNT, OLD_ORG],
);
assert!(c.get("cachedArtifactRoster").is_none());
assert!(c.get("cachedUsageUtilization").is_none());
}
#[test]
fn machine_and_preference_state_is_left_alone() {
let mut c = config();
splice_identity(
&mut c,
&serde_json::json!({"accountUuid": NEW_ACCOUNT}),
&[OLD_ACCOUNT, OLD_ORG],
);
assert_eq!(c["numStartups"], 412);
assert_eq!(c["autoUpdates"], true);
assert!(c["projects"]["/Users/x/code"].is_object());
}
#[test]
fn oauth_account_is_replaced_never_removed() {
let mut c = config();
splice_identity(
&mut c,
&serde_json::json!({"accountUuid": NEW_ACCOUNT, "emailAddress": "new@x.com"}),
&[OLD_ACCOUNT, OLD_ORG],
);
assert_eq!(c["oauthAccount"]["accountUuid"], NEW_ACCOUNT);
assert_eq!(c["oauthAccount"]["emailAddress"], "new@x.com");
}
#[test]
fn profile_fetched_at_is_stripped_so_claude_code_refetches() {
let mut c = config();
let incoming = serde_json::json!({"accountUuid": NEW_ACCOUNT, "profileFetchedAt": 999});
splice_identity(&mut c, &incoming, &[OLD_ACCOUNT, OLD_ORG]);
assert!(c["oauthAccount"].get("profileFetchedAt").is_none());
}
#[test]
fn identifier_shapes_match_what_claude_code_actually_uses() {
assert!(is_identifier(OLD_ACCOUNT));
assert!(is_identifier(&format!("acct:{OLD_ACCOUNT}")));
assert!(is_identifier("bi1-0123456789abcdef"));
for not in [
"numStartups",
"bi1-short",
"bi1-0123456789abcdeZ",
"",
"a-b-c-d-e",
] {
assert!(
!is_identifier(not),
"{not:?} should not read as an identifier"
);
}
}
#[test]
fn an_empty_object_is_not_treated_as_partitioned() {
assert!(!is_partitioned(&serde_json::json!({})));
}
#[test]
fn a_container_with_the_outgoing_id_buried_deep_inside_is_kept() {
let mut c = config();
c["projects"]["/Users/x/code"]["lastSession"] =
serde_json::json!({"org": OLD_ORG, "at": 1});
splice_identity(&mut c, &serde_json::json!({}), &[OLD_ACCOUNT, OLD_ORG]);
assert!(
c["projects"]["/Users/x/code"].is_object(),
"every project's settings would have been dropped"
);
}
#[test]
fn an_id_that_merely_contains_the_outgoing_one_is_not_a_match() {
let mut c = config();
c["unrelated"] = serde_json::json!({"note": format!("prefix-{OLD_ACCOUNT}-suffix")});
splice_identity(&mut c, &serde_json::json!({}), &[OLD_ACCOUNT, OLD_ORG]);
assert!(c.get("unrelated").is_some());
}
#[test]
fn a_cache_naming_neither_uuid_is_kept() {
let mut c = config();
c["somethingNew"] = serde_json::json!({"unrelated": "value"});
splice_identity(&mut c, &serde_json::json!({}), &[OLD_ACCOUNT, OLD_ORG]);
assert!(c.get("somethingNew").is_some());
}
}