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