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::in_use::{Identified, InUse, Replaced};
11use crate::provider::ProviderId;
12use crate::provider::{Identity, Owner};
13use crate::service::Permit;
14use crate::{atomic, home};
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17use std::collections::BTreeMap;
18use std::path::PathBuf;
19
20const SCHEMA: u32 = 8;
21
22#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
23#[serde(rename_all = "snake_case")]
24pub enum ParkKind {
25    #[default]
26    Vault,
27    // a folder under `<pitboard home>/parks`, holding an app's own files
28    Folder,
29}
30
31impl ParkKind {
32    // By reference, as `skip_serializing_if` calls it.
33    pub fn is_vault(&self) -> bool {
34        matches!(self, ParkKind::Vault)
35    }
36}
37
38#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
39pub struct Dropped {
40    pub service: String,
41    #[serde(default, skip_serializing_if = "ParkKind::is_vault")]
42    pub kind: ParkKind,
43    // Set by `forget_folder` alone, for `State::remove` and `uninstall`: a held login goes then.
44    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
45    pub forgotten: bool,
46}
47
48#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
49#[serde(rename_all = "snake_case")]
50pub enum SetAside {
51    InTheWay,
52    Superseded,
53    AnotherMachine,
54    GivenUp,
55    Orphan,
56}
57
58// A folder under `parks` no account holds as its park: only `forget` and `uninstall` delete it.
59#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
60pub struct Aside {
61    pub name: String,
62    pub tool: ProviderId,
63    pub at: i64,
64    pub why: SetAside,
65    // the id it was parked for
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub account: Option<String>,
68    // the fingerprint of the session its jar holds
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub login: Option<String>,
71}
72
73#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
74#[serde(rename_all = "snake_case")]
75pub enum Half {
76    Leave,
77    Arrive,
78}
79
80// One half of a folder login's move, written before its first rename and removed by the save
81// that records what the half came to.
82#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
83pub struct Moving {
84    pub half: Half,
85    pub started_at: i64,
86    // the account whose login moves
87    pub id: String,
88    pub label: String,
89    // the folder under `parks` it moves to (leave) or from (arrive)
90    pub park: String,
91    pub login: String,
92    // the items the source held when the record was written
93    pub items: Vec<String>,
94    // SHA-256, hex, of each account key's value as the source held it
95    pub keys: BTreeMap<String, String>,
96    // each folder this move reserved for what it set aside, and why, saved before it is made
97    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
98    pub asides: BTreeMap<String, SetAside>,
99    pub slot: String,
100    // the sign-in this leave is for, copied into `awaiting` by its commit
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub awaited: Option<Awaited>,
103}
104
105// A sign-in inside the app is waited for, with an account parked for it.
106#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
107pub struct Awaited {
108    pub since: i64,
109    // the name asked
110    pub label: String,
111    // the id that label has already: a Sign In Again
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub account: Option<String>,
114    // the id of the account parked for it
115    pub put_back: String,
116}
117
118/// A login held for an account while another is signed in. There is at most one per
119/// account: once installed it is Claude Code's again, and Claude Code rotates it from then
120/// on, so a copy kept back would only ever present a token it has moved past.
121#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
122pub struct Park {
123    pub service: String,
124    #[serde(default, skip_serializing_if = "ParkKind::is_vault")]
125    pub kind: ParkKind,
126    pub parked_at: i64,
127    // a document's refresh token's, a folder login's session cookie's as stored
128    #[serde(rename = "refresh_fingerprint")]
129    pub login: String,
130    /// Until then its usage can be asked without renewing it first.
131    pub access_expires_at: Option<i64>,
132    /// Until then it can be restored.
133    pub refresh_expires_at: Option<i64>,
134    // the jar's own date for the session cookie: said, never a refusal
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub session_expires_at: Option<i64>,
137}
138
139/// A login of a tool's own that Pitboard renewed and has not saved where the tool keeps it
140/// yet, with the vault item holding a copy of the renewed login meanwhile.
141#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
142pub(crate) struct Renewing {
143    pub(crate) tool: ProviderId,
144    /// The tool's credential slot it is kept in.
145    pub(crate) slot: String,
146    /// Which of the tool's stores keeps it, as [`crate::store::Backend::name`] names it.
147    pub(crate) store: String,
148    /// The fingerprint of the refresh token the renewal sent.
149    pub(crate) sent: String,
150    /// The vault item holding the renewed login.
151    pub(crate) service: String,
152}
153
154impl Park {
155    pub fn restorable_at(&self, now: i64) -> bool {
156        self.refresh_expires_at.is_none_or(|at| at > now)
157    }
158
159    pub(crate) fn askable_at(&self, now: i64) -> bool {
160        self.access_expires_at.is_none_or(|at| at > now)
161    }
162}
163
164/// What one provider keeps about an account that the others have no equivalent of.
165///
166/// A tagged enum rather than a pile of optional fields, so no code reading a Codex account
167/// ever has to decide what an absent Claude organisation means for it. `provider` is the
168/// tag, and the variant's own fields sit beside `label` and `email` in the file, which is
169/// why a schema 3 account needs nothing moved to become a schema 4 one.
170#[derive(Serialize, Deserialize, Debug, Clone)]
171#[serde(tag = "provider", rename_all = "snake_case")]
172pub(crate) enum Detail {
173    Claude {
174        organization_uuid: String,
175        /// Written into Claude Code's config on switching here. Only what Anthropic
176        /// confirmed, so Claude Code fetches the rest of its profile itself.
177        oauth_account: Value,
178    },
179    Codex {
180        /// The ChatGPT workspace this account belongs to, where it belongs to one.
181        #[serde(default)]
182        workspace_id: Option<String>,
183    },
184    Desktop {},
185}
186
187/// Claude Code's own extras, for a caller that has already established it is holding a
188/// Claude account.
189#[derive(Debug)]
190pub(crate) struct ClaudeDetail<'a> {
191    pub(crate) organization_uuid: &'a str,
192    pub(crate) oauth_account: &'a Value,
193}
194
195#[derive(Serialize, Deserialize, Debug, Clone)]
196pub struct Account {
197    pub label: String,
198    /// What Pitboard files the account's parked logins, readings, budget and windows under.
199    /// Set at enrolment and never changed, so none of them has to move.
200    pub id: String,
201    /// The account as its tool names it: Anthropic's account uuid, which is the person and
202    /// not the login, or Codex's ChatGPT account joined with the person in it.
203    pub account_uuid: String,
204    pub email: String,
205    pub parked: Option<Park>,
206    /// When this account last came to be in use, in epoch seconds: switched to, enrolled
207    /// while signed in, or named by its service for the login its tool has stored.
208    ///
209    /// Pitboard renews a parked login for as long as the account is enrolled, so an account
210    /// somebody enrolled once and never came back to keeps a live, continuously rotated
211    /// refresh token on the machine indefinitely. Nothing said so, and nothing asked.
212    /// Recording this is what lets `doctor` say it.
213    ///
214    /// `None` on an account enrolled before this was recorded, and on one that has never
215    /// been in use.
216    #[serde(default)]
217    pub last_used_at: Option<i64>,
218    /// When the account's only login was replaced by a sign-in outside Pitboard, in epoch
219    /// seconds: its tool's store held that login with nothing parked for it and no other
220    /// slot's record naming it, and its service then named another account's login there, or
221    /// none. Pitboard keeps no copy of a login in use, so nothing brings it back. Cleared once
222    /// the account holds a parked login or is in use again.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub replaced_at: Option<i64>,
225    /// The plan its login last said, as a label: "Max 20x", "Pro".
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub plan: Option<String>,
228    /// Which tool's login this is, and whatever only that tool keeps.
229    #[serde(flatten)]
230    pub(crate) detail: Detail,
231}
232
233/// One account, the way Pitboard tells accounts apart: which tool, and what it is called
234/// there.
235///
236/// A label alone stopped being enough the day a second tool could have a `work` of its own.
237/// Everything that finds, changes or drops an account takes one of these, so no lookup can
238/// quietly land on the other tool's account of the same name.
239#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
240pub struct Key {
241    pub provider: ProviderId,
242    pub label: String,
243}
244
245impl Key {
246    pub fn new(provider: ProviderId, label: impl Into<String>) -> Key {
247        Key {
248            provider,
249            label: label.into(),
250        }
251    }
252
253    /// The name as somebody would type it back: bare for the tool a bare name means,
254    /// qualified for any other. Every message and the audit log use this, so a Claude Code
255    /// account reads exactly as it did before there was a second tool.
256    pub fn typed(&self) -> String {
257        if self.provider == crate::label::DEFAULT {
258            self.label.clone()
259        } else {
260            self.qualified()
261        }
262    }
263
264    /// `codex/work`, whichever tool it is.
265    pub fn qualified(&self) -> String {
266        format!(
267            "{}{}{}",
268            self.provider.code(),
269            crate::label::SEPARATOR,
270            self.label
271        )
272    }
273}
274
275impl std::fmt::Display for Key {
276    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277        f.write_str(&self.typed())
278    }
279}
280
281impl Account {
282    pub(crate) fn key(&self) -> Key {
283        Key::new(self.provider(), self.label.clone())
284    }
285
286    pub(crate) fn is(&self, key: &Key) -> bool {
287        self.label == key.label && self.provider() == key.provider
288    }
289
290    pub fn provider(&self) -> ProviderId {
291        match self.detail {
292            Detail::Claude { .. } => ProviderId::Claude,
293            Detail::Codex { .. } => ProviderId::Codex,
294            Detail::Desktop {} => ProviderId::Desktop,
295        }
296    }
297
298    /// Whose login this account's is, as its tool's service names one.
299    pub(crate) fn owner(&self) -> Owner {
300        Owner {
301            account_uuid: self.account_uuid.clone(),
302            email: self.email.clone(),
303            organization_uuid: match &self.detail {
304                Detail::Claude {
305                    organization_uuid, ..
306                } => organization_uuid.clone(),
307                Detail::Codex { workspace_id, .. } => workspace_id.clone().unwrap_or_default(),
308                Detail::Desktop {} => String::new(),
309            },
310        }
311    }
312
313    /// Whether `owner`'s login is this account's.
314    pub(crate) fn owned_by(&self, owner: &Owner) -> bool {
315        self.holds(&owner.identity())
316    }
317
318    /// Whether `identity` is this account's login.
319    pub(crate) fn holds(&self, identity: &Identity) -> bool {
320        self.account_uuid == identity.account_uuid
321            && match &self.detail {
322                // One person in two organisations holds two logins, each on its own plan.
323                Detail::Claude {
324                    organization_uuid, ..
325                } => *organization_uuid == identity.organization_uuid,
326                // Codex's account id already joins the workspace with the person in it.
327                Detail::Codex { .. } => true,
328                // One claude.ai session covers the person's organisations.
329                Detail::Desktop {} => true,
330            }
331    }
332
333    /// Claude Code's extras, or `None` when this account belongs to another tool.
334    pub(crate) fn claude(&self) -> Option<ClaudeDetail<'_>> {
335        match &self.detail {
336            Detail::Claude {
337                organization_uuid,
338                oauth_account,
339            } => Some(ClaudeDetail {
340                organization_uuid,
341                oauth_account,
342            }),
343            Detail::Codex { .. } | Detail::Desktop {} => None,
344        }
345    }
346}
347
348#[derive(Serialize, Deserialize, Debug, Clone)]
349pub struct State {
350    pub(crate) schema: u32,
351    pub(crate) machine: String,
352    pub(crate) accounts: Vec<Account>,
353    /// Whose login each tool has stored, as its service last said, by the tool's code: the
354    /// record of the credential slot `slot` names for that tool.
355    #[serde(default)]
356    pub(crate) in_use: BTreeMap<String, InUse>,
357    /// The credential slot each tool's `in_use` was recorded for. One state file serves
358    /// every slot a machine uses, and a tool's own home variable changes which store is the
359    /// live one, so a record made in one slot says nothing about another.
360    #[serde(default)]
361    pub(crate) slot: BTreeMap<String, String>,
362    /// The record of each other slot a tool was read in, by the tool's code and then the
363    /// slot. [`load`] puts the slot it reads in place of the one `slot` names, so a read or a
364    /// change in one slot keeps every other slot's record for when that slot is read again.
365    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
366    pub(crate) other_slots: BTreeMap<String, BTreeMap<String, InUse>>,
367    /// Parked items no account refers to any more. Listed in the same save that drops them
368    /// and removed once deleted, so a delete that fails or is interrupted is retried.
369    #[serde(default)]
370    pub discarded: Vec<Dropped>,
371    /// Parked items an account here holds that this home never wrote down: logins
372    /// `pitboard repair` found in the store and gave back. On macOS every `PITBOARD_HOME`
373    /// shares the login keychain, so each may be another Pitboard's parked login, and
374    /// letting one go unused must leave it where it is. Removed once nothing here holds it.
375    /// Always empty where the vault is a directory inside this home, which nobody else
376    /// parks in.
377    #[serde(default)]
378    pub(crate) foreign: Vec<String>,
379    /// Parked items that copy the login in a file behind a tool's store which is still
380    /// there, each written down before the copy is. A put-away that stopped leaves the login
381    /// in both, and a session that signs in with the file can renew it there, which spends
382    /// the copy. So the next put-away never keeps one of these in place of what the file
383    /// holds by then. Emptied once the file has gone.
384    #[serde(default, skip_serializing_if = "Vec::is_empty")]
385    pub(crate) from_file: Vec<String>,
386    /// Renewals of a tool's own login whose answer is not saved where the tool keeps it yet,
387    /// each written down before the refresh token is sent. At most one per slot.
388    #[serde(default, skip_serializing_if = "Vec::is_empty")]
389    pub(crate) renewing: Vec<Renewing>,
390    // by tool code
391    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
392    pub moving: BTreeMap<String, Moving>,
393    #[serde(default, skip_serializing_if = "Vec::is_empty")]
394    pub aside: Vec<Aside>,
395    // by tool code
396    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
397    pub awaiting: BTreeMap<String, Awaited>,
398}
399
400impl Default for State {
401    fn default() -> Self {
402        State {
403            schema: SCHEMA,
404            machine: machine_id(),
405            accounts: Vec::new(),
406            in_use: BTreeMap::new(),
407            slot: BTreeMap::new(),
408            other_slots: BTreeMap::new(),
409            discarded: Vec::new(),
410            foreign: Vec::new(),
411            from_file: Vec::new(),
412            renewing: Vec::new(),
413            moving: BTreeMap::new(),
414            aside: Vec::new(),
415            awaiting: BTreeMap::new(),
416        }
417    }
418}
419
420impl State {
421    /// Whose login `which` has stored, as its service last said.
422    pub(crate) fn in_use(&self, which: ProviderId) -> Option<&InUse> {
423        self.in_use.get(which.code())
424    }
425
426    /// The account whose login `which` has stored, as its service last said. `None` where
427    /// it stored none, where nothing is recorded, and where that login's account is not
428    /// enrolled.
429    pub fn account_in_use(&self, which: ProviderId) -> Option<&Account> {
430        let owner = self.in_use(which)?.owner.as_ref()?;
431        self.account_of(which, owner)
432    }
433
434    /// Record whose login `which` has stored, as its service said it at `at`.
435    ///
436    /// A record that agrees with `found` is kept as it is, with when it was first said.
437    /// Where the login is another account's than before, that account came to be in use at
438    /// `at`, and the one before lost the login it had in use. With nothing parked for it and
439    /// no other slot's record naming it, that was its only login, gone to a sign-in outside
440    /// Pitboard: a switch parks the login it moves out first.
441    pub(crate) fn identified(&mut self, which: ProviderId, found: InUse, at: i64) -> Identified {
442        let before = self.in_use.get(which.code());
443        if before.is_some_and(|known| known.agrees(which, &found)) {
444            return Identified {
445                changed: false,
446                replaced: None,
447            };
448        }
449        let was = before.and_then(|known| known.owner.clone());
450        let mut replaced = None;
451        if before.and_then(|known| known.account(which)) != found.account(which) {
452            if let Some(previous) = was
453                .as_ref()
454                .filter(|owner| !self.stored_in_another_slot(which, owner))
455                .and_then(|owner| self.account_of_mut(which, owner))
456                && previous.parked.is_none()
457            {
458                previous.replaced_at = Some(at);
459                replaced = Some(Replaced {
460                    key: previous.key(),
461                });
462            }
463            if let Some(now) = found
464                .owner
465                .as_ref()
466                .and_then(|owner| self.account_of_mut(which, owner))
467            {
468                now.replaced_at = None;
469                now.last_used_at = Some(at);
470            }
471        }
472        self.in_use.insert(which.code().to_string(), found);
473        Identified {
474            changed: true,
475            replaced,
476        }
477    }
478
479    /// Whether another slot's record says `which` has `owner`'s login stored there, which a
480    /// change in this slot leaves where it is.
481    fn stored_in_another_slot(&self, which: ProviderId, owner: &Owner) -> bool {
482        let account = Some(new_id(which, owner));
483        self.other_slots.get(which.code()).is_some_and(|records| {
484            records
485                .values()
486                .any(|record| record.account(which) == account)
487        })
488    }
489
490    /// Make `which`'s record the one of `slot`. Where it is of another slot, that one waits
491    /// with the other slots' records, and the record `slot` was last given, where there is
492    /// one, comes back. A record with no slot named is taken as `slot`'s.
493    fn in_slot(&mut self, which: ProviderId, slot: String) {
494        let code = which.code();
495        if let Some(recorded) = self.slot.get(code).filter(|recorded| **recorded != slot) {
496            let others = self.other_slots.entry(code.to_string()).or_default();
497            if let Some(record) = self.in_use.remove(code) {
498                others.insert(recorded.clone(), record);
499            }
500            if let Some(record) = others.remove(&slot) {
501                self.in_use.insert(code.to_string(), record);
502            }
503            if others.is_empty() {
504                self.other_slots.remove(code);
505            }
506        }
507        self.slot.insert(code.to_string(), slot);
508    }
509
510    /// Forget whose login every tool has stored, in every slot.
511    pub(crate) fn forget_in_use(&mut self) {
512        self.in_use.clear();
513        self.slot.clear();
514        self.other_slots.clear();
515    }
516
517    /// The account's name as a command would take it here: bare for the tool a bare name
518    /// means, unless another tool has an account of the same name, in which case a bare
519    /// name would be refused as ambiguous and the tool is said.
520    ///
521    /// What every message that tells somebody what to type uses, so the command it names
522    /// is one that works on this machine.
523    pub(crate) fn typed(&self, key: &Key) -> String {
524        let shared = self
525            .accounts
526            .iter()
527            .any(|a| a.label == key.label && a.provider() != key.provider);
528        if shared { key.qualified() } else { key.typed() }
529    }
530
531    /// Every label this tool has enrolled, for a message that would otherwise send someone
532    /// to another command to find out.
533    pub(crate) fn labels(&self, provider: ProviderId) -> crate::error::Enrolled {
534        crate::error::Enrolled(
535            self.accounts
536                .iter()
537                .filter(|a| a.provider() == provider)
538                .map(|a| a.label.clone())
539                .collect(),
540        )
541    }
542
543    // A vault item's name or a folder's: held, waiting to be deleted, renewing, moving or set
544    // aside.
545    pub fn names(&self, service: &str) -> bool {
546        self.accounts
547            .iter()
548            .filter_map(|a| a.parked.as_ref())
549            .any(|p| p.service == service)
550            || self.discarded.iter().any(|d| d.service == service)
551            || self
552                .renewing
553                .iter()
554                .any(|renewing| renewing.service == service)
555            || self.aside.iter().any(|aside| aside.name == service)
556            || self.moves(service)
557    }
558
559    // Whether a move under way holds the folder `name`, as its park or as one it set aside.
560    pub fn moves(&self, name: &str) -> bool {
561        self.moving
562            .values()
563            .any(|moving| moving.park == name || moving.asides.contains_key(name))
564    }
565
566    pub fn moving(&self, which: ProviderId) -> Option<&Moving> {
567        self.moving.get(which.code())
568    }
569
570    pub fn awaiting(&self, which: ProviderId) -> Option<&Awaited> {
571        self.awaiting.get(which.code())
572    }
573
574    /// The account under this key.
575    ///
576    /// Callers that took a name from a person should go through [`crate::label::resolve`]
577    /// first, which knows what to do when two tools share a label.
578    pub(crate) fn get(&self, key: &Key) -> Option<&Account> {
579        self.accounts.iter().find(|a| a.is(key))
580    }
581
582    /// This tool's account holding `owner`'s login.
583    pub(crate) fn account_of(&self, provider: ProviderId, owner: &Owner) -> Option<&Account> {
584        self.accounts
585            .iter()
586            .find(|a| a.provider() == provider && a.owned_by(owner))
587    }
588
589    /// Whose `identity` is, where Pitboard knows the account: enrolled, or the one its record
590    /// last named in use.
591    pub(crate) fn named(&self, provider: ProviderId, identity: &Identity) -> Option<Owner> {
592        let enrolled = self
593            .accounts
594            .iter()
595            .find(|a| a.provider() == provider && a.holds(identity))
596            .map(|a| Owner::named(identity.clone(), a.email.clone()));
597        enrolled.or_else(|| {
598            self.in_use(provider)?
599                .owner
600                .clone()
601                .filter(|owner| owner.identity() == *identity)
602        })
603    }
604
605    /// What `owner`'s login is filed under: its account's id, or the one it would be
606    /// enrolled with.
607    pub(crate) fn id_of(&self, provider: ProviderId, owner: &Owner) -> String {
608        self.id_for(provider, &owner.identity())
609    }
610
611    /// [`State::id_of`], for a login known by its identity alone.
612    pub(crate) fn id_for(&self, provider: ProviderId, identity: &Identity) -> String {
613        self.accounts
614            .iter()
615            .find(|a| a.provider() == provider && a.holds(identity))
616            .map_or_else(|| identity_id(provider, identity), |a| a.id.clone())
617    }
618
619    /// This tool's account filed under `id`.
620    pub(crate) fn by_id(&self, provider: ProviderId, id: &str) -> Option<&Account> {
621        self.accounts
622            .iter()
623            .find(|a| a.provider() == provider && a.id == id)
624    }
625
626    /// The account a parked item was written for.
627    ///
628    /// A park's name carries the account's id and not its tool, because names were fixed
629    /// before there was a second tool and every item already on a machine is filed under
630    /// one. The ids cannot collide in practice: each is built from its tool's UUIDs.
631    pub(crate) fn owner_of_park(&self, id: &str) -> Option<&Account> {
632        self.accounts.iter().find(|a| a.id == id)
633    }
634
635    fn get_mut(&mut self, key: &Key) -> Option<&mut Account> {
636        self.accounts.iter_mut().find(|a| a.is(key))
637    }
638
639    fn account_of_mut(&mut self, provider: ProviderId, owner: &Owner) -> Option<&mut Account> {
640        self.accounts
641            .iter_mut()
642            .find(|a| a.provider() == provider && a.owned_by(owner))
643    }
644
645    // The park it replaces: a vault item is released, a folder is set aside.
646    pub fn park(&mut self, key: &Key, park: Park) {
647        let (service, at) = (park.service.clone(), park.parked_at);
648        let Some(account) = self.get_mut(key) else {
649            return;
650        };
651        account.replaced_at = None;
652        if let Some(previous) = account.parked.replace(park)
653            && previous.service != service
654        {
655            match previous.kind {
656                ParkKind::Vault => self.release(&previous.service),
657                ParkKind::Folder => self.set_aside(key, previous, SetAside::Superseded, at),
658            }
659        }
660    }
661
662    // Keep `park` of `key`'s account as a folder no account holds, and stop holding it.
663    pub fn set_aside(&mut self, key: &Key, park: Park, why: SetAside, at: i64) {
664        let account = self.get_mut(key).map(|account| {
665            account.parked.take_if(|held| held.service == park.service);
666            account.id.clone()
667        });
668        if self.aside.iter().any(|aside| aside.name == park.service) {
669            return;
670        }
671        self.aside.push(Aside {
672            name: park.service,
673            tool: key.provider,
674            at,
675            why,
676            account,
677            login: Some(park.login),
678        });
679    }
680
681    // A folder a move leaves that no account holds as its park any more. `aside.login` is
682    // the session its jar holds, so none where the jar went back into the app's folder.
683    pub(crate) fn keep_aside(&mut self, aside: Aside) {
684        self.let_go(&aside.name);
685        if !self.aside.iter().any(|kept| kept.name == aside.name) {
686            self.aside.push(aside);
687        }
688    }
689
690    // An aside given back as a park, or deleted.
691    pub fn drop_aside(&mut self, name: &str) -> Option<Aside> {
692        let at = self.aside.iter().position(|aside| aside.name == name)?;
693        Some(self.aside.remove(at))
694    }
695
696    pub fn aside_of(&self, id: &str) -> impl Iterator<Item = &Aside> {
697        self.aside
698            .iter()
699            .filter(move |aside| aside.account.as_deref() == Some(id))
700    }
701
702    /// Hold a park this home did not write, found in the store and given back. It is used
703    /// like any other, and deleted only once it has been.
704    pub(crate) fn park_foreign(&mut self, key: &Key, park: Park) {
705        let service = park.service.clone();
706        self.park(key, park);
707        if self.references(&service) && !self.is_foreign(&service) {
708            self.foreign.push(service);
709        }
710    }
711
712    /// Whether an account here holds `service` without this home having written it.
713    pub(crate) fn is_foreign(&self, service: &str) -> bool {
714        self.foreign.iter().any(|listed| listed == service)
715    }
716
717    /// Take `service` as this home's own from now on, because this home has used it: a
718    /// renewal presented its refresh token. Letting it go afterwards deletes it.
719    pub(crate) fn used_here(&mut self, service: &str) {
720        self.foreign.retain(|listed| listed != service);
721    }
722
723    /// Stop holding `service` because it has been used up, and list it for deletion
724    /// whoever wrote it: it has been installed, a renewal has spent it, or it copies a login
725    /// that is still signed in. Nothing can use it again, and for a tool whose park may
726    /// never be a copy it must not stay beside the login it copies.
727    pub fn discard(&mut self, service: &str, kind: ParkKind) {
728        self.let_go(service);
729        if !self
730            .discarded
731            .iter()
732            .any(|listed| listed.service == service)
733        {
734            self.discarded.push(Dropped {
735                service: service.to_string(),
736                kind,
737                forgotten: false,
738            });
739        }
740    }
741
742    /// Stop holding `service` because nothing here wants it any more: a newer park replaced
743    /// it, or its account was dropped. Listed for deletion only when this home wrote it. One
744    /// `repair` gave back may be another Pitboard's parked login, which this one never
745    /// used, and deleting it would end that account's session for somebody who never ran
746    /// the command that did it.
747    pub(crate) fn release(&mut self, service: &str) {
748        if self.is_foreign(service) {
749            self.let_go(service);
750        } else {
751            self.discard(service, ParkKind::Vault);
752        }
753    }
754
755    /// No account holds `service` afterwards, and nothing records who wrote it or what it
756    /// copied.
757    fn let_go(&mut self, service: &str) {
758        for account in &mut self.accounts {
759            account.parked.take_if(|p| p.service == service);
760        }
761        self.foreign.retain(|listed| listed != service);
762        self.from_file.retain(|listed| listed != service);
763    }
764
765    /// `service` is about to hold a copy of the login in a file behind a tool's store.
766    pub(crate) fn copying_from_file(&mut self, service: &str) {
767        if !self.is_from_file(service) {
768            self.from_file.push(service.to_string());
769        }
770    }
771
772    /// Whether `service` copies the login a file behind a tool's store held, with that file
773    /// still there as far as anything recorded.
774    pub(crate) fn is_from_file(&self, service: &str) -> bool {
775        self.from_file.iter().any(|listed| listed == service)
776    }
777
778    /// The file behind a tool's store has gone, so no park copies it any more. Whether any
779    /// was written down as one.
780    pub(crate) fn file_gone(&mut self) -> bool {
781        !std::mem::take(&mut self.from_file).is_empty()
782    }
783
784    pub(crate) fn references(&self, service: &str) -> bool {
785        self.accounts
786            .iter()
787            .any(|a| a.parked.as_ref().is_some_and(|p| p.service == service))
788    }
789
790    /// Add the account, or replace the one this tool already has under its label.
791    pub(crate) fn upsert(&mut self, account: Account) {
792        match self.get_mut(&account.key()) {
793            Some(existing) => *existing = account,
794            None => self.accounts.push(account),
795        }
796    }
797
798    /// Enroll the account under `from` as `to` instead, inside the same tool. Only the
799    /// label changes: parked logins are named by account, not by label.
800    pub(crate) fn relabel(&mut self, from: &Key, to: &str) -> Result<&Account> {
801        let target = Key::new(from.provider, to);
802        if from.label != to
803            && let Some(taken) = self.get(&target)
804        {
805            let renamed = self.get(from).map(|a| a.email.as_str());
806            return Err(Error::LabelTaken {
807                label: target.typed(),
808                who: crate::words::login(
809                    from.provider,
810                    &taken.email,
811                    renamed == Some(taken.email.as_str()),
812                ),
813            });
814        }
815        let enrolled = self.labels(from.provider);
816        let account = self.get_mut(from).ok_or_else(|| Error::AccountUnknown {
817            label: from.typed(),
818            enrolled,
819        })?;
820        account.label = to.to_string();
821        Ok(account)
822    }
823
824    // Whose login its tool has stored stays recorded: the record names its owner, not a label.
825    pub fn remove(&mut self, key: &Key) -> Option<Account> {
826        let index = self.accounts.iter().position(|a| a.is(key))?;
827        let account = self.accounts.remove(index);
828        if let Some(park) = &account.parked {
829            match park.kind {
830                ParkKind::Vault => self.release(&park.service),
831                ParkKind::Folder => self.forget_folder(&park.service),
832            }
833        }
834        let filed: Vec<String> = self
835            .aside_of(&account.id)
836            .map(|aside| aside.name.clone())
837            .collect();
838        for name in filed {
839            self.drop_aside(&name);
840            self.forget_folder(&name);
841        }
842        self.awaiting
843            .retain(|_, awaited| awaited.put_back != account.id);
844        for awaited in self.awaiting.values_mut() {
845            awaited.account.take_if(|id| *id == account.id);
846        }
847        Some(account)
848    }
849
850    // Every folder set aside listed for deletion, as `uninstall` asks.
851    pub fn forget_asides(&mut self) {
852        for aside in std::mem::take(&mut self.aside) {
853            self.forget_folder(&aside.name);
854        }
855    }
856
857    // Every folder listed for deletion deleted whatever it still holds, as `uninstall` asks.
858    pub fn forget_discarded(&mut self) {
859        let folders: Vec<String> = self
860            .discarded
861            .iter()
862            .filter(|dropped| dropped.kind == ParkKind::Folder && !dropped.forgotten)
863            .map(|dropped| dropped.service.clone())
864            .collect();
865        for name in folders {
866            self.forget_folder(&name);
867        }
868    }
869
870    // The one way a folder that holds a login is listed to be deleted. Never one a move holds.
871    fn forget_folder(&mut self, name: &str) {
872        self.let_go(name);
873        if self.moves(name) {
874            return;
875        }
876        self.discarded.retain(|listed| listed.service != name);
877        self.discarded.push(Dropped {
878            service: name.to_string(),
879            kind: ParkKind::Folder,
880            forgotten: true,
881        });
882    }
883}
884
885/// The id a login is enrolled with. Claude's joins the organisation, since Anthropic's
886/// account uuid is the person and each of their organisations is a login of its own. `_`
887/// is in neither uuid, and is safe in a park's name and a readings file.
888pub(crate) fn new_id(provider: ProviderId, owner: &Owner) -> String {
889    identity_id(provider, &owner.identity())
890}
891
892pub(crate) fn identity_id(provider: ProviderId, identity: &Identity) -> String {
893    match provider {
894        ProviderId::Claude => format!("{}_{}", identity.account_uuid, identity.organization_uuid),
895        ProviderId::Codex => identity.account_uuid.clone(),
896        ProviderId::Desktop => format!("desktop_{}", identity.account_uuid),
897    }
898}
899
900// What `machine_id` answers where the platform's id could not be read.
901pub(crate) const UNKNOWN_MACHINE: &str = "unknown";
902
903/// Hashed, so the raw platform identifier never lands in a file Pitboard writes.
904pub(crate) fn machine_id() -> String {
905    use sha2::{Digest, Sha256};
906    match machine_uid::get() {
907        Ok(raw) => hex::encode(Sha256::digest(raw.as_bytes())),
908        Err(_) => String::from(UNKNOWN_MACHINE),
909    }
910}
911
912fn file(ctx: &Context) -> PathBuf {
913    home::dir(ctx).join("state.json")
914}
915
916/// When Pitboard's account index last changed, in epoch milliseconds, or 0 when there is
917/// none.
918///
919/// Three front ends run on one machine and none of them could tell when another had
920/// changed anything. A switch typed in a terminal left the menu bar naming the account the
921/// person had just stopped using, for as long as five minutes, with a button offering a
922/// switch that had already happened.
923///
924/// This is the cheapest true answer there is: one stat of one file. It is deliberately the
925/// account index alone and not the whole directory. The status line writes usage readings
926/// after a message in any open session, and those say nothing about who is signed in: a
927/// front end follows them with `readings::changed_at`, and takes only the numbers.
928/// Milliseconds rather than seconds, as there: a switch made in the second of the last write
929/// a front end saw would otherwise go unseen.
930pub(crate) fn changed_at(ctx: &Context) -> i64 {
931    std::fs::metadata(file(ctx))
932        .and_then(|m| m.modified())
933        .ok()
934        .and_then(|at| at.duration_since(std::time::UNIX_EPOCH).ok())
935        .map_or(0, |since| {
936            i64::try_from(since.as_millis()).unwrap_or(i64::MAX)
937        })
938}
939
940pub(crate) fn load(ctx: &Context) -> Result<State> {
941    let (state, here) = load_any_machine(ctx)?;
942    if !here {
943        return Err(Error::StateWrongMachine { path: file(ctx) });
944    }
945    Ok(state)
946}
947
948/// The state whatever machine wrote it, and whether that machine is this one.
949///
950/// Only `adopt` reads it this way. Everything else goes through [`load`], which refuses a
951/// file from elsewhere: a parked login is a refresh token, and two machines taking turns
952/// presenting one ends the login for both.
953///
954/// A build that may do nothing here reads nothing either ([`crate::release`]), so an app on
955/// a Windows build of a release before Windows is released is refused at its first read.
956pub(crate) fn load_any_machine(ctx: &Context) -> Result<(State, bool)> {
957    crate::release::check()?;
958    let path = file(ctx);
959    home::check_absolute(ctx)?;
960    home::check_location(ctx, &home::dir(ctx))?;
961    let raw = match std::fs::read_to_string(&path) {
962        Ok(s) => s,
963        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((State::default(), true)),
964        Err(source) => return Err(Error::StateUnreadable { path, source }),
965    };
966    let mut document: Value = serde_json::from_str(&raw).map_err(|source| Error::StateCorrupt {
967        path: path.clone(),
968        source,
969    })?;
970    migrate(&mut document, &path)?;
971    // An account of a tool this build does not know was written by a newer Pitboard, not
972    // damaged. Said as such, because the advice for a corrupt file is to delete it, and
973    // following that here would orphan every parked login in the vault.
974    if let Some(unknown) = unknown_tool(&document) {
975        return Err(Error::StateNamesUnknownTool {
976            path: path.clone(),
977            tool: unknown,
978        });
979    }
980    let state: State = serde_json::from_value(document).map_err(|source| Error::StateCorrupt {
981        path: path.clone(),
982        source,
983    })?;
984    let here = state.machine == machine_id();
985    let mut state = state;
986    // Whose login a tool has stored is a fact about one slot. Per tool, so a changed
987    // `CLAUDE_CONFIG_DIR` says nothing about Codex's record, nor `CODEX_HOME` about Claude
988    // Code's.
989    for &tool in ProviderId::ALL {
990        if let Some(slot) = crate::provider::slot(ctx, tool) {
991            state.in_slot(tool, slot);
992        }
993    }
994    Ok((state, here))
995}
996
997/// The first tool an account names that this build does not know, if any.
998fn unknown_tool(document: &Value) -> Option<String> {
999    document
1000        .get("accounts")?
1001        .as_array()?
1002        .iter()
1003        .filter_map(|account| account.get("provider")?.as_str())
1004        .find(|code| ProviderId::parse(code).is_none())
1005        .map(str::to_owned)
1006}
1007
1008/// Brings an older file up to the current format in place.
1009///
1010/// The command line and the app carry their own copy of this crate and update by different
1011/// routes, so on one machine an older Pitboard will meet a file a newer one wrote. Reading
1012/// forwards is what this is for; reading backwards is not possible, and says so.
1013fn migrate(document: &mut Value, path: &std::path::Path) -> Result<()> {
1014    // Each bump adds a step after the ones before it, so a file two versions behind is
1015    // brought all the way forward in one read.
1016    let found = document
1017        .get("schema")
1018        .and_then(Value::as_u64)
1019        .unwrap_or_default();
1020    let found = u32::try_from(found).unwrap_or(u32::MAX);
1021    match found {
1022        SCHEMA => Ok(()),
1023        3..SCHEMA => {
1024            if found == 3 {
1025                three_to_four(document);
1026            }
1027            if found <= 4 {
1028                four_to_five(document);
1029            }
1030            if found <= 5 {
1031                five_to_six(document);
1032            }
1033            if found <= 6 {
1034                six_to_seven(document);
1035            }
1036            seven_to_eight(document);
1037            Ok(())
1038        }
1039        // Nothing released wrote 1 or 2: the schema reached 3 before the first release.
1040        0..3 => Err(Error::StateVersionUnknown {
1041            path: path.to_path_buf(),
1042            found,
1043        }),
1044        _ => Err(Error::StateFromNewerVersion {
1045            path: path.to_path_buf(),
1046            found,
1047            expected: SCHEMA,
1048        }),
1049    }
1050}
1051
1052/// Schema 3 was Claude Code and nothing else, so every account in one is a Claude account
1053/// and the two singular records are Claude's.
1054///
1055/// Deliberately the smallest transform there could be. Nothing is nested and nothing is
1056/// renamed, because `Detail` is flattened and tagged: a schema 3 account already has
1057/// `organization_uuid` and `oauth_account` as siblings of `label`, which is exactly where
1058/// schema 4 reads them. All that is missing is the tag. No keychain item and no vault file
1059/// is touched, so a bug here is recoverable by fixing the code and reading again, never by
1060/// somebody signing in from scratch.
1061fn three_to_four(document: &mut Value) {
1062    let claude = Value::from(ProviderId::Claude.code());
1063    if let Some(accounts) = document.get_mut("accounts").and_then(Value::as_array_mut) {
1064        for account in accounts {
1065            if let Some(fields) = account.as_object_mut() {
1066                fields.insert("provider".into(), claude.clone());
1067            }
1068        }
1069    }
1070    for singular in ["active", "slot"] {
1071        let was = document.get(singular).cloned().unwrap_or(Value::Null);
1072        document[singular] = match was {
1073            Value::String(label) => serde_json::json!({ ProviderId::Claude.code(): label }),
1074            _ => serde_json::json!({}),
1075        };
1076    }
1077    document["schema"] = serde_json::json!(4);
1078}
1079
1080/// Schema 4 filed every account under its tool's account id, and Anthropic's is the person:
1081/// one person in two organisations was one account. Schema 5 gives each account an id of
1082/// its own, and an account already enrolled keeps the one everything is filed under, so
1083/// no park, reading or window has to move.
1084fn four_to_five(document: &mut Value) {
1085    if let Some(accounts) = document.get_mut("accounts").and_then(Value::as_array_mut) {
1086        for account in accounts {
1087            if let Some(fields) = account.as_object_mut()
1088                && let Some(id) = fields.get("account_uuid").cloned()
1089            {
1090                fields.insert("id".into(), id);
1091            }
1092        }
1093    }
1094    document["schema"] = serde_json::json!(5);
1095}
1096
1097/// Schema 5 recorded the account Pitboard last switched to, by label, which a sign-in
1098/// outside Pitboard left naming an account whose login was gone. Schema 6 records whose login
1099/// each tool has stored, as its service said. A record brought forward is that account's,
1100/// with no login and `known_at` 0: nothing established which login the store held. A label
1101/// naming no account names nobody.
1102fn five_to_six(document: &mut Value) {
1103    let accounts: Vec<Account> = document
1104        .get("accounts")
1105        .and_then(Value::as_array)
1106        .into_iter()
1107        .flatten()
1108        .filter_map(|account| serde_json::from_value(account.clone()).ok())
1109        .collect();
1110    let in_use: serde_json::Map<String, Value> = document
1111        .get("active")
1112        .and_then(Value::as_object)
1113        .into_iter()
1114        .flatten()
1115        .filter_map(|(code, label)| {
1116            let key = Key::new(ProviderId::parse(code)?, label.as_str()?);
1117            let account = accounts.iter().find(|a| a.is(&key))?;
1118            let unconfirmed = InUse {
1119                owner: Some(account.owner()),
1120                login: String::new(),
1121                known_at: 0,
1122                named: None,
1123            };
1124            let record = serde_json::to_value(unconfirmed).expect("a record is serialisable");
1125            Some((code.clone(), record))
1126        })
1127        .collect();
1128    if let Some(fields) = document.as_object_mut() {
1129        fields.remove("active");
1130        fields.insert("in_use".into(), Value::Object(in_use));
1131    }
1132    document["schema"] = serde_json::json!(6);
1133}
1134
1135/// Schema 7 adds `renewing`, which a schema 6 file reads as empty. It is a bump so that an
1136/// earlier Pitboard refuses the file: one would drop `renewing` at its next save, leaving the
1137/// copy of the renewed login named by nothing, so the next renewal of that login, by this
1138/// version or by 0.10.0's `stow`, would send the spent refresh token again.
1139fn six_to_seven(document: &mut Value) {
1140    document["schema"] = serde_json::json!(7);
1141}
1142
1143// A bump: every earlier release reads `discarded` as names, and would call a file of typed
1144// entries corrupt.
1145fn seven_to_eight(document: &mut Value) {
1146    if let Some(discarded) = document.get_mut("discarded").and_then(Value::as_array_mut) {
1147        for entry in discarded {
1148            if let Value::String(service) = entry {
1149                *entry = serde_json::json!({ "service": service });
1150            }
1151        }
1152    }
1153    document["schema"] = serde_json::json!(8);
1154}
1155
1156pub(crate) fn save(ctx: &Context, permit: Permit, state: &State) -> Result<()> {
1157    home::check_absolute(ctx)?;
1158    home::check_location(ctx, &home::dir(ctx))?;
1159    let mut state = state.clone();
1160    for &tool in ProviderId::ALL {
1161        if let Some(slot) = crate::provider::slot(ctx, tool) {
1162            state.in_slot(tool, slot);
1163        }
1164    }
1165    let state = &state;
1166    let path = file(ctx);
1167    let write = |source| Error::StateWriteFailed {
1168        path: path.clone(),
1169        source,
1170    };
1171    home::ensure(ctx, permit).map_err(write)?;
1172    let body = serde_json::to_string_pretty(state).expect("State is always serialisable");
1173    atomic::write(permit, &path, body.as_bytes(), atomic::Perms::Secret).map_err(write)
1174}
1175
1176#[cfg(test)]
1177#[allow(
1178    clippy::disallowed_methods,
1179    reason = "a test sets up its scratch files as a person or a tool would"
1180)]
1181mod tests {
1182    /// CLAUDE_CONFIG_DIR picks which keychain item is the live one, and one state file
1183    /// serves every slot on a machine. A record of whose login one slot holds says nothing
1184    /// about another, so it is not carried over, and says nothing about Codex's at all. It is
1185    /// kept for when its slot is read again, whatever another slot records meanwhile.
1186    #[test]
1187    fn whose_login_another_slot_holds_is_not_claimed_here() {
1188        let home = std::env::temp_dir().join(format!("pitboard-slots-{}", std::process::id()));
1189        let _ = std::fs::remove_dir_all(&home);
1190        let here = Context::new(home.clone()).with_pitboard_home(home.clone());
1191        let elsewhere = here
1192            .clone()
1193            .with_claude_config_dir(home.join("somewhere-else").to_string_lossy().into());
1194
1195        let mut state = State::default();
1196        state.accounts.push(account("work", None));
1197        state.accounts.push(codex_account("personal"));
1198        let found = InUse::of(&state.accounts[0], "f", 100);
1199        state.identified(ProviderId::Claude, found.clone(), 100);
1200        let codex = InUse::of(&state.accounts[1], "f", 100);
1201        state.identified(ProviderId::Codex, codex.clone(), 100);
1202        save(&here, Permit::for_a_test(), &state).expect("saved");
1203
1204        assert_eq!(
1205            load(&here).unwrap().in_use(ProviderId::Claude),
1206            Some(&found)
1207        );
1208        let moved = load(&elsewhere).unwrap();
1209        assert_eq!(
1210            moved.in_use(ProviderId::Claude),
1211            None,
1212            "another slot's record of whose login is stored is not this slot's"
1213        );
1214        assert_eq!(
1215            moved.in_use(ProviderId::Codex),
1216            Some(&codex),
1217            "Codex's slot did not move"
1218        );
1219
1220        let mut moved = moved;
1221        let there = InUse::of(&moved.accounts[0], "g", 200);
1222        moved.identified(ProviderId::Claude, there.clone(), 200);
1223        save(&elsewhere, Permit::for_a_test(), &moved).expect("saved");
1224        assert_eq!(
1225            load(&here).unwrap().in_use(ProviderId::Claude),
1226            Some(&found),
1227            "this slot's record is kept while another slot records its own"
1228        );
1229        save(&here, Permit::for_a_test(), &load(&here).unwrap()).expect("saved");
1230        assert_eq!(
1231            load(&elsewhere).unwrap().in_use(ProviderId::Claude),
1232            Some(&there),
1233            "and so is the other's"
1234        );
1235        let _ = std::fs::remove_dir_all(&home);
1236    }
1237
1238    /// `CODEX_HOME` picks which `auth.json` is Codex's live login, the way
1239    /// `CLAUDE_CONFIG_DIR` picks Claude Code's keychain item. A record made under one home
1240    /// says nothing about another, and says nothing about Claude Code's at all.
1241    #[test]
1242    fn another_codex_home_is_another_codex_slot() {
1243        let home = std::env::temp_dir().join(format!(
1244            "pitboard-codex-slots-{}-{:?}",
1245            std::process::id(),
1246            std::thread::current().id()
1247        ));
1248        let _ = std::fs::remove_dir_all(&home);
1249        let here = Context::new(home.clone()).with_pitboard_home(home.clone());
1250        let mut state = State::default();
1251        state.accounts.push(account("work", None));
1252        state.accounts.push(codex_account("work"));
1253        for (which, at) in [(ProviderId::Claude, 0), (ProviderId::Codex, 1)] {
1254            let found = InUse::of(&state.accounts[at], "f", 100);
1255            state.identified(which, found, 100);
1256        }
1257        save(&here, Permit::for_a_test(), &state).expect("saved");
1258
1259        let label_in_use = |state: &State, which| {
1260            state
1261                .account_in_use(which)
1262                .map(|account| account.label.clone())
1263        };
1264        let moved = here
1265            .clone()
1266            .with_codex_home(home.join("somewhere-else").to_string_lossy().into());
1267        let loaded = load(&moved).unwrap();
1268        assert_eq!(loaded.in_use(ProviderId::Codex), None);
1269        assert_eq!(
1270            label_in_use(&loaded, ProviderId::Claude).as_deref(),
1271            Some("work"),
1272            "Claude Code's slot did not move"
1273        );
1274        assert_eq!(
1275            label_in_use(&load(&here).unwrap(), ProviderId::Codex).as_deref(),
1276            Some("work")
1277        );
1278        let _ = std::fs::remove_dir_all(&home);
1279    }
1280
1281    /// A file written by the version in people's hands today. The command line and the app
1282    /// update separately, so a file one of them wrote has to keep loading in the other.
1283    #[test]
1284    fn the_format_shipped_in_0_1_x_still_loads() {
1285        let written = serde_json::json!({
1286            "schema": 3,
1287            "machine": machine_id(),
1288            "accounts": [{
1289                "label": "work",
1290                "account_uuid": "acc-1",
1291                "email": "a@b.c",
1292                "organization_uuid": "org-1",
1293                "oauth_account": {"emailAddress": "a@b.c"},
1294                "parked": {
1295                    "service": "pitboard-park-acc-1-1789935600123",
1296                    "parked_at": 1_789_935_600,
1297                    "refresh_fingerprint": "abcd",
1298                    "access_expires_at": 1_789_999_999,
1299                    "refresh_expires_at": 1_792_000_000
1300                }
1301            }],
1302            "active": "work",
1303            "discarded": []
1304        });
1305        let mut document = written;
1306        migrate(&mut document, std::path::Path::new("/tmp/state.json")).expect("still current");
1307        let state: State = serde_json::from_value(document).expect("still parses");
1308        assert_eq!(state.get(&claude("work")).unwrap().email, "a@b.c");
1309        assert_eq!(state.get(&claude("work")).unwrap().id, "acc-1");
1310        assert_eq!(
1311            state.account_in_use(ProviderId::Claude).map(Account::key),
1312            Some(claude("work"))
1313        );
1314    }
1315
1316    /// Migrating a file that is already current must change nothing.
1317    ///
1318    /// `three_to_four` rewrites `active` and `slot` in place and `five_to_six` drops
1319    /// `active`, and a version check that slipped would wrap an already-wrapped map into
1320    /// `{"claude": {"claude": "work"}}`, or find no `active` to bring forward, and lose whose
1321    /// login is stored, silently, on every load after that.
1322    #[test]
1323    fn migrating_a_current_file_is_a_no_op() {
1324        let mut once = serde_json::json!({
1325            "schema": 3,
1326            "machine": machine_id(),
1327            "accounts": [{
1328                "label": "work", "account_uuid": "acc-1", "email": "a@b.c",
1329                "organization_uuid": "org-1", "oauth_account": {}, "parked": null
1330            }],
1331            "active": "work",
1332            "slot": "Claude Code-credentials",
1333            "discarded": ["gone"]
1334        });
1335        migrate(&mut once, std::path::Path::new("/tmp/state.json")).expect("3 to 8");
1336        assert_eq!(once["discarded"], serde_json::json!([{"service": "gone"}]));
1337        let mut twice = once.clone();
1338        migrate(&mut twice, std::path::Path::new("/tmp/state.json")).expect("8 is current");
1339        assert_eq!(once, twice, "a second migration must change nothing");
1340        assert!(once.get("active").is_none());
1341        assert_eq!(
1342            once["in_use"]["claude"]["owner"]["account_uuid"],
1343            serde_json::json!("acc-1")
1344        );
1345        assert_eq!(
1346            once["slot"],
1347            serde_json::json!({"claude": "Claude Code-credentials"})
1348        );
1349        assert_eq!(once["accounts"][0]["provider"], "claude");
1350        assert_eq!(once["accounts"][0]["id"], "acc-1");
1351        assert_eq!(once["schema"], SCHEMA);
1352    }
1353
1354    /// Schema 3 had no `active` at all when nothing had been switched to, and a migration
1355    /// that turned that into a one-entry map naming nothing would claim a switch happened.
1356    #[test]
1357    fn a_file_that_never_switched_migrates_to_no_account_in_use() {
1358        let mut document = serde_json::json!({
1359            "schema": 3, "machine": machine_id(), "accounts": [], "discarded": []
1360        });
1361        migrate(&mut document, std::path::Path::new("/tmp/state.json")).expect("3 to 8");
1362        let state: State = serde_json::from_value(document).expect("parses");
1363        assert!(state.in_use.is_empty() && state.slot.is_empty());
1364    }
1365
1366    /// A file as 0.9.0 writes it names the account last switched to. It comes forward as
1367    /// that account's record with nothing established about the login, so nothing reads it
1368    /// as what the service said until the service is asked.
1369    #[test]
1370    fn a_schema_5_file_comes_forward_with_the_account_in_use_unconfirmed() {
1371        let home = std::env::temp_dir().join(format!(
1372            "pitboard-schema-5-{}-{:?}",
1373            std::process::id(),
1374            std::thread::current().id()
1375        ));
1376        let _ = std::fs::remove_dir_all(&home);
1377        std::fs::create_dir_all(&home).unwrap();
1378        let ctx = Context::new(home.clone()).with_pitboard_home(home.clone());
1379        std::fs::write(
1380            home.join("state.json"),
1381            serde_json::json!({
1382                "schema": 5,
1383                "machine": machine_id(),
1384                "accounts": [{
1385                    "label": "work",
1386                    "id": "acc-1_org-1",
1387                    "account_uuid": "acc-1",
1388                    "email": "a@b.c",
1389                    "parked": null,
1390                    "last_used_at": 1_789_935_000,
1391                    "provider": "claude",
1392                    "organization_uuid": "org-1",
1393                    "oauth_account": {"accountUuid": "acc-1", "organizationUuid": "org-1"}
1394                }],
1395                "active": {"claude": "work", "codex": "gone"},
1396                "slot": {},
1397                "discarded": [],
1398                "foreign": []
1399            })
1400            .to_string(),
1401        )
1402        .unwrap();
1403
1404        let state = load(&ctx).expect("brought forward");
1405        assert_eq!(
1406            state.in_use(ProviderId::Claude),
1407            Some(&InUse {
1408                owner: Some(Owner {
1409                    account_uuid: "acc-1".into(),
1410                    email: "a@b.c".into(),
1411                    organization_uuid: "org-1".into(),
1412                }),
1413                login: String::new(),
1414                known_at: 0,
1415                named: None,
1416            })
1417        );
1418        assert_eq!(
1419            state.in_use(ProviderId::Codex),
1420            None,
1421            "a label naming no account names nobody"
1422        );
1423
1424        save(&ctx, Permit::for_a_test(), &state).expect("saved");
1425        let written: Value =
1426            serde_json::from_str(&std::fs::read_to_string(home.join("state.json")).unwrap())
1427                .unwrap();
1428        assert_eq!(written["schema"], SCHEMA);
1429        assert!(written.get("active").is_none(), "{written}");
1430        assert_eq!(written["in_use"]["claude"]["known_at"], 0);
1431        let _ = std::fs::remove_dir_all(&home);
1432    }
1433
1434    #[test]
1435    fn a_schema_6_file_is_written_back_at_schema_8_and_otherwise_as_it_was() {
1436        let home = std::env::temp_dir().join(format!(
1437            "pitboard-schema-6-{}-{:?}",
1438            std::process::id(),
1439            std::thread::current().id()
1440        ));
1441        let _ = std::fs::remove_dir_all(&home);
1442        std::fs::create_dir_all(&home).unwrap();
1443        let ctx = Context::new(home.clone()).with_pitboard_home(home.clone());
1444        let mut state = State::default();
1445        state.accounts.push(account("work", Some(park("parked"))));
1446        let found = InUse::of(&state.accounts[0], "f", 100);
1447        state.identified(ProviderId::Claude, found, 100);
1448        state.discarded.push(Dropped {
1449            service: "gone".into(),
1450            kind: ParkKind::Vault,
1451            forgotten: false,
1452        });
1453        save(&ctx, Permit::for_a_test(), &state).expect("saved");
1454        let path = home.join("state.json");
1455        let read = |path: &PathBuf| -> Value {
1456            serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
1457        };
1458        let mut written = read(&path);
1459        written["schema"] = 6.into();
1460        std::fs::write(&path, written.to_string()).unwrap();
1461
1462        let state = load(&ctx).expect("a schema 6 file is read");
1463        save(&ctx, Permit::for_a_test(), &state).expect("saved");
1464
1465        written["schema"] = 8.into();
1466        assert_eq!(read(&path), written);
1467        let _ = std::fs::remove_dir_all(&home);
1468    }
1469
1470    #[test]
1471    fn a_schema_7_file_0_11_0_wrote_is_written_back_at_schema_8_with_its_discarded_parks_typed() {
1472        let home = std::env::temp_dir().join(format!(
1473            "pitboard-schema-7-{}-{:?}",
1474            std::process::id(),
1475            std::thread::current().id()
1476        ));
1477        let _ = std::fs::remove_dir_all(&home);
1478        std::fs::create_dir_all(&home).unwrap();
1479        let ctx = Context::new(home.clone()).with_pitboard_home(home.clone());
1480        let mut state = State::default();
1481        state.accounts.push(account("work", Some(park("parked"))));
1482        let found = InUse::of(&state.accounts[0], "f", 100);
1483        state.identified(ProviderId::Claude, found, 100);
1484        save(&ctx, Permit::for_a_test(), &state).expect("saved");
1485        let path = home.join("state.json");
1486        let read = |path: &PathBuf| -> Value {
1487            serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
1488        };
1489        let mut written = read(&path);
1490        written["schema"] = 7.into();
1491        written["discarded"] = serde_json::json!(["pitboard-park-a-1", "pitboard-park-b-2"]);
1492        std::fs::write(&path, written.to_string()).unwrap();
1493
1494        let state = load(&ctx).expect("a schema 7 file is read");
1495        save(&ctx, Permit::for_a_test(), &state).expect("saved");
1496
1497        written["schema"] = 8.into();
1498        written["discarded"] = serde_json::json!([
1499            {"service": "pitboard-park-a-1"},
1500            {"service": "pitboard-park-b-2"}
1501        ]);
1502        assert_eq!(read(&path), written);
1503        let _ = std::fs::remove_dir_all(&home);
1504    }
1505
1506    #[test]
1507    fn a_schema_7_park_reads_and_writes_as_it_was() {
1508        let written = serde_json::json!({
1509            "service": "pitboard-park-a-1", "parked_at": 100, "refresh_fingerprint": "0a1b",
1510            "access_expires_at": 200, "refresh_expires_at": null
1511        });
1512        let park: Park = serde_json::from_value(written.clone()).expect("a schema 7 park");
1513        assert_eq!(park.kind, ParkKind::Vault);
1514        assert_eq!(park.login, "0a1b");
1515        assert_eq!(park.session_expires_at, None);
1516        assert_eq!(serde_json::to_value(&park).unwrap(), written);
1517
1518        let folder = Park {
1519            kind: ParkKind::Folder,
1520            session_expires_at: Some(900),
1521            ..park
1522        };
1523        let written = serde_json::to_value(&folder).unwrap();
1524        assert_eq!(written["kind"], "folder");
1525        assert_eq!(written["refresh_fingerprint"], "0a1b");
1526        assert_eq!(written["session_expires_at"], 900);
1527        assert_eq!(serde_json::from_value::<Park>(written).unwrap(), folder);
1528        let dropped = Dropped {
1529            service: "pitboard-park-a-1".into(),
1530            kind: ParkKind::Folder,
1531            forgotten: false,
1532        };
1533        assert_eq!(
1534            serde_json::to_value(&dropped).unwrap(),
1535            serde_json::json!({"service": "pitboard-park-a-1", "kind": "folder"})
1536        );
1537    }
1538
1539    fn folder_park(service: &str, login: &str, at: i64) -> Park {
1540        Park {
1541            service: service.into(),
1542            kind: ParkKind::Folder,
1543            parked_at: at,
1544            login: login.into(),
1545            access_expires_at: None,
1546            refresh_expires_at: None,
1547            session_expires_at: None,
1548        }
1549    }
1550
1551    #[test]
1552    fn a_move_and_a_sign_in_waited_for_name_their_folders_and_follow_a_forgotten_account() {
1553        let mut s = State::default();
1554        s.accounts.push(account("work", None));
1555        s.accounts
1556            .push(account("home", Some(folder_park("park-home", "f1", 100))));
1557        let (work, home) = (
1558            Key::new(ProviderId::Claude, "work"),
1559            Key::new(ProviderId::Claude, "home"),
1560        );
1561        let awaited = Awaited {
1562            since: 5,
1563            label: "travel".into(),
1564            account: Some("home-uuid".into()),
1565            put_back: "work-uuid".into(),
1566        };
1567        s.moving.insert(
1568            "desktop".into(),
1569            Moving {
1570                half: Half::Arrive,
1571                started_at: 5,
1572                id: "home-uuid".into(),
1573                label: "home".into(),
1574                park: "park-home".into(),
1575                login: "f1".into(),
1576                items: vec!["Cookies".into()],
1577                keys: BTreeMap::new(),
1578                asides: BTreeMap::from([("aside-1".to_string(), SetAside::InTheWay)]),
1579                slot: "/data".into(),
1580                awaited: None,
1581            },
1582        );
1583        s.awaiting.insert("desktop".into(), awaited.clone());
1584        assert!(s.names("park-home") && s.names("aside-1") && !s.names("aside-2"));
1585        assert!(s.moves("aside-1") && s.moving(ProviderId::Desktop).is_some());
1586
1587        let written = serde_json::to_value(&s).unwrap();
1588        assert_eq!(written["moving"]["desktop"]["half"], "arrive");
1589        assert_eq!(
1590            written["moving"]["desktop"]["asides"]["aside-1"],
1591            "in_the_way"
1592        );
1593        assert_eq!(written["awaiting"]["desktop"]["put_back"], "work-uuid");
1594        let read: State = serde_json::from_value(written).unwrap();
1595        assert_eq!(read.moving, s.moving);
1596        assert_eq!(read.awaiting, s.awaiting);
1597        let plain = serde_json::to_value(State::default()).unwrap();
1598        assert!(plain.get("moving").is_none() && plain.get("awaiting").is_none());
1599
1600        // A folder a move holds is never listed to be deleted, forgotten or not.
1601        s.remove(&home);
1602        assert!(discarded(&s).is_empty(), "{:?}", s.discarded);
1603        assert_eq!(
1604            s.awaiting["desktop"].account, None,
1605            "a Sign In Again of a forgotten account reads as an add"
1606        );
1607        s.remove(&work);
1608        assert!(s.awaiting.is_empty(), "nothing is left to put back");
1609    }
1610
1611    #[test]
1612    fn a_folder_park_replaced_is_set_aside_and_never_listed_for_deletion() {
1613        let mut s = State::default();
1614        s.accounts.push(account("work", None));
1615        s.accounts.push(account("home", Some(park("vault-old"))));
1616        let (work, home) = (
1617            Key::new(ProviderId::Claude, "work"),
1618            Key::new(ProviderId::Claude, "home"),
1619        );
1620        s.park(&work, folder_park("folder-old", "f1", 100));
1621        s.park(&work, folder_park("folder-new", "f2", 200));
1622        assert_eq!(
1623            s.get(&work).unwrap().parked.as_ref().unwrap().service,
1624            "folder-new"
1625        );
1626        assert_eq!(
1627            s.aside,
1628            [Aside {
1629                name: "folder-old".into(),
1630                tool: ProviderId::Claude,
1631                at: 200,
1632                why: SetAside::Superseded,
1633                account: Some("work-uuid".into()),
1634                login: Some("f1".into()),
1635            }]
1636        );
1637        assert!(discarded(&s).is_empty(), "never listed for deletion");
1638        assert!(s.names("folder-old") && s.names("folder-new"));
1639        assert_eq!(s.aside_of("work-uuid").count(), 1);
1640
1641        // A vault park replaced is let go as it always was.
1642        s.park(&home, park("vault-new"));
1643        assert_eq!(discarded(&s), ["vault-old"]);
1644        assert_eq!(s.aside.len(), 1);
1645
1646        assert_eq!(
1647            s.drop_aside("folder-old").map(|a| a.name).as_deref(),
1648            Some("folder-old")
1649        );
1650        assert!(!s.names("folder-old"));
1651    }
1652
1653    #[test]
1654    fn forgetting_an_account_is_the_only_way_its_folder_is_listed_forgotten() {
1655        let mut s = State::default();
1656        s.accounts.push(account("work", None));
1657        s.accounts.push(account("home", None));
1658        let (work, home) = (
1659            Key::new(ProviderId::Claude, "work"),
1660            Key::new(ProviderId::Claude, "home"),
1661        );
1662        s.park(&work, folder_park("work-1", "f1", 100));
1663        s.park(&work, folder_park("work-2", "f2", 200));
1664        s.park(&home, folder_park("home-1", "f3", 100));
1665        s.set_aside(
1666            &home,
1667            folder_park("home-0", "f0", 50),
1668            SetAside::GivenUp,
1669            300,
1670        );
1671        s.discard("emptied", ParkKind::Folder);
1672        assert!(
1673            s.discarded.iter().all(|d| !d.forgotten),
1674            "{:?}",
1675            s.discarded
1676        );
1677
1678        s.remove(&work);
1679        let forgotten: Vec<&str> = s
1680            .discarded
1681            .iter()
1682            .filter(|d| d.forgotten)
1683            .map(|d| d.service.as_str())
1684            .collect();
1685        assert_eq!(
1686            forgotten,
1687            ["work-2", "work-1"],
1688            "its park, and what was set aside for it"
1689        );
1690        assert!(s.discarded.iter().all(|d| d.kind == ParkKind::Folder));
1691        assert_eq!(
1692            s.aside.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
1693            ["home-0"],
1694            "another account's stays"
1695        );
1696        assert!(s.get(&home).unwrap().parked.is_some());
1697
1698        s.forget_asides();
1699        assert!(s.aside.is_empty());
1700        assert!(
1701            s.discarded
1702                .iter()
1703                .any(|d| d.service == "home-0" && d.forgotten)
1704        );
1705        assert!(!s.discarded.iter().any(|d| d.service == "home-1"));
1706    }
1707
1708    #[test]
1709    fn a_discarded_entry_already_typed_is_left_as_it_is() {
1710        let mut document = serde_json::json!({
1711            "schema": 7, "machine": machine_id(), "accounts": [],
1712            "discarded": ["pitboard-park-a-1", {"service": "pitboard-park-b-2"}]
1713        });
1714        migrate(&mut document, std::path::Path::new("/tmp/state.json")).expect("7 to 8");
1715        assert_eq!(
1716            document["discarded"],
1717            serde_json::json!([{"service": "pitboard-park-a-1"}, {"service": "pitboard-park-b-2"}])
1718        );
1719    }
1720
1721    /// 0.11.0 kept a Codex plan beside the workspace, as its token says it.
1722    #[test]
1723    fn a_codex_plan_0_11_0_recorded_is_the_accounts_plan() {
1724        let home = std::env::temp_dir().join(format!(
1725            "pitboard-plan-0-11-0-{}-{:?}",
1726            std::process::id(),
1727            std::thread::current().id()
1728        ));
1729        let _ = std::fs::remove_dir_all(&home);
1730        std::fs::create_dir_all(&home).unwrap();
1731        let ctx = Context::new(home.clone()).with_pitboard_home(home.clone());
1732        let mut state = State::default();
1733        state.accounts.push(codex_account("work"));
1734        save(&ctx, Permit::for_a_test(), &state).expect("saved");
1735        let path = home.join("state.json");
1736        let read = |path: &PathBuf| -> Value {
1737            serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
1738        };
1739        let mut written = read(&path);
1740        written["schema"] = 7.into();
1741        written["accounts"][0]["plan"] = "pro".into();
1742        std::fs::write(&path, written.to_string()).unwrap();
1743
1744        let state = load(&ctx).expect("read");
1745        assert_eq!(state.accounts[0].plan.as_deref(), Some("pro"));
1746        save(&ctx, Permit::for_a_test(), &state).expect("saved");
1747
1748        written["schema"] = 8.into();
1749        assert_eq!(read(&path), written);
1750        assert_eq!(read(&path)["accounts"][0]["label"], "work");
1751        let _ = std::fs::remove_dir_all(&home);
1752    }
1753
1754    /// A file as 0.8.0 writes it. Each account keeps the id its parks, readings and windows
1755    /// are already filed under.
1756    #[test]
1757    fn the_format_0_8_0_writes_keeps_every_account_where_it_is_filed() {
1758        let mut document = serde_json::json!({
1759            "schema": 4,
1760            "machine": machine_id(),
1761            "accounts": [
1762                {
1763                    "label": "work",
1764                    "account_uuid": "acc-1",
1765                    "email": "a@b.c",
1766                    "parked": {
1767                        "service": "pitboard-park-acc-1-1789935600123",
1768                        "parked_at": 1_789_935_600,
1769                        "refresh_fingerprint": "abcd",
1770                        "access_expires_at": 1_789_999_999,
1771                        "refresh_expires_at": 1_792_000_000
1772                    },
1773                    "last_used_at": 1_789_935_000,
1774                    "provider": "claude",
1775                    "organization_uuid": "org-1",
1776                    "oauth_account": {
1777                        "accountUuid": "acc-1",
1778                        "emailAddress": "a@b.c",
1779                        "organizationUuid": "org-1"
1780                    }
1781                },
1782                {
1783                    "label": "work",
1784                    "account_uuid": "team_user-1",
1785                    "email": "a@b.c",
1786                    "parked": null,
1787                    "last_used_at": null,
1788                    "provider": "codex",
1789                    "workspace_id": "team",
1790                    "plan": "team"
1791                }
1792            ],
1793            "active": {"claude": "work", "codex": "work"},
1794            "slot": {"claude": "Claude Code-credentials", "codex": "/home/a/.codex"},
1795            "discarded": [],
1796            "foreign": []
1797        });
1798        migrate(&mut document, std::path::Path::new("/tmp/state.json")).expect("4 to 6");
1799        let state: State = serde_json::from_value(document).expect("parses");
1800
1801        let work = state.get(&claude("work")).unwrap();
1802        assert_eq!(
1803            (work.id.as_str(), work.account_uuid.as_str()),
1804            ("acc-1", "acc-1")
1805        );
1806        let codex = state.get(&Key::new(ProviderId::Codex, "work")).unwrap();
1807        assert_eq!(codex.id, "team_user-1");
1808        assert_eq!(
1809            state.owner_of_park("acc-1").map(Account::key),
1810            Some(work.key())
1811        );
1812        let signed_in = Owner {
1813            account_uuid: "acc-1".into(),
1814            email: "a@b.c".into(),
1815            organization_uuid: "org-1".into(),
1816        };
1817        assert_eq!(
1818            state.id_of(ProviderId::Claude, &signed_in),
1819            "acc-1",
1820            "the login it was enrolled with is still found by its organisation"
1821        );
1822    }
1823
1824    /// Anthropic's account uuid is the person. Their logins to two organisations are two
1825    /// accounts, and a third organisation's is neither.
1826    #[test]
1827    fn one_person_in_two_organisations_is_two_accounts() {
1828        let in_org = |label: &str, id: &str, org: &str| Account {
1829            last_used_at: None,
1830            replaced_at: None,
1831            plan: None,
1832            label: label.into(),
1833            id: id.into(),
1834            account_uuid: "acc".into(),
1835            email: "a@b.c".into(),
1836            detail: Detail::Claude {
1837                organization_uuid: org.into(),
1838                oauth_account: serde_json::json!({}),
1839            },
1840            parked: None,
1841        };
1842        let mut state = State::default();
1843        state.upsert(in_org("work", "acc", "org-a"));
1844        state.upsert(in_org("team", "acc_org-b", "org-b"));
1845        let signed_in = |org: &str| Owner {
1846            account_uuid: "acc".into(),
1847            email: "a@b.c".into(),
1848            organization_uuid: org.into(),
1849        };
1850        let label_of = |org: &str| {
1851            state
1852                .account_of(ProviderId::Claude, &signed_in(org))
1853                .map(|a| a.label.as_str())
1854        };
1855
1856        assert_eq!(label_of("org-a"), Some("work"));
1857        assert_eq!(label_of("org-b"), Some("team"));
1858        assert_eq!(label_of("org-c"), None);
1859        assert_eq!(
1860            state.id_of(ProviderId::Claude, &signed_in("org-c")),
1861            "acc_org-c"
1862        );
1863        assert!(
1864            state
1865                .account_of(ProviderId::Codex, &signed_in("org-a"))
1866                .is_none()
1867        );
1868    }
1869
1870    /// `Detail` is flattened and tagged, which is the whole reason the migration has
1871    /// nothing to move. If it ever stopped sitting beside `label` in the file, every
1872    /// account already written would stop loading.
1873    #[test]
1874    fn a_providers_own_fields_sit_beside_the_shared_ones() {
1875        let account = Account {
1876            label: "work".into(),
1877            id: "acc".into(),
1878            account_uuid: "acc".into(),
1879            email: "a@b.c".into(),
1880            parked: None,
1881            last_used_at: None,
1882            replaced_at: None,
1883            plan: None,
1884            detail: Detail::Claude {
1885                organization_uuid: "org".into(),
1886                oauth_account: serde_json::json!({"emailAddress": "a@b.c"}),
1887            },
1888        };
1889        let written = serde_json::to_value(&account).expect("writes");
1890        assert_eq!(written["provider"], "claude");
1891        assert_eq!(written["organization_uuid"], "org");
1892        assert_eq!(written["label"], "work");
1893        assert!(
1894            written.get("detail").is_none(),
1895            "flattened, so there is no nested object: {written}"
1896        );
1897        let back: Account = serde_json::from_value(written).expect("reads back");
1898        assert_eq!(back.provider(), ProviderId::Claude);
1899        assert_eq!(back.claude().unwrap().organization_uuid, "org");
1900    }
1901
1902    /// A file naming a tool this build does not know came from a newer Pitboard. Called
1903    /// corrupt, its advice would be to delete it, which orphans every parked login.
1904    #[test]
1905    fn an_account_of_an_unknown_tool_asks_for_an_upgrade_not_a_deletion() {
1906        let home = std::env::temp_dir().join(format!(
1907            "pitboard-unknown-tool-{}-{:?}",
1908            std::process::id(),
1909            std::thread::current().id()
1910        ));
1911        let _ = std::fs::remove_dir_all(&home);
1912        std::fs::create_dir_all(&home).unwrap();
1913        let ctx = Context::new(home.clone()).with_pitboard_home(home.clone());
1914        std::fs::write(
1915            home.join("state.json"),
1916            serde_json::json!({
1917                "schema": SCHEMA,
1918                "machine": machine_id(),
1919                "accounts": [{
1920                    "label": "work", "account_uuid": "u", "email": "a@b.c",
1921                    "parked": null, "provider": "somethingnew"
1922                }],
1923                "in_use": {}, "slot": {}, "discarded": []
1924            })
1925            .to_string(),
1926        )
1927        .unwrap();
1928        let err = load(&ctx).unwrap_err();
1929        assert_eq!(err.code(), "state_names_unknown_tool");
1930        assert!(err.to_string().contains("somethingnew"), "{err}");
1931        assert!(!err.to_string().contains("Delete"), "{err}");
1932        let _ = std::fs::remove_dir_all(&home);
1933    }
1934
1935    /// Two writes of the index within one second are two changes to a front end that follows
1936    /// it: a switch typed in a terminal in the second of the last write it saw went unseen
1937    /// until it next looked for another reason.
1938    #[test]
1939    fn two_writes_within_one_second_are_two_changes() {
1940        let home = std::env::temp_dir().join(format!(
1941            "pitboard-changed-at-{}-{:?}",
1942            std::process::id(),
1943            std::thread::current().id()
1944        ));
1945        let _ = std::fs::remove_dir_all(&home);
1946        std::fs::create_dir_all(&home).unwrap();
1947        let ctx = Context::new(home.clone()).with_pitboard_home(home.clone());
1948        std::fs::write(home.join("state.json"), "{}").unwrap();
1949        let second = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_760_000_000);
1950        let written_at = |millis| {
1951            std::fs::File::options()
1952                .write(true)
1953                .open(home.join("state.json"))
1954                .and_then(|index| {
1955                    index.set_modified(second + std::time::Duration::from_millis(millis))
1956                })
1957                .expect("the index's time set");
1958            changed_at(&ctx)
1959        };
1960        assert_ne!(written_at(100), written_at(600));
1961        let _ = std::fs::remove_dir_all(&home);
1962    }
1963
1964    /// The other direction cannot work, and the message has to say which half to upgrade.
1965    #[test]
1966    fn a_file_from_a_newer_pitboard_is_refused() {
1967        let mut current =
1968            serde_json::json!({"schema": SCHEMA, "machine": machine_id(), "accounts": []});
1969        migrate(&mut current, std::path::Path::new("/tmp/state.json")).expect("this one's own");
1970        let mut document = serde_json::json!({"schema": SCHEMA + 1});
1971        let err = migrate(&mut document, std::path::Path::new("/tmp/state.json")).unwrap_err();
1972        assert_eq!(err.code(), "state_from_newer_version");
1973        let said = err.to_string();
1974        assert!(said.contains("Update this Pitboard"), "{said}");
1975        assert!(
1976            !said.contains("brew") && !said.contains("cargo"),
1977            "it names no one way of installing Pitboard: {said}"
1978        );
1979    }
1980
1981    #[test]
1982    fn a_schema_number_too_large_to_hold_is_refused() {
1983        let mut document = serde_json::json!({
1984            "schema": (1_u64 << 32) + u64::from(SCHEMA),
1985            "machine": machine_id(),
1986            "accounts": []
1987        });
1988        let err = migrate(&mut document, std::path::Path::new("/tmp/state.json")).unwrap_err();
1989        assert_eq!(err.code(), "state_from_newer_version");
1990    }
1991
1992    #[test]
1993    fn each_migration_step_stamps_the_schema_it_brings_a_file_to() {
1994        for (from, step) in [
1995            (3, three_to_four as fn(&mut Value)),
1996            (4, four_to_five),
1997            (5, five_to_six),
1998            (6, six_to_seven),
1999            (7, seven_to_eight),
2000        ] {
2001            let mut document = serde_json::json!({"schema": from, "accounts": []});
2002            step(&mut document);
2003            assert_eq!(document["schema"], from + 1, "the step from {from}");
2004        }
2005    }
2006
2007    use super::*;
2008
2009    fn claude(label: &str) -> Key {
2010        Key::new(ProviderId::Claude, label)
2011    }
2012
2013    #[test]
2014    fn machine_id_is_stable_and_real() {
2015        let a = machine_id();
2016        assert_eq!(a, machine_id());
2017        assert_eq!(
2018            a.len(),
2019            64,
2020            "expected a sha256 of the platform id, got {a:?}"
2021        );
2022        assert_ne!(
2023            a, "unknown",
2024            "this platform should report a stable machine id"
2025        );
2026    }
2027
2028    fn park(service: &str) -> Park {
2029        Park {
2030            service: service.into(),
2031            parked_at: 100,
2032            kind: ParkKind::Vault,
2033            login: "f".into(),
2034            access_expires_at: Some(200),
2035            refresh_expires_at: Some(300),
2036            session_expires_at: None,
2037        }
2038    }
2039
2040    fn discarded(s: &State) -> Vec<&str> {
2041        s.discarded.iter().map(|d| d.service.as_str()).collect()
2042    }
2043
2044    fn account(label: &str, parked: Option<Park>) -> Account {
2045        Account {
2046            last_used_at: None,
2047            replaced_at: None,
2048            plan: None,
2049            label: label.into(),
2050            id: format!("{label}-uuid"),
2051            account_uuid: format!("{label}-uuid"),
2052            email: format!("{label}@example.com"),
2053            detail: Detail::Claude {
2054                organization_uuid: "o".into(),
2055                oauth_account: serde_json::json!({}),
2056            },
2057            parked,
2058        }
2059    }
2060
2061    #[test]
2062    fn a_new_park_discards_the_one_it_replaces() {
2063        let mut s = State::default();
2064        s.upsert(account("work", Some(park("old"))));
2065        s.park(&claude("work"), park("new"));
2066        assert_eq!(
2067            s.get(&claude("work"))
2068                .unwrap()
2069                .parked
2070                .as_ref()
2071                .unwrap()
2072                .service,
2073            "new"
2074        );
2075        assert_eq!(discarded(&s), ["old"]);
2076        assert!(!s.references("old"));
2077    }
2078
2079    #[test]
2080    fn discarding_releases_whichever_account_held_it_and_lists_it_once() {
2081        let mut s = State::default();
2082        s.upsert(account("work", Some(park("current"))));
2083        s.discard("something-else", ParkKind::Vault);
2084        assert!(s.get(&claude("work")).unwrap().parked.is_some());
2085        s.discard("current", ParkKind::Vault);
2086        s.discard("current", ParkKind::Vault);
2087        assert!(s.get(&claude("work")).unwrap().parked.is_none());
2088        assert_eq!(discarded(&s), ["something-else", "current"]);
2089    }
2090
2091    /// A park let go of, installed or replaced, is no longer written down as a copy of a
2092    /// file's login, and the list is left out of the file while it is empty.
2093    #[test]
2094    fn a_park_let_go_of_is_no_longer_a_copy_of_a_files_login() {
2095        let mut s = State::default();
2096        s.upsert(account("work", Some(park("copy"))));
2097        assert!(!serde_json::to_string(&s).unwrap().contains("from_file"));
2098        s.copying_from_file("copy");
2099        s.copying_from_file("copy");
2100        assert_eq!(s.from_file, ["copy"]);
2101        assert!(s.is_from_file("copy"));
2102
2103        s.park(&claude("work"), park("renewed"));
2104        assert!(s.from_file.is_empty());
2105        assert!(!s.file_gone(), "nothing was written down by then");
2106    }
2107
2108    #[test]
2109    fn relabelling_keeps_the_account_and_its_park() {
2110        let mut s = State::default();
2111        s.upsert(account("wrong", Some(park("p"))));
2112        s.upsert(account("other", None));
2113
2114        assert_eq!(
2115            s.relabel(&claude("wrong"), "right").unwrap().email,
2116            "wrong@example.com"
2117        );
2118        assert!(s.get(&claude("wrong")).is_none());
2119        let renamed = s.get(&claude("right")).unwrap();
2120        assert_eq!(renamed.account_uuid, "wrong-uuid");
2121        assert_eq!(renamed.parked.as_ref().unwrap().service, "p");
2122        assert!(s.discarded.is_empty(), "nothing is deleted by a rename");
2123    }
2124
2125    /// The record names whose login is stored, not a label, so a rename has nothing in it
2126    /// to change.
2127    #[test]
2128    fn renaming_an_account_leaves_the_record_alone() {
2129        let mut s = State::default();
2130        s.upsert(account("wrong", None));
2131        let found = InUse::of(s.get(&claude("wrong")).unwrap(), "f", 100);
2132        s.identified(ProviderId::Claude, found.clone(), 100);
2133
2134        s.relabel(&claude("wrong"), "right").unwrap();
2135
2136        assert_eq!(s.in_use(ProviderId::Claude), Some(&found));
2137        assert_eq!(
2138            s.account_in_use(ProviderId::Claude)
2139                .map(|a| a.label.as_str()),
2140            Some("right")
2141        );
2142    }
2143
2144    #[test]
2145    fn relabelling_refuses_a_taken_label_and_an_unknown_one() {
2146        let mut s = State::default();
2147        s.upsert(account("a", None));
2148        s.upsert(account("b", None));
2149        assert!(matches!(
2150            s.relabel(&claude("a"), "b"),
2151            Err(Error::LabelTaken { .. })
2152        ));
2153        assert!(matches!(
2154            s.relabel(&claude("nobody"), "c"),
2155            Err(Error::AccountUnknown { .. })
2156        ));
2157        assert_eq!(
2158            s.accounts
2159                .iter()
2160                .map(|a| a.label.as_str())
2161                .collect::<Vec<_>>(),
2162            ["a", "b"],
2163            "a refused rename changes nothing"
2164        );
2165        assert!(s.relabel(&claude("a"), "a").is_ok());
2166    }
2167
2168    /// Anthropic naming another account's login, where the account in use had nothing
2169    /// parked, means a sign-in outside Pitboard wrote over that account's only login.
2170    #[test]
2171    fn an_answer_naming_another_account_marks_the_account_whose_only_login_it_replaced() {
2172        let mut s = State::default();
2173        s.upsert(account("work", None));
2174        s.upsert(account("personal", None));
2175        s.upsert(account("spare", Some(park("p"))));
2176        let said = |s: &State, label: &str, login: &str, at: i64| {
2177            InUse::of(s.get(&claude(label)).unwrap(), login, at)
2178        };
2179        let work = said(&s, "work", "work-login", 100);
2180        s.identified(ProviderId::Claude, work, 100);
2181
2182        let personal = said(&s, "personal", "personal-login", 200);
2183        let identified = s.identified(ProviderId::Claude, personal.clone(), 200);
2184        assert!(identified.changed);
2185        assert_eq!(
2186            identified.replaced,
2187            Some(Replaced {
2188                key: claude("work")
2189            })
2190        );
2191        assert_eq!(s.get(&claude("work")).unwrap().replaced_at, Some(200));
2192        let now = s.get(&claude("personal")).unwrap();
2193        assert_eq!((now.last_used_at, now.replaced_at), (Some(200), None));
2194        assert_eq!(s.in_use(ProviderId::Claude), Some(&personal));
2195
2196        // The same account on another login: a renewal, or a sign-in to it again.
2197        let renewed = InUse {
2198            login: "personal-renewed".into(),
2199            known_at: 300,
2200            ..personal
2201        };
2202        let identified = s.identified(ProviderId::Claude, renewed.clone(), 300);
2203        assert!(identified.changed);
2204        assert_eq!(identified.replaced, None);
2205        assert_eq!(
2206            s.get(&claude("personal")).unwrap().last_used_at,
2207            Some(200),
2208            "still the account it was"
2209        );
2210
2211        let again = InUse {
2212            known_at: 400,
2213            ..renewed
2214        };
2215        assert!(!s.identified(ProviderId::Claude, again, 400).changed);
2216        assert_eq!(
2217            s.in_use(ProviderId::Claude).map(|r| r.known_at),
2218            Some(300),
2219            "known since it was first said"
2220        );
2221
2222        // An account that holds a parked login lost nothing when its login in use went.
2223        let spare = said(&s, "spare", "spare-login", 500);
2224        s.identified(ProviderId::Claude, spare, 500);
2225        let back = said(&s, "work", "work-login-2", 600);
2226        let identified = s.identified(ProviderId::Claude, back, 600);
2227        assert_eq!(identified.replaced, None);
2228        assert_eq!(s.get(&claude("spare")).unwrap().replaced_at, None);
2229        assert_eq!(
2230            s.get(&claude("work")).unwrap().replaced_at,
2231            None,
2232            "in use again"
2233        );
2234    }
2235
2236    /// An account signed in under two `CLAUDE_CONFIG_DIR`s keeps its login in the other when
2237    /// a sign-in to another account replaces it in this one, so it lost nothing. Once no
2238    /// slot's record names it, the login replaced was its only one.
2239    #[test]
2240    fn a_login_another_slot_still_holds_is_not_the_only_one() {
2241        let mut s = State::default();
2242        s.upsert(account("work", None));
2243        s.upsert(account("personal", None));
2244        let said = |s: &State, label: &str, login: &str, at: i64| {
2245            InUse::of(s.get(&claude(label)).unwrap(), login, at)
2246        };
2247        let other = |s: &mut State, record: InUse| {
2248            s.other_slots
2249                .entry(ProviderId::Claude.code().into())
2250                .or_default()
2251                .insert("Claude Code-credentials-other".into(), record);
2252        };
2253        let work_there = said(&s, "work", "work-there", 50);
2254        other(&mut s, work_there);
2255        let work = said(&s, "work", "work-login", 100);
2256        s.identified(ProviderId::Claude, work, 100);
2257
2258        let personal = said(&s, "personal", "personal-login", 200);
2259        let identified = s.identified(ProviderId::Claude, personal, 200);
2260
2261        assert_eq!(identified.replaced, None);
2262        assert_eq!(s.get(&claude("work")).unwrap().replaced_at, None);
2263
2264        let work = said(&s, "work", "work-login-2", 300);
2265        s.identified(ProviderId::Claude, work, 300);
2266        let personal_there = said(&s, "personal", "personal-there", 350);
2267        other(&mut s, personal_there);
2268        let personal = said(&s, "personal", "personal-login", 400);
2269        let identified = s.identified(ProviderId::Claude, personal, 400);
2270
2271        assert_eq!(
2272            identified.replaced,
2273            Some(Replaced {
2274                key: claude("work")
2275            })
2276        );
2277        assert_eq!(s.get(&claude("work")).unwrap().replaced_at, Some(400));
2278    }
2279
2280    #[test]
2281    fn parking_a_replaced_account_clears_it() {
2282        let mut s = State::default();
2283        s.upsert(account("work", None));
2284        s.upsert(account("personal", None));
2285        for (label, at) in [("work", 100), ("personal", 200)] {
2286            let found = InUse::of(s.get(&claude(label)).unwrap(), label, at);
2287            s.identified(ProviderId::Claude, found, at);
2288        }
2289        assert_eq!(s.get(&claude("work")).unwrap().replaced_at, Some(200));
2290
2291        s.park(&claude("work"), park("p"));
2292
2293        assert_eq!(s.get(&claude("work")).unwrap().replaced_at, None);
2294    }
2295
2296    #[test]
2297    fn removing_an_account_lists_its_park_for_deletion() {
2298        let mut s = State::default();
2299        s.upsert(account("work", Some(park("p"))));
2300        assert_eq!(s.remove(&claude("work")).unwrap().label, "work");
2301        assert!(s.accounts.is_empty());
2302        assert_eq!(discarded(&s), ["p"]);
2303    }
2304
2305    /// A park `repair` gave back may be another Pitboard's. Replaced by a newer one, or
2306    /// dropped with its account, it was never used here, so it is let go and not deleted.
2307    #[test]
2308    fn a_park_this_home_did_not_write_is_let_go_and_never_listed_for_deletion() {
2309        let mut s = State::default();
2310        s.upsert(account("work", None));
2311        s.upsert(account("home", None));
2312        s.park_foreign(&claude("work"), park("found"));
2313        s.park_foreign(&claude("home"), park("also-found"));
2314        assert_eq!(s.foreign, ["found", "also-found"]);
2315
2316        s.park(&claude("work"), park("ours"));
2317        assert_eq!(
2318            s.get(&claude("work"))
2319                .unwrap()
2320                .parked
2321                .as_ref()
2322                .unwrap()
2323                .service,
2324            "ours"
2325        );
2326        s.remove(&claude("home"));
2327        s.release("ours");
2328
2329        assert_eq!(discarded(&s), ["ours"], "only the park this home wrote");
2330        assert!(s.foreign.is_empty(), "and nothing here holds the others");
2331    }
2332
2333    /// Installed or renewed, a park has been used up whoever wrote it, and goes the way
2334    /// every used park goes.
2335    #[test]
2336    fn a_park_this_home_did_not_write_is_listed_once_it_is_used() {
2337        let mut s = State::default();
2338        s.upsert(account("work", None));
2339        s.park_foreign(&claude("work"), park("found"));
2340        s.discard("found", ParkKind::Vault);
2341        assert!(s.get(&claude("work")).unwrap().parked.is_none());
2342        assert_eq!(discarded(&s), ["found"]);
2343        assert!(s.foreign.is_empty());
2344    }
2345
2346    /// Schema 4 is new on this branch and gained the record of parks written elsewhere
2347    /// after it was first written, so a file without it has to load as one holding none.
2348    #[test]
2349    fn a_state_file_from_before_foreign_parks_were_recorded_still_loads() {
2350        let written = serde_json::json!({
2351            "schema": 4,
2352            "machine": machine_id(),
2353            "accounts": [{
2354                "label": "work", "account_uuid": "acc-1", "email": "a@b.c",
2355                "provider": "codex", "workspace_id": null, "plan": "pro",
2356                "parked": {
2357                    "service": "pitboard-park-acc-1-1789935600123",
2358                    "parked_at": 1_789_935_600,
2359                    "refresh_fingerprint": "abcd",
2360                    "access_expires_at": null,
2361                    "refresh_expires_at": null
2362                }
2363            }],
2364            "active": {}, "slot": {}, "discarded": []
2365        });
2366        let mut document = written;
2367        migrate(&mut document, std::path::Path::new("/tmp/state.json")).expect("current");
2368        let mut state: State = serde_json::from_value(document).expect("still parses");
2369        assert!(state.foreign.is_empty());
2370        state.remove(&Key::new(ProviderId::Codex, "work"));
2371        assert_eq!(
2372            discarded(&state),
2373            ["pitboard-park-acc-1-1789935600123"],
2374            "a park recorded before is this home's own, and goes as it always did"
2375        );
2376    }
2377
2378    #[test]
2379    fn a_park_is_restorable_until_its_login_expires() {
2380        let p = park("p");
2381        assert!(p.askable_at(199) && !p.askable_at(200));
2382        assert!(p.restorable_at(299) && !p.restorable_at(300));
2383        let unknown = Park {
2384            access_expires_at: None,
2385            refresh_expires_at: None,
2386            ..park("p")
2387        };
2388        assert!(
2389            unknown.restorable_at(i64::MAX),
2390            "no expiry recorded is not expired"
2391        );
2392    }
2393
2394    #[test]
2395    fn accounts_are_replaced_by_label_not_duplicated() {
2396        let mut s = State::default();
2397        s.upsert(account("work", None));
2398        s.upsert(Account {
2399            email: "d@e.f".into(),
2400            ..account("work", None)
2401        });
2402        assert_eq!(s.accounts.len(), 1);
2403        assert_eq!(s.get(&claude("work")).unwrap().email, "d@e.f");
2404    }
2405
2406    fn codex_account(label: &str) -> Account {
2407        Account {
2408            last_used_at: None,
2409            replaced_at: None,
2410            plan: None,
2411            label: label.into(),
2412            id: format!("codex-{label}-uuid"),
2413            account_uuid: format!("codex-{label}-uuid"),
2414            email: format!("{label}@openai.example"),
2415            detail: Detail::Codex { workspace_id: None },
2416            parked: None,
2417        }
2418    }
2419
2420    /// Two tools, one label. Every lookup used to take the label alone and return the
2421    /// first match, so `pitboard use codex/work` could park and install Claude Code's
2422    /// `work` instead, and enrolling Codex's `work` replaced Claude Code's outright.
2423    #[test]
2424    fn two_tools_can_each_have_an_account_of_the_same_name() {
2425        let mut s = State::default();
2426        s.upsert(account("work", Some(park("claude-park"))));
2427        s.upsert(codex_account("work"));
2428        assert_eq!(
2429            s.accounts.len(),
2430            2,
2431            "the second is added, not a replacement"
2432        );
2433
2434        let codex = Key::new(ProviderId::Codex, "work");
2435        assert_eq!(s.get(&codex).unwrap().provider(), ProviderId::Codex);
2436        assert_eq!(
2437            s.get(&claude("work")).unwrap().provider(),
2438            ProviderId::Claude
2439        );
2440
2441        s.park(&codex, park("codex-park"));
2442        assert_eq!(
2443            s.get(&claude("work"))
2444                .unwrap()
2445                .parked
2446                .as_ref()
2447                .unwrap()
2448                .service,
2449            "claude-park",
2450            "parking one tool's account leaves the other's alone"
2451        );
2452
2453        s.remove(&codex);
2454        assert!(s.get(&claude("work")).is_some(), "and so does dropping it");
2455        assert_eq!(discarded(&s), ["codex-park"]);
2456    }
2457
2458    /// A label is unique within a tool, so renaming into a name only another tool uses is
2459    /// not a clash.
2460    #[test]
2461    fn a_rename_clashes_only_within_its_own_tool() {
2462        let mut s = State::default();
2463        s.upsert(account("personal", None));
2464        s.upsert(codex_account("work"));
2465        assert!(s.relabel(&claude("personal"), "work").is_ok());
2466        assert_eq!(
2467            s.get(&claude("work")).unwrap().email,
2468            "personal@example.com"
2469        );
2470    }
2471
2472    /// Forgetting an account leaves the login its tool has stored where it was, and the
2473    /// record says whose it is by owner, so another account given the same label is not in
2474    /// use, and the same account enrolled again is.
2475    #[test]
2476    fn removing_the_account_in_use_leaves_whose_login_is_stored() {
2477        let mut s = State::default();
2478        s.upsert(account("work", None));
2479        s.upsert(codex_account("work"));
2480        let codex = Key::new(ProviderId::Codex, "work");
2481        let found = InUse::of(s.get(&codex).unwrap(), "f", 100);
2482        s.identified(ProviderId::Codex, found.clone(), 100);
2483
2484        let forgotten = s.remove(&codex).expect("enrolled");
2485        assert_eq!(s.in_use(ProviderId::Codex), Some(&found));
2486        assert!(s.account_in_use(ProviderId::Codex).is_none());
2487
2488        s.upsert(Account {
2489            id: "codex-other-uuid".into(),
2490            account_uuid: "codex-other-uuid".into(),
2491            ..codex_account("work")
2492        });
2493        assert!(
2494            s.account_in_use(ProviderId::Codex).is_none(),
2495            "a label is not who is in use"
2496        );
2497        s.upsert(forgotten);
2498        assert_eq!(
2499            s.account_in_use(ProviderId::Codex).map(Account::key),
2500            Some(codex)
2501        );
2502    }
2503
2504    /// A bare name for an account whose label another tool shares would be refused as
2505    /// ambiguous, so the name every message suggests is qualified exactly then.
2506    #[test]
2507    fn a_name_is_qualified_where_a_bare_one_would_be_ambiguous() {
2508        let mut s = State::default();
2509        s.upsert(account("work", None));
2510        s.upsert(account("personal", None));
2511        s.upsert(codex_account("work"));
2512        assert_eq!(s.typed(&claude("work")), "claude/work");
2513        assert_eq!(s.typed(&Key::new(ProviderId::Codex, "work")), "codex/work");
2514        assert_eq!(s.typed(&claude("personal")), "personal");
2515    }
2516
2517    #[test]
2518    fn a_key_reads_the_way_it_would_be_typed() {
2519        assert_eq!(claude("work").to_string(), "work");
2520        assert_eq!(
2521            Key::new(ProviderId::Codex, "work").to_string(),
2522            "codex/work"
2523        );
2524        assert_eq!(claude("work").qualified(), "claude/work");
2525    }
2526}