use crate::paths::Paths;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct PortableAccount {
pub name: String,
pub tool: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Portable {
pub version: u32,
pub accounts: Vec<PortableAccount>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unreadable: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub settings: Option<crate::settings::Settings>,
}
pub const FORMAT_VERSION: u32 = 1;
pub fn export(paths: &Paths) -> Portable {
let mut accounts = Vec::new();
let mut unreadable = Vec::new();
for tool in crate::adapters::names() {
if crate::slots::Slots::open_for(paths, tool).is_err() {
unreadable.push(tool.to_string());
}
if let Ok(s) = crate::slots::Slots::open_for(paths, tool) {
for r in s.list() {
accounts.push(PortableAccount {
name: r.name,
tool: tool.to_string(),
});
}
}
}
Portable {
version: FORMAT_VERSION,
unreadable,
accounts,
settings: Some(crate::settings::load(paths)),
}
}
pub fn plan<'a>(here: &[(String, String)], incoming: &'a Portable) -> Vec<&'a PortableAccount> {
incoming
.accounts
.iter()
.filter(|a| !here.iter().any(|(n, t)| n == &a.name && t == &a.tool))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nothing_a_credential_could_hide_in_survives_the_round_trip() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
let rec = crate::slots::Slots::open_for(&paths, "claude-code")
.unwrap()
.create("work")
.unwrap();
std::fs::write(
rec.config_dir.join(".credentials.json"),
br#"{"claudeAiOauth":{"accessToken":"sk-ant-SECRET","refreshToken":"rt-SECRET"}}"#,
)
.unwrap();
let text = serde_json::to_string(&export(&paths)).unwrap();
for forbidden in [
"sk-ant",
"SECRET",
"accessToken",
"refreshToken",
"claudeAiOauth",
] {
assert!(
!text.contains(forbidden),
"{forbidden} reached the export: {text}"
);
}
assert!(!text.contains(&rec.config_dir.display().to_string()));
}
#[test]
fn the_names_and_their_tools_travel() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
crate::slots::Slots::open_for(&paths, "claude-code")
.unwrap()
.create("work")
.unwrap();
crate::slots::Slots::open_for(&paths, "codex")
.unwrap()
.create("work")
.unwrap();
let p = export(&paths);
assert_eq!(p.version, FORMAT_VERSION);
assert_eq!(
p.accounts.len(),
2,
"same name on two tools is two accounts"
);
assert!(p.accounts.iter().any(|a| a.tool == "codex"));
}
#[test]
fn an_account_that_already_exists_is_left_alone() {
let incoming = Portable {
unreadable: Vec::new(),
version: FORMAT_VERSION,
accounts: vec![
PortableAccount {
name: "work".into(),
tool: "claude-code".into(),
},
PortableAccount {
name: "new".into(),
tool: "claude-code".into(),
},
PortableAccount {
name: "work".into(),
tool: "codex".into(),
},
],
settings: None,
};
let here = [("work".to_string(), "claude-code".to_string())];
let todo = plan(&here, &incoming);
let names: Vec<&str> = todo.iter().map(|a| a.name.as_str()).collect();
assert_eq!(
names,
["new", "work"],
"the codex 'work' is a different account"
);
}
}