Skip to main content

pitboard_core/
state.rs

1//! Which accounts Pitboard knows and where each one is parked. No secrets: the logins stay
2//! in the keychain or vault.
3//!
4//! Stamped with the machine that wrote it, because a parked login belongs to exactly one
5//! machine: presenting a refresh token another machine has since rotated ends the login on
6//! both.
7
8use crate::api::Owner;
9use crate::context::Context;
10use crate::error::{Error, Result};
11use crate::provider::ProviderId;
12use crate::service::Permit;
13use crate::{atomic, home};
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use std::collections::BTreeMap;
17use std::path::PathBuf;
18
19const SCHEMA: u32 = 5;
20
21/// A login held for an account while another is signed in. There is at most one per
22/// account: once installed it is Claude Code's again, and Claude Code rotates it from then
23/// on, so a copy kept back would only ever present a token it has moved past.
24#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
25pub struct Park {
26    pub service: String,
27    pub parked_at: i64,
28    pub refresh_fingerprint: String,
29    /// Until then its usage can be asked without renewing it first.
30    pub access_expires_at: Option<i64>,
31    /// Until then it can be restored.
32    pub refresh_expires_at: Option<i64>,
33}
34
35impl Park {
36    pub fn restorable_at(&self, now: i64) -> bool {
37        self.refresh_expires_at.is_none_or(|at| at > now)
38    }
39
40    pub fn askable_at(&self, now: i64) -> bool {
41        self.access_expires_at.is_none_or(|at| at > now)
42    }
43}
44
45/// What one provider keeps about an account that the others have no equivalent of.
46///
47/// A tagged enum rather than a pile of optional fields, so no code reading a Codex account
48/// ever has to decide what an absent Claude organisation means for it. `provider` is the
49/// tag, and the variant's own fields sit beside `label` and `email` in the file, which is
50/// why a schema 3 account needs nothing moved to become a schema 4 one.
51#[derive(Serialize, Deserialize, Debug, Clone)]
52#[serde(tag = "provider", rename_all = "snake_case")]
53#[non_exhaustive]
54pub enum Detail {
55    Claude {
56        organization_uuid: String,
57        /// Written into Claude Code's config on switching here. Only what Anthropic
58        /// confirmed, so Claude Code fetches the rest of its profile itself.
59        oauth_account: Value,
60    },
61    Codex {
62        /// The ChatGPT workspace this account belongs to, where it belongs to one.
63        #[serde(default)]
64        workspace_id: Option<String>,
65        /// `plus`, `pro`, `team` and so on, read out of the login's own ID token. Kept
66        /// because it is free to know and explains a limit somebody is surprised by.
67        #[serde(default)]
68        plan: Option<String>,
69    },
70}
71
72/// Claude Code's own extras, for a caller that has already established it is holding a
73/// Claude account.
74pub struct ClaudeDetail<'a> {
75    pub organization_uuid: &'a str,
76    pub oauth_account: &'a Value,
77}
78
79#[derive(Serialize, Deserialize, Debug, Clone)]
80pub struct Account {
81    pub label: String,
82    /// What Pitboard files the account's parked logins, readings, budget and windows under.
83    /// Set at enrolment and never changed, so none of them has to move.
84    pub id: String,
85    /// The account as its tool names it: Anthropic's account uuid, which is the person and
86    /// not the login, or Codex's ChatGPT account joined with the person in it.
87    pub account_uuid: String,
88    pub email: String,
89    pub parked: Option<Park>,
90    /// When this account was last switched to, in epoch seconds.
91    ///
92    /// Pitboard renews a parked login for as long as the account is enrolled, so an account
93    /// somebody enrolled once and never came back to keeps a live, continuously rotated
94    /// refresh token on the machine indefinitely. Nothing said so, and nothing asked.
95    /// Recording this is what lets `doctor` say it.
96    ///
97    /// `None` on an account enrolled before this was recorded, and on one that has never
98    /// been switched to.
99    #[serde(default)]
100    pub last_used_at: Option<i64>,
101    /// Which tool's login this is, and whatever only that tool keeps.
102    #[serde(flatten)]
103    pub detail: Detail,
104}
105
106/// One account, the way Pitboard tells accounts apart: which tool, and what it is called
107/// there.
108///
109/// A label alone stopped being enough the day a second tool could have a `work` of its own.
110/// Everything that finds, changes or drops an account takes one of these, so no lookup can
111/// quietly land on the other tool's account of the same name.
112#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
113pub struct Key {
114    pub provider: ProviderId,
115    pub label: String,
116}
117
118impl Key {
119    pub fn new(provider: ProviderId, label: impl Into<String>) -> Key {
120        Key {
121            provider,
122            label: label.into(),
123        }
124    }
125
126    /// The name as somebody would type it back: bare for the tool a bare name means,
127    /// qualified for any other. Every message and the audit log use this, so a Claude Code
128    /// account reads exactly as it did before there was a second tool.
129    pub fn typed(&self) -> String {
130        if self.provider == crate::label::DEFAULT {
131            self.label.clone()
132        } else {
133            self.qualified()
134        }
135    }
136
137    /// `codex/work`, whichever tool it is.
138    pub fn qualified(&self) -> String {
139        format!(
140            "{}{}{}",
141            self.provider.code(),
142            crate::label::SEPARATOR,
143            self.label
144        )
145    }
146}
147
148impl std::fmt::Display for Key {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        f.write_str(&self.typed())
151    }
152}
153
154impl Account {
155    pub fn key(&self) -> Key {
156        Key::new(self.provider(), self.label.clone())
157    }
158
159    pub fn is(&self, key: &Key) -> bool {
160        self.label == key.label && self.provider() == key.provider
161    }
162
163    pub fn provider(&self) -> ProviderId {
164        match self.detail {
165            Detail::Claude { .. } => ProviderId::Claude,
166            Detail::Codex { .. } => ProviderId::Codex,
167        }
168    }
169
170    /// Whether `owner`'s login is this account's.
171    pub fn owned_by(&self, owner: &Owner) -> bool {
172        self.account_uuid == owner.account_uuid
173            && match &self.detail {
174                // One person in two organisations holds two logins, each on its own plan.
175                Detail::Claude {
176                    organization_uuid, ..
177                } => *organization_uuid == owner.organization_uuid,
178                // Codex's account id already joins the workspace with the person in it.
179                Detail::Codex { .. } => true,
180            }
181    }
182
183    /// Claude Code's extras, or `None` when this account belongs to another tool.
184    pub fn claude(&self) -> Option<ClaudeDetail<'_>> {
185        match &self.detail {
186            Detail::Claude {
187                organization_uuid,
188                oauth_account,
189            } => Some(ClaudeDetail {
190                organization_uuid,
191                oauth_account,
192            }),
193            Detail::Codex { .. } => None,
194        }
195    }
196}
197
198#[derive(Serialize, Deserialize, Debug, Clone)]
199pub struct State {
200    pub schema: u32,
201    pub machine: String,
202    pub accounts: Vec<Account>,
203    /// Which account is signed in, per provider.
204    ///
205    /// One string until schema 4, which stopped being true the moment a machine could have
206    /// a Claude Code login and a Codex login at the same time. They are different programs
207    /// reading different stores; neither signs the other out.
208    #[serde(default)]
209    pub active: BTreeMap<String, String>,
210    /// The credential slot each provider's `active` was recorded for. One state file serves
211    /// every slot a machine uses, and a tool's own home variable changes which store is the
212    /// live one, so a record made in one slot says nothing about another.
213    #[serde(default)]
214    pub slot: BTreeMap<String, String>,
215    /// Parked items no account refers to any more. Listed in the same save that drops them
216    /// and removed once deleted, so a delete that fails or is interrupted is retried.
217    #[serde(default)]
218    pub discarded: Vec<String>,
219    /// Parked items an account here holds that this home never wrote down: logins
220    /// `pitboard repair` found in the store and gave back. On macOS every `PITBOARD_HOME`
221    /// shares the login keychain, so each may be another Pitboard's parked login, and
222    /// letting one go unused must leave it where it is. Removed once nothing here holds it.
223    /// Always empty where the vault is a directory inside this home, which nobody else
224    /// parks in.
225    #[serde(default)]
226    pub foreign: Vec<String>,
227}
228
229impl Default for State {
230    fn default() -> Self {
231        State {
232            schema: SCHEMA,
233            machine: machine_id(),
234            accounts: Vec::new(),
235            active: BTreeMap::new(),
236            slot: BTreeMap::new(),
237            discarded: Vec::new(),
238            foreign: Vec::new(),
239        }
240    }
241}
242
243impl State {
244    /// Which account is signed in for this provider, as Pitboard last recorded it.
245    pub fn active_for(&self, provider: ProviderId) -> Option<&str> {
246        self.active.get(provider.code()).map(String::as_str)
247    }
248
249    pub fn set_active(&mut self, provider: ProviderId, label: Option<String>) {
250        match label {
251            Some(label) => self.active.insert(provider.code().to_string(), label),
252            None => self.active.remove(provider.code()),
253        };
254    }
255
256    /// The credential slot this provider's `active` was recorded for.
257    pub fn slot_for(&self, provider: ProviderId) -> Option<&str> {
258        self.slot.get(provider.code()).map(String::as_str)
259    }
260
261    pub fn set_slot(&mut self, provider: ProviderId, slot: String) {
262        self.slot.insert(provider.code().to_string(), slot);
263    }
264
265    /// The account's name as a command would take it here: bare for the tool a bare name
266    /// means, unless another tool has an account of the same name, in which case a bare
267    /// name would be refused as ambiguous and the tool is said.
268    ///
269    /// What every message that tells somebody what to type uses, so the command it names
270    /// is one that works on this machine.
271    pub fn typed(&self, key: &Key) -> String {
272        let shared = self
273            .accounts
274            .iter()
275            .any(|a| a.label == key.label && a.provider() != key.provider);
276        if shared { key.qualified() } else { key.typed() }
277    }
278
279    /// Every label this tool has enrolled, for a message that would otherwise send someone
280    /// to another command to find out.
281    pub fn labels(&self, provider: ProviderId) -> crate::error::Enrolled {
282        crate::error::Enrolled(
283            self.accounts
284                .iter()
285                .filter(|a| a.provider() == provider)
286                .map(|a| a.label.clone())
287                .collect(),
288        )
289    }
290
291    /// Whether anything in the state refers to this vault item: an account holding it, or
292    /// the list of ones waiting to be deleted.
293    pub fn names(&self, service: &str) -> bool {
294        self.accounts
295            .iter()
296            .filter_map(|a| a.parked.as_ref())
297            .any(|p| p.service == service)
298            || self.discarded.iter().any(|s| s == service)
299    }
300
301    /// The account under this key.
302    ///
303    /// Callers that took a name from a person should go through [`crate::label::resolve`]
304    /// first, which knows what to do when two tools share a label.
305    pub fn get(&self, key: &Key) -> Option<&Account> {
306        self.accounts.iter().find(|a| a.is(key))
307    }
308
309    /// This tool's account holding `owner`'s login.
310    pub fn account_of(&self, provider: ProviderId, owner: &Owner) -> Option<&Account> {
311        self.accounts
312            .iter()
313            .find(|a| a.provider() == provider && a.owned_by(owner))
314    }
315
316    /// What `owner`'s login is filed under: its account's id, or the one it would be
317    /// enrolled with.
318    pub fn id_of(&self, provider: ProviderId, owner: &Owner) -> String {
319        self.account_of(provider, owner)
320            .map_or_else(|| new_id(provider, owner), |a| a.id.clone())
321    }
322
323    /// This tool's account filed under `id`.
324    pub fn by_id(&self, provider: ProviderId, id: &str) -> Option<&Account> {
325        self.accounts
326            .iter()
327            .find(|a| a.provider() == provider && a.id == id)
328    }
329
330    /// The account a parked item was written for.
331    ///
332    /// A park's name carries the account's id and not its tool, because names were fixed
333    /// before there was a second tool and every item already on a machine is filed under
334    /// one. The ids cannot collide in practice: each is built from its tool's UUIDs.
335    pub fn owner_of_park(&self, id: &str) -> Option<&Account> {
336        self.accounts.iter().find(|a| a.id == id)
337    }
338
339    fn get_mut(&mut self, key: &Key) -> Option<&mut Account> {
340        self.accounts.iter_mut().find(|a| a.is(key))
341    }
342
343    /// Hold `park` for the account, releasing whatever it replaces. A newer park does not
344    /// use the one before it, so that one is let go rather than consumed.
345    pub fn park(&mut self, key: &Key, park: Park) {
346        let service = park.service.clone();
347        if let Some(previous) = self
348            .get_mut(key)
349            .and_then(|account| account.parked.replace(park))
350            && previous.service != service
351        {
352            self.release(&previous.service);
353        }
354    }
355
356    /// Hold a park this home did not write, found in the store and given back. It is used
357    /// like any other, and deleted only once it has been.
358    pub fn park_foreign(&mut self, key: &Key, park: Park) {
359        let service = park.service.clone();
360        self.park(key, park);
361        if self.references(&service) && !self.is_foreign(&service) {
362            self.foreign.push(service);
363        }
364    }
365
366    /// Whether an account here holds `service` without this home having written it.
367    pub fn is_foreign(&self, service: &str) -> bool {
368        self.foreign.iter().any(|listed| listed == service)
369    }
370
371    /// Take `service` as this home's own from now on, because this home has used it: a
372    /// renewal presented its refresh token. Letting it go afterwards deletes it.
373    pub fn used_here(&mut self, service: &str) {
374        self.foreign.retain(|listed| listed != service);
375    }
376
377    /// Stop holding `service` because it has been used up, and list it for deletion
378    /// whoever wrote it: it has been installed, a renewal has spent it, or it copies a login
379    /// that is still signed in. Nothing can use it again, and for a tool whose park may
380    /// never be a copy it must not stay beside the login it copies.
381    pub fn discard(&mut self, service: &str) {
382        self.let_go(service);
383        if !self.discarded.iter().any(|listed| listed == service) {
384            self.discarded.push(service.to_string());
385        }
386    }
387
388    /// Stop holding `service` because nothing here wants it any more: a newer park replaced
389    /// it, or its account was dropped. Listed for deletion only when this home wrote it. One
390    /// `repair` gave back may be another Pitboard's parked login, which this one never
391    /// used, and deleting it would end that account's session for somebody who never ran
392    /// the command that did it.
393    pub fn release(&mut self, service: &str) {
394        if self.is_foreign(service) {
395            self.let_go(service);
396        } else {
397            self.discard(service);
398        }
399    }
400
401    /// No account holds `service` afterwards, and nothing records who wrote it.
402    fn let_go(&mut self, service: &str) {
403        for account in &mut self.accounts {
404            account.parked.take_if(|p| p.service == service);
405        }
406        self.foreign.retain(|listed| listed != service);
407    }
408
409    pub fn references(&self, service: &str) -> bool {
410        self.accounts
411            .iter()
412            .any(|a| a.parked.as_ref().is_some_and(|p| p.service == service))
413    }
414
415    /// Record that the account under `key` was just put to use.
416    pub fn used(&mut self, key: &Key, at: i64) {
417        if let Some(account) = self.get_mut(key) {
418            account.last_used_at = Some(at);
419        }
420    }
421
422    /// Add the account, or replace the one this tool already has under its label.
423    pub fn upsert(&mut self, account: Account) {
424        match self.get_mut(&account.key()) {
425            Some(existing) => *existing = account,
426            None => self.accounts.push(account),
427        }
428    }
429
430    /// Enroll the account under `from` as `to` instead, inside the same tool. Only the
431    /// label changes: parked logins are named by account, not by label.
432    pub fn relabel(&mut self, from: &Key, to: &str) -> Result<&Account> {
433        let target = Key::new(from.provider, to);
434        if from.label != to
435            && let Some(taken) = self.get(&target)
436        {
437            let renamed = self.get(from).map(|a| a.email.as_str());
438            return Err(Error::LabelTaken {
439                label: target.typed(),
440                who: crate::words::login(
441                    from.provider,
442                    &taken.email,
443                    renamed == Some(taken.email.as_str()),
444                ),
445            });
446        }
447        if self.active_for(from.provider) == Some(from.label.as_str()) {
448            self.set_active(from.provider, Some(to.to_string()));
449        }
450        let enrolled = self.labels(from.provider);
451        let account = self.get_mut(from).ok_or_else(|| Error::AccountUnknown {
452            label: from.typed(),
453            enrolled,
454        })?;
455        account.label = to.to_string();
456        Ok(account)
457    }
458
459    /// Drop the account, releasing its park.
460    pub fn remove(&mut self, key: &Key) -> Option<Account> {
461        let index = self.accounts.iter().position(|a| a.is(key))?;
462        let account = self.accounts.remove(index);
463        if let Some(park) = &account.parked {
464            self.release(&park.service);
465        }
466        if self.active_for(key.provider) == Some(key.label.as_str()) {
467            self.set_active(key.provider, None);
468        }
469        Some(account)
470    }
471}
472
473/// The id a login is enrolled with. Claude's joins the organisation, since Anthropic's
474/// account uuid is the person and each of their organisations is a login of its own. `_`
475/// is in neither uuid, and is safe in a park's name and a readings file.
476pub fn new_id(provider: ProviderId, owner: &Owner) -> String {
477    match provider {
478        ProviderId::Claude => format!("{}_{}", owner.account_uuid, owner.organization_uuid),
479        ProviderId::Codex => owner.account_uuid.clone(),
480    }
481}
482
483/// Hashed, so the raw platform identifier never lands in a file Pitboard writes.
484pub fn machine_id() -> String {
485    use sha2::{Digest, Sha256};
486    match machine_uid::get() {
487        Ok(raw) => hex::encode(Sha256::digest(raw.as_bytes())),
488        Err(_) => String::from("unknown"),
489    }
490}
491
492fn file(ctx: &Context) -> PathBuf {
493    home::dir(ctx).join("state.json")
494}
495
496/// When Pitboard's account index last changed, in epoch seconds, or 0 when there is none.
497///
498/// Three front ends run on one machine and none of them could tell when another had
499/// changed anything. A switch typed in a terminal left the menu bar naming the account the
500/// person had just stopped using, for as long as five minutes, with a button offering a
501/// switch that had already happened.
502///
503/// This is the cheapest true answer there is: one stat of one file. It is deliberately the
504/// account index alone and not the whole directory. The status line writes usage readings
505/// after a message in any open session, and those say nothing about who is signed in: a
506/// front end follows them with `readings::changed_at`, and takes only the numbers.
507pub fn changed_at(ctx: &Context) -> i64 {
508    std::fs::metadata(file(ctx))
509        .and_then(|m| m.modified())
510        .ok()
511        .and_then(|at| at.duration_since(std::time::UNIX_EPOCH).ok())
512        .map_or(0, |since| {
513            i64::try_from(since.as_secs()).unwrap_or(i64::MAX)
514        })
515}
516
517pub fn load(ctx: &Context) -> Result<State> {
518    let (state, here) = load_any_machine(ctx)?;
519    if !here {
520        return Err(Error::StateWrongMachine { path: file(ctx) });
521    }
522    Ok(state)
523}
524
525/// The state whatever machine wrote it, and whether that machine is this one.
526///
527/// Only `adopt` reads it this way. Everything else goes through [`load`], which refuses a
528/// file from elsewhere: a parked login is a refresh token, and two machines taking turns
529/// presenting one ends the login for both.
530///
531/// A build that may do nothing here reads nothing either ([`crate::release`]), so an app on
532/// a Windows build of a release before Windows is released is refused at its first read.
533pub(crate) fn load_any_machine(ctx: &Context) -> Result<(State, bool)> {
534    crate::release::check()?;
535    let path = file(ctx);
536    home::check_absolute(ctx)?;
537    home::check_location(&home::dir(ctx))?;
538    let raw = match std::fs::read_to_string(&path) {
539        Ok(s) => s,
540        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((State::default(), true)),
541        Err(source) => return Err(Error::StateUnreadable { path, source }),
542    };
543    let mut document: serde_json::Value =
544        serde_json::from_str(&raw).map_err(|source| Error::StateCorrupt {
545            path: path.clone(),
546            source,
547        })?;
548    migrate(&mut document, &path)?;
549    // An account of a tool this build does not know was written by a newer Pitboard, not
550    // damaged. Said as such, because the advice for a corrupt file is to delete it, and
551    // following that here would orphan every parked login in the vault.
552    if let Some(unknown) = unknown_tool(&document) {
553        return Err(Error::StateNamesUnknownTool {
554            path: path.clone(),
555            tool: unknown,
556        });
557    }
558    let state: State = serde_json::from_value(document).map_err(|source| Error::StateCorrupt {
559        path: path.clone(),
560        source,
561    })?;
562    let here = state.machine == machine_id();
563    let mut state = state;
564    // Which account is in use is a fact about one slot. Read from another, the record says
565    // nothing, and Pitboard asks the tool who is signed in anyway. Per tool, so a changed
566    // `CLAUDE_CONFIG_DIR` says nothing about Codex's record, nor `CODEX_HOME` about Claude
567    // Code's.
568    for &tool in ProviderId::ALL {
569        let slot = crate::provider::of(tool).slot(ctx);
570        if state
571            .slot_for(tool)
572            .is_some_and(|recorded| recorded != slot)
573        {
574            state.set_active(tool, None);
575        }
576    }
577    Ok((state, here))
578}
579
580/// The first tool an account names that this build does not know, if any.
581fn unknown_tool(document: &Value) -> Option<String> {
582    document
583        .get("accounts")?
584        .as_array()?
585        .iter()
586        .filter_map(|account| account.get("provider")?.as_str())
587        .find(|code| ProviderId::parse(code).is_none())
588        .map(str::to_owned)
589}
590
591/// Brings an older file up to the current format in place.
592///
593/// The command line and the app carry their own copy of this crate and update by different
594/// routes, so on one machine an older Pitboard will meet a file a newer one wrote. Reading
595/// forwards is what this is for; reading backwards is not possible, and says so.
596fn migrate(document: &mut serde_json::Value, path: &std::path::Path) -> Result<()> {
597    // Each bump adds a step after the ones before it, so a file two versions behind is
598    // brought all the way forward in one read.
599    let found = document
600        .get("schema")
601        .and_then(serde_json::Value::as_u64)
602        .unwrap_or_default() as u32;
603    match found {
604        SCHEMA => Ok(()),
605        3 | 4 => {
606            if found == 3 {
607                three_to_four(document);
608            }
609            four_to_five(document);
610            Ok(())
611        }
612        // Nothing released wrote 1 or 2: the schema reached 3 before the first release.
613        0..3 => Err(Error::StateVersionUnknown {
614            path: path.to_path_buf(),
615            found,
616        }),
617        _ => Err(Error::StateFromNewerVersion {
618            path: path.to_path_buf(),
619            found,
620            expected: SCHEMA,
621        }),
622    }
623}
624
625/// Schema 3 was Claude Code and nothing else, so every account in one is a Claude account
626/// and the two singular records are Claude's.
627///
628/// Deliberately the smallest transform there could be. Nothing is nested and nothing is
629/// renamed, because `Detail` is flattened and tagged: a schema 3 account already has
630/// `organization_uuid` and `oauth_account` as siblings of `label`, which is exactly where
631/// schema 4 reads them. All that is missing is the tag. No keychain item and no vault file
632/// is touched, so a bug here is recoverable by fixing the code and reading again, never by
633/// somebody signing in from scratch.
634fn three_to_four(document: &mut serde_json::Value) {
635    let claude = serde_json::Value::from(ProviderId::Claude.code());
636    if let Some(accounts) = document.get_mut("accounts").and_then(Value::as_array_mut) {
637        for account in accounts {
638            if let Some(fields) = account.as_object_mut() {
639                fields.insert("provider".into(), claude.clone());
640            }
641        }
642    }
643    for singular in ["active", "slot"] {
644        let was = document.get(singular).cloned().unwrap_or(Value::Null);
645        document[singular] = match was {
646            Value::String(label) => serde_json::json!({ ProviderId::Claude.code(): label }),
647            _ => serde_json::json!({}),
648        };
649    }
650    document["schema"] = serde_json::json!(4);
651}
652
653/// Schema 4 filed every account under its tool's account id, and Anthropic's is the person:
654/// one person in two organisations was one account. Schema 5 gives each account an id of
655/// its own, and an account already enrolled keeps the one everything is filed under, so
656/// no park, reading or window has to move.
657fn four_to_five(document: &mut serde_json::Value) {
658    if let Some(accounts) = document.get_mut("accounts").and_then(Value::as_array_mut) {
659        for account in accounts {
660            if let Some(fields) = account.as_object_mut()
661                && let Some(id) = fields.get("account_uuid").cloned()
662            {
663                fields.insert("id".into(), id);
664            }
665        }
666    }
667    document["schema"] = serde_json::json!(SCHEMA);
668}
669
670pub(crate) fn save(ctx: &Context, permit: Permit, state: &State) -> Result<()> {
671    home::check_absolute(ctx)?;
672    home::check_location(&home::dir(ctx))?;
673    let mut state = state.clone();
674    for &tool in ProviderId::ALL {
675        state.set_slot(tool, crate::provider::of(tool).slot(ctx));
676    }
677    let state = &state;
678    let path = file(ctx);
679    let write = |source| Error::StateWriteFailed {
680        path: path.clone(),
681        source,
682    };
683    home::ensure(ctx, permit).map_err(write)?;
684    let body = serde_json::to_string_pretty(state).expect("State is always serialisable");
685    atomic::write(permit, &path, body.as_bytes(), atomic::Perms::Secret).map_err(write)
686}
687
688#[cfg(test)]
689mod tests {
690    /// CLAUDE_CONFIG_DIR picks which keychain item is the live one, and one state file
691    /// serves every slot on a machine. A record of what was switched to in one slot says
692    /// nothing about another, so it is not carried over.
693    #[test]
694    fn what_was_active_in_another_slot_is_not_claimed_here() {
695        let home = std::env::temp_dir().join(format!("pitboard-slots-{}", std::process::id()));
696        let _ = std::fs::remove_dir_all(&home);
697        let here = Context::new(home.clone()).with_pitboard_home(home.clone());
698        let elsewhere = here
699            .clone()
700            .with_claude_config_dir("/somewhere/else".into());
701
702        let mut state = State::default();
703        state.accounts.push(Account {
704            last_used_at: None,
705            label: "work".into(),
706            id: "acc".into(),
707            account_uuid: "acc".into(),
708            email: "a@b.c".into(),
709            detail: Detail::Claude {
710                organization_uuid: "org".into(),
711                oauth_account: serde_json::json!({}),
712            },
713            parked: None,
714        });
715        state.set_active(ProviderId::Claude, Some("work".into()));
716        save(&here, Permit::for_a_test(), &state).expect("saved");
717
718        assert_eq!(
719            load(&here).unwrap().active_for(ProviderId::Claude),
720            Some("work")
721        );
722        assert_eq!(
723            load(&elsewhere).unwrap().active_for(ProviderId::Claude),
724            None,
725            "another slot's record of what is in use is not this slot's"
726        );
727        let _ = std::fs::remove_dir_all(&home);
728    }
729
730    /// `CODEX_HOME` picks which `auth.json` is Codex's live login, the way
731    /// `CLAUDE_CONFIG_DIR` picks Claude Code's keychain item. A record made under one home
732    /// says nothing about another, and says nothing about Claude Code's at all.
733    #[test]
734    fn another_codex_home_is_another_codex_slot() {
735        let home = std::env::temp_dir().join(format!(
736            "pitboard-codex-slots-{}-{:?}",
737            std::process::id(),
738            std::thread::current().id()
739        ));
740        let _ = std::fs::remove_dir_all(&home);
741        let here = Context::new(home.clone()).with_pitboard_home(home.clone());
742        let mut state = State::default();
743        state.set_active(ProviderId::Claude, Some("work".into()));
744        state.set_active(ProviderId::Codex, Some("work".into()));
745        save(&here, Permit::for_a_test(), &state).expect("saved");
746
747        let moved = here.clone().with_codex_home("/somewhere/else".into());
748        let loaded = load(&moved).unwrap();
749        assert_eq!(loaded.active_for(ProviderId::Codex), None);
750        assert_eq!(
751            loaded.active_for(ProviderId::Claude),
752            Some("work"),
753            "Claude Code's slot did not move"
754        );
755        assert_eq!(
756            load(&here).unwrap().active_for(ProviderId::Codex),
757            Some("work")
758        );
759        let _ = std::fs::remove_dir_all(&home);
760    }
761
762    /// A file written by the version in people's hands today. The command line and the app
763    /// update separately, so a file one of them wrote has to keep loading in the other.
764    #[test]
765    fn the_format_shipped_in_0_1_x_still_loads() {
766        let written = serde_json::json!({
767            "schema": 3,
768            "machine": machine_id(),
769            "accounts": [{
770                "label": "work",
771                "account_uuid": "acc-1",
772                "email": "a@b.c",
773                "organization_uuid": "org-1",
774                "oauth_account": {"emailAddress": "a@b.c"},
775                "parked": {
776                    "service": "pitboard-park-acc-1-1789935600123",
777                    "parked_at": 1_789_935_600,
778                    "refresh_fingerprint": "abcd",
779                    "access_expires_at": 1_789_999_999,
780                    "refresh_expires_at": 1_792_000_000
781                }
782            }],
783            "active": "work",
784            "discarded": []
785        });
786        let mut document = written.clone();
787        migrate(&mut document, std::path::Path::new("/tmp/state.json")).expect("still current");
788        let state: State = serde_json::from_value(document).expect("still parses");
789        assert_eq!(state.get(&claude("work")).unwrap().email, "a@b.c");
790        assert_eq!(state.get(&claude("work")).unwrap().id, "acc-1");
791        assert_eq!(state.active_for(ProviderId::Claude), Some("work"));
792    }
793
794    /// Migrating a file that is already current must change nothing.
795    ///
796    /// `three_to_four` rewrites `active` and `slot` in place, and a version check that
797    /// slipped would wrap an already-wrapped map into `{"claude": {"claude": "work"}}` and
798    /// lose which account is in use, silently, on every load after that.
799    #[test]
800    fn migrating_a_current_file_is_a_no_op() {
801        let mut once = serde_json::json!({
802            "schema": 3,
803            "machine": machine_id(),
804            "accounts": [{
805                "label": "work", "account_uuid": "acc-1", "email": "a@b.c",
806                "organization_uuid": "org-1", "oauth_account": {}, "parked": null
807            }],
808            "active": "work",
809            "slot": "Claude Code-credentials",
810            "discarded": []
811        });
812        migrate(&mut once, std::path::Path::new("/tmp/state.json")).expect("3 to 5");
813        let mut twice = once.clone();
814        migrate(&mut twice, std::path::Path::new("/tmp/state.json")).expect("5 is current");
815        assert_eq!(once, twice, "a second migration must change nothing");
816        assert_eq!(once["active"], serde_json::json!({"claude": "work"}));
817        assert_eq!(
818            once["slot"],
819            serde_json::json!({"claude": "Claude Code-credentials"})
820        );
821        assert_eq!(once["accounts"][0]["provider"], "claude");
822        assert_eq!(once["accounts"][0]["id"], "acc-1");
823        assert_eq!(once["schema"], SCHEMA);
824    }
825
826    /// Schema 3 had no `active` at all when nothing had been switched to, and a migration
827    /// that turned that into a one-entry map naming nothing would claim a switch happened.
828    #[test]
829    fn a_file_that_never_switched_migrates_to_no_active_account() {
830        let mut document = serde_json::json!({
831            "schema": 3, "machine": machine_id(), "accounts": [], "discarded": []
832        });
833        migrate(&mut document, std::path::Path::new("/tmp/state.json")).expect("3 to 4");
834        let state: State = serde_json::from_value(document).expect("parses");
835        assert_eq!(state.active_for(ProviderId::Claude), None);
836        assert!(state.active.is_empty() && state.slot.is_empty());
837    }
838
839    /// A file as 0.8.0 writes it. Each account keeps the id its parks, readings and windows
840    /// are already filed under.
841    #[test]
842    fn the_format_0_8_0_writes_keeps_every_account_where_it_is_filed() {
843        let mut document = serde_json::json!({
844            "schema": 4,
845            "machine": machine_id(),
846            "accounts": [
847                {
848                    "label": "work",
849                    "account_uuid": "acc-1",
850                    "email": "a@b.c",
851                    "parked": {
852                        "service": "pitboard-park-acc-1-1789935600123",
853                        "parked_at": 1_789_935_600,
854                        "refresh_fingerprint": "abcd",
855                        "access_expires_at": 1_789_999_999,
856                        "refresh_expires_at": 1_792_000_000
857                    },
858                    "last_used_at": 1_789_935_000,
859                    "provider": "claude",
860                    "organization_uuid": "org-1",
861                    "oauth_account": {
862                        "accountUuid": "acc-1",
863                        "emailAddress": "a@b.c",
864                        "organizationUuid": "org-1"
865                    }
866                },
867                {
868                    "label": "work",
869                    "account_uuid": "team_user-1",
870                    "email": "a@b.c",
871                    "parked": null,
872                    "last_used_at": null,
873                    "provider": "codex",
874                    "workspace_id": "team",
875                    "plan": "team"
876                }
877            ],
878            "active": {"claude": "work", "codex": "work"},
879            "slot": {"claude": "Claude Code-credentials", "codex": "/home/a/.codex"},
880            "discarded": [],
881            "foreign": []
882        });
883        migrate(&mut document, std::path::Path::new("/tmp/state.json")).expect("4 to 5");
884        let state: State = serde_json::from_value(document).expect("parses");
885
886        let work = state.get(&claude("work")).unwrap();
887        assert_eq!(
888            (work.id.as_str(), work.account_uuid.as_str()),
889            ("acc-1", "acc-1")
890        );
891        let codex = state.get(&Key::new(ProviderId::Codex, "work")).unwrap();
892        assert_eq!(codex.id, "team_user-1");
893        assert_eq!(
894            state.owner_of_park("acc-1").map(Account::key),
895            Some(work.key())
896        );
897        let signed_in = Owner {
898            account_uuid: "acc-1".into(),
899            email: "a@b.c".into(),
900            organization_uuid: "org-1".into(),
901        };
902        assert_eq!(
903            state.id_of(ProviderId::Claude, &signed_in),
904            "acc-1",
905            "the login it was enrolled with is still found by its organisation"
906        );
907    }
908
909    /// Anthropic's account uuid is the person. Their logins to two organisations are two
910    /// accounts, and a third organisation's is neither.
911    #[test]
912    fn one_person_in_two_organisations_is_two_accounts() {
913        let in_org = |label: &str, id: &str, org: &str| Account {
914            last_used_at: None,
915            label: label.into(),
916            id: id.into(),
917            account_uuid: "acc".into(),
918            email: "a@b.c".into(),
919            detail: Detail::Claude {
920                organization_uuid: org.into(),
921                oauth_account: serde_json::json!({}),
922            },
923            parked: None,
924        };
925        let mut state = State::default();
926        state.upsert(in_org("work", "acc", "org-a"));
927        state.upsert(in_org("team", "acc_org-b", "org-b"));
928        let signed_in = |org: &str| Owner {
929            account_uuid: "acc".into(),
930            email: "a@b.c".into(),
931            organization_uuid: org.into(),
932        };
933        let label_of = |org: &str| {
934            state
935                .account_of(ProviderId::Claude, &signed_in(org))
936                .map(|a| a.label.as_str())
937        };
938
939        assert_eq!(label_of("org-a"), Some("work"));
940        assert_eq!(label_of("org-b"), Some("team"));
941        assert_eq!(label_of("org-c"), None);
942        assert_eq!(
943            state.id_of(ProviderId::Claude, &signed_in("org-c")),
944            "acc_org-c"
945        );
946        assert!(
947            state
948                .account_of(ProviderId::Codex, &signed_in("org-a"))
949                .is_none()
950        );
951    }
952
953    /// `Detail` is flattened and tagged, which is the whole reason the migration has
954    /// nothing to move. If it ever stopped sitting beside `label` in the file, every
955    /// account already written would stop loading.
956    #[test]
957    fn a_providers_own_fields_sit_beside_the_shared_ones() {
958        let account = Account {
959            label: "work".into(),
960            id: "acc".into(),
961            account_uuid: "acc".into(),
962            email: "a@b.c".into(),
963            parked: None,
964            last_used_at: None,
965            detail: Detail::Claude {
966                organization_uuid: "org".into(),
967                oauth_account: serde_json::json!({"emailAddress": "a@b.c"}),
968            },
969        };
970        let written = serde_json::to_value(&account).expect("writes");
971        assert_eq!(written["provider"], "claude");
972        assert_eq!(written["organization_uuid"], "org");
973        assert_eq!(written["label"], "work");
974        assert!(
975            written.get("detail").is_none(),
976            "flattened, so there is no nested object: {written}"
977        );
978        let back: Account = serde_json::from_value(written).expect("reads back");
979        assert_eq!(back.provider(), ProviderId::Claude);
980        assert_eq!(back.claude().unwrap().organization_uuid, "org");
981    }
982
983    /// Two tools are two programs reading two stores. A `CLAUDE_CONFIG_DIR` that changed
984    /// says nothing about which Codex account is signed in, and clearing both would tell
985    /// somebody their other switch never happened.
986    #[test]
987    fn a_changed_slot_clears_only_that_providers_record() {
988        let mut state = State::default();
989        state.set_active(ProviderId::Claude, Some("work".into()));
990        state.set_slot(ProviderId::Claude, "some-other-slot".into());
991        state
992            .active
993            .insert("pretend-other-provider".into(), "personal".into());
994
995        // What `load_any_machine` does when the slot it reads is not the one recorded.
996        if state
997            .slot_for(ProviderId::Claude)
998            .is_some_and(|recorded| recorded != "Claude Code-credentials")
999        {
1000            state.set_active(ProviderId::Claude, None);
1001        }
1002
1003        assert_eq!(state.active_for(ProviderId::Claude), None);
1004        assert_eq!(
1005            state
1006                .active
1007                .get("pretend-other-provider")
1008                .map(String::as_str),
1009            Some("personal"),
1010            "another provider's record is not this provider's to clear"
1011        );
1012    }
1013
1014    /// A file naming a tool this build does not know came from a newer Pitboard. Called
1015    /// corrupt, its advice would be to delete it, which orphans every parked login.
1016    #[test]
1017    fn an_account_of_an_unknown_tool_asks_for_an_upgrade_not_a_deletion() {
1018        let home = std::env::temp_dir().join(format!(
1019            "pitboard-unknown-tool-{}-{:?}",
1020            std::process::id(),
1021            std::thread::current().id()
1022        ));
1023        let _ = std::fs::remove_dir_all(&home);
1024        std::fs::create_dir_all(&home).unwrap();
1025        let ctx = Context::new(home.clone()).with_pitboard_home(home.clone());
1026        std::fs::write(
1027            home.join("state.json"),
1028            serde_json::json!({
1029                "schema": SCHEMA,
1030                "machine": machine_id(),
1031                "accounts": [{
1032                    "label": "work", "account_uuid": "u", "email": "a@b.c",
1033                    "parked": null, "provider": "somethingnew"
1034                }],
1035                "active": {}, "slot": {}, "discarded": []
1036            })
1037            .to_string(),
1038        )
1039        .unwrap();
1040        let err = load(&ctx).unwrap_err();
1041        assert_eq!(err.code(), "state_names_unknown_tool");
1042        assert!(err.to_string().contains("somethingnew"), "{err}");
1043        assert!(!err.to_string().contains("Delete"), "{err}");
1044        let _ = std::fs::remove_dir_all(&home);
1045    }
1046
1047    /// The other direction cannot work, and the message has to say which half to upgrade.
1048    #[test]
1049    fn a_file_from_a_newer_pitboard_says_so() {
1050        let mut document = serde_json::json!({"schema": SCHEMA + 1});
1051        let err = migrate(&mut document, std::path::Path::new("/tmp/state.json")).unwrap_err();
1052        assert_eq!(err.code(), "state_from_newer_version");
1053        let said = err.to_string();
1054        assert!(said.contains("Update this Pitboard"), "{said}");
1055        assert!(
1056            !said.contains("brew") && !said.contains("cargo"),
1057            "it names no one way of installing Pitboard: {said}"
1058        );
1059    }
1060
1061    use super::*;
1062
1063    fn claude(label: &str) -> Key {
1064        Key::new(ProviderId::Claude, label)
1065    }
1066
1067    #[test]
1068    fn machine_id_is_stable_and_real() {
1069        let a = machine_id();
1070        assert_eq!(a, machine_id());
1071        assert_eq!(
1072            a.len(),
1073            64,
1074            "expected a sha256 of the platform id, got {a:?}"
1075        );
1076        assert_ne!(
1077            a, "unknown",
1078            "this platform should report a stable machine id"
1079        );
1080    }
1081
1082    fn park(service: &str) -> Park {
1083        Park {
1084            service: service.into(),
1085            parked_at: 100,
1086            refresh_fingerprint: "f".into(),
1087            access_expires_at: Some(200),
1088            refresh_expires_at: Some(300),
1089        }
1090    }
1091
1092    fn account(label: &str, parked: Option<Park>) -> Account {
1093        Account {
1094            last_used_at: None,
1095            label: label.into(),
1096            id: format!("{label}-uuid"),
1097            account_uuid: format!("{label}-uuid"),
1098            email: format!("{label}@example.com"),
1099            detail: Detail::Claude {
1100                organization_uuid: "o".into(),
1101                oauth_account: serde_json::json!({}),
1102            },
1103            parked,
1104        }
1105    }
1106
1107    #[test]
1108    fn a_new_park_discards_the_one_it_replaces() {
1109        let mut s = State::default();
1110        s.upsert(account("work", Some(park("old"))));
1111        s.park(&claude("work"), park("new"));
1112        assert_eq!(
1113            s.get(&claude("work"))
1114                .unwrap()
1115                .parked
1116                .as_ref()
1117                .unwrap()
1118                .service,
1119            "new"
1120        );
1121        assert_eq!(s.discarded, ["old"]);
1122        assert!(!s.references("old"));
1123    }
1124
1125    #[test]
1126    fn discarding_releases_whichever_account_held_it_and_lists_it_once() {
1127        let mut s = State::default();
1128        s.upsert(account("work", Some(park("current"))));
1129        s.discard("something-else");
1130        assert!(s.get(&claude("work")).unwrap().parked.is_some());
1131        s.discard("current");
1132        s.discard("current");
1133        assert!(s.get(&claude("work")).unwrap().parked.is_none());
1134        assert_eq!(s.discarded, ["something-else", "current"]);
1135    }
1136
1137    #[test]
1138    fn relabelling_keeps_the_account_its_park_and_whether_it_is_active() {
1139        let mut s = State::default();
1140        s.upsert(account("wrong", Some(park("p"))));
1141        s.upsert(account("other", None));
1142        s.set_active(ProviderId::Claude, Some("wrong".into()));
1143
1144        assert_eq!(
1145            s.relabel(&claude("wrong"), "right").unwrap().email,
1146            "wrong@example.com"
1147        );
1148        assert!(s.get(&claude("wrong")).is_none());
1149        let renamed = s.get(&claude("right")).unwrap();
1150        assert_eq!(renamed.account_uuid, "wrong-uuid");
1151        assert_eq!(renamed.parked.as_ref().unwrap().service, "p");
1152        assert_eq!(s.active_for(ProviderId::Claude), Some("right"));
1153        assert!(s.discarded.is_empty(), "nothing is deleted by a rename");
1154    }
1155
1156    #[test]
1157    fn relabelling_refuses_a_taken_label_and_an_unknown_one() {
1158        let mut s = State::default();
1159        s.upsert(account("a", None));
1160        s.upsert(account("b", None));
1161        s.set_active(ProviderId::Claude, Some("a".into()));
1162        assert!(matches!(
1163            s.relabel(&claude("a"), "b"),
1164            Err(Error::LabelTaken { .. })
1165        ));
1166        assert!(matches!(
1167            s.relabel(&claude("nobody"), "c"),
1168            Err(Error::AccountUnknown { .. })
1169        ));
1170        assert_eq!(
1171            s.active_for(ProviderId::Claude),
1172            Some("a"),
1173            "a refused rename changes nothing"
1174        );
1175        assert!(s.relabel(&claude("a"), "a").is_ok());
1176    }
1177
1178    #[test]
1179    fn removing_an_account_lists_its_park_for_deletion() {
1180        let mut s = State::default();
1181        s.upsert(account("work", Some(park("p"))));
1182        assert_eq!(s.remove(&claude("work")).unwrap().label, "work");
1183        assert!(s.accounts.is_empty());
1184        assert_eq!(s.discarded, ["p"]);
1185    }
1186
1187    /// A park `repair` gave back may be another Pitboard's. Replaced by a newer one, or
1188    /// dropped with its account, it was never used here, so it is let go and not deleted.
1189    #[test]
1190    fn a_park_this_home_did_not_write_is_let_go_and_never_listed_for_deletion() {
1191        let mut s = State::default();
1192        s.upsert(account("work", None));
1193        s.upsert(account("home", None));
1194        s.park_foreign(&claude("work"), park("found"));
1195        s.park_foreign(&claude("home"), park("also-found"));
1196        assert_eq!(s.foreign, ["found", "also-found"]);
1197
1198        s.park(&claude("work"), park("ours"));
1199        assert_eq!(
1200            s.get(&claude("work"))
1201                .unwrap()
1202                .parked
1203                .as_ref()
1204                .unwrap()
1205                .service,
1206            "ours"
1207        );
1208        s.remove(&claude("home"));
1209        s.release("ours");
1210
1211        assert_eq!(s.discarded, ["ours"], "only the park this home wrote");
1212        assert!(s.foreign.is_empty(), "and nothing here holds the others");
1213    }
1214
1215    /// Installed or renewed, a park has been used up whoever wrote it, and goes the way
1216    /// every used park goes.
1217    #[test]
1218    fn a_park_this_home_did_not_write_is_listed_once_it_is_used() {
1219        let mut s = State::default();
1220        s.upsert(account("work", None));
1221        s.park_foreign(&claude("work"), park("found"));
1222        s.discard("found");
1223        assert!(s.get(&claude("work")).unwrap().parked.is_none());
1224        assert_eq!(s.discarded, ["found"]);
1225        assert!(s.foreign.is_empty());
1226    }
1227
1228    /// Schema 4 is new on this branch and gained the record of parks written elsewhere
1229    /// after it was first written, so a file without it has to load as one holding none.
1230    #[test]
1231    fn a_state_file_from_before_foreign_parks_were_recorded_still_loads() {
1232        let written = serde_json::json!({
1233            "schema": 4,
1234            "machine": machine_id(),
1235            "accounts": [{
1236                "label": "work", "account_uuid": "acc-1", "email": "a@b.c",
1237                "provider": "codex", "workspace_id": null, "plan": "pro",
1238                "parked": {
1239                    "service": "pitboard-park-acc-1-1789935600123",
1240                    "parked_at": 1_789_935_600,
1241                    "refresh_fingerprint": "abcd",
1242                    "access_expires_at": null,
1243                    "refresh_expires_at": null
1244                }
1245            }],
1246            "active": {}, "slot": {}, "discarded": []
1247        });
1248        let mut document = written.clone();
1249        migrate(&mut document, std::path::Path::new("/tmp/state.json")).expect("current");
1250        let mut state: State = serde_json::from_value(document).expect("still parses");
1251        assert!(state.foreign.is_empty());
1252        state.remove(&Key::new(ProviderId::Codex, "work"));
1253        assert_eq!(
1254            state.discarded,
1255            ["pitboard-park-acc-1-1789935600123"],
1256            "a park recorded before is this home's own, and goes as it always did"
1257        );
1258    }
1259
1260    #[test]
1261    fn a_park_is_restorable_until_its_login_expires() {
1262        let p = park("p");
1263        assert!(p.askable_at(199) && !p.askable_at(200));
1264        assert!(p.restorable_at(299) && !p.restorable_at(300));
1265        let unknown = Park {
1266            access_expires_at: None,
1267            refresh_expires_at: None,
1268            ..park("p")
1269        };
1270        assert!(
1271            unknown.restorable_at(i64::MAX),
1272            "no expiry recorded is not expired"
1273        );
1274    }
1275
1276    #[test]
1277    fn accounts_are_replaced_by_label_not_duplicated() {
1278        let mut s = State::default();
1279        s.upsert(account("work", None));
1280        s.upsert(Account {
1281            email: "d@e.f".into(),
1282            ..account("work", None)
1283        });
1284        assert_eq!(s.accounts.len(), 1);
1285        assert_eq!(s.get(&claude("work")).unwrap().email, "d@e.f");
1286    }
1287
1288    fn codex_account(label: &str) -> Account {
1289        Account {
1290            last_used_at: None,
1291            label: label.into(),
1292            id: format!("codex-{label}-uuid"),
1293            account_uuid: format!("codex-{label}-uuid"),
1294            email: format!("{label}@openai.example"),
1295            detail: Detail::Codex {
1296                workspace_id: None,
1297                plan: None,
1298            },
1299            parked: None,
1300        }
1301    }
1302
1303    /// Two tools, one label. Every lookup used to take the label alone and return the
1304    /// first match, so `pitboard use codex/work` could park and install Claude Code's
1305    /// `work` instead, and enrolling Codex's `work` replaced Claude Code's outright.
1306    #[test]
1307    fn two_tools_can_each_have_an_account_of_the_same_name() {
1308        let mut s = State::default();
1309        s.upsert(account("work", Some(park("claude-park"))));
1310        s.upsert(codex_account("work"));
1311        assert_eq!(
1312            s.accounts.len(),
1313            2,
1314            "the second is added, not a replacement"
1315        );
1316
1317        let codex = Key::new(ProviderId::Codex, "work");
1318        assert_eq!(s.get(&codex).unwrap().provider(), ProviderId::Codex);
1319        assert_eq!(
1320            s.get(&claude("work")).unwrap().provider(),
1321            ProviderId::Claude
1322        );
1323
1324        s.park(&codex, park("codex-park"));
1325        assert_eq!(
1326            s.get(&claude("work"))
1327                .unwrap()
1328                .parked
1329                .as_ref()
1330                .unwrap()
1331                .service,
1332            "claude-park",
1333            "parking one tool's account leaves the other's alone"
1334        );
1335
1336        s.remove(&codex);
1337        assert!(s.get(&claude("work")).is_some(), "and so does dropping it");
1338        assert_eq!(s.discarded, ["codex-park"]);
1339    }
1340
1341    /// A label is unique within a tool, so renaming into a name only another tool uses is
1342    /// not a clash.
1343    #[test]
1344    fn a_rename_clashes_only_within_its_own_tool() {
1345        let mut s = State::default();
1346        s.upsert(account("personal", None));
1347        s.upsert(codex_account("work"));
1348        assert!(s.relabel(&claude("personal"), "work").is_ok());
1349        assert_eq!(
1350            s.get(&claude("work")).unwrap().email,
1351            "personal@example.com"
1352        );
1353    }
1354
1355    /// Forgetting the account a tool last switched to must not leave that tool's record
1356    /// naming it: a later account enrolled under the same label would read as in use.
1357    #[test]
1358    fn removing_the_account_in_use_clears_that_tools_record_only() {
1359        let mut s = State::default();
1360        s.upsert(account("work", None));
1361        s.upsert(codex_account("work"));
1362        s.set_active(ProviderId::Claude, Some("work".into()));
1363        s.set_active(ProviderId::Codex, Some("work".into()));
1364        s.remove(&Key::new(ProviderId::Codex, "work"));
1365        assert_eq!(s.active_for(ProviderId::Codex), None);
1366        assert_eq!(s.active_for(ProviderId::Claude), Some("work"));
1367    }
1368
1369    /// A bare name for an account whose label another tool shares would be refused as
1370    /// ambiguous, so the name every message suggests is qualified exactly then.
1371    #[test]
1372    fn a_name_is_qualified_where_a_bare_one_would_be_ambiguous() {
1373        let mut s = State::default();
1374        s.upsert(account("work", None));
1375        s.upsert(account("personal", None));
1376        s.upsert(codex_account("work"));
1377        assert_eq!(s.typed(&claude("work")), "claude/work");
1378        assert_eq!(s.typed(&Key::new(ProviderId::Codex, "work")), "codex/work");
1379        assert_eq!(s.typed(&claude("personal")), "personal");
1380    }
1381
1382    #[test]
1383    fn a_key_reads_the_way_it_would_be_typed() {
1384        assert_eq!(claude("work").to_string(), "work");
1385        assert_eq!(
1386            Key::new(ProviderId::Codex, "work").to_string(),
1387            "codex/work"
1388        );
1389        assert_eq!(claude("work").qualified(), "claude/work");
1390    }
1391}