1use 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 Folder,
29}
30
31impl ParkKind {
32 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 #[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub account: Option<String>,
68 #[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#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
83pub struct Moving {
84 pub half: Half,
85 pub started_at: i64,
86 pub id: String,
88 pub label: String,
89 pub park: String,
91 pub login: String,
92 pub items: Vec<String>,
94 pub keys: BTreeMap<String, String>,
96 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
98 pub asides: BTreeMap<String, SetAside>,
99 pub slot: String,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub awaited: Option<Awaited>,
103}
104
105#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
107pub struct Awaited {
108 pub since: i64,
109 pub label: String,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub account: Option<String>,
114 pub put_back: String,
116}
117
118#[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 #[serde(rename = "refresh_fingerprint")]
129 pub login: String,
130 pub access_expires_at: Option<i64>,
132 pub refresh_expires_at: Option<i64>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub session_expires_at: Option<i64>,
137}
138
139#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
142pub(crate) struct Renewing {
143 pub(crate) tool: ProviderId,
144 pub(crate) slot: String,
146 pub(crate) store: String,
148 pub(crate) sent: String,
150 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#[derive(Serialize, Deserialize, Debug, Clone)]
171#[serde(tag = "provider", rename_all = "snake_case")]
172pub(crate) enum Detail {
173 Claude {
174 organization_uuid: String,
175 oauth_account: Value,
178 },
179 Codex {
180 #[serde(default)]
182 workspace_id: Option<String>,
183 },
184 Desktop {},
185}
186
187#[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 pub id: String,
201 pub account_uuid: String,
204 pub email: String,
205 pub parked: Option<Park>,
206 #[serde(default)]
217 pub last_used_at: Option<i64>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub replaced_at: Option<i64>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub plan: Option<String>,
228 #[serde(flatten)]
230 pub(crate) detail: Detail,
231}
232
233#[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 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 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 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 pub(crate) fn owned_by(&self, owner: &Owner) -> bool {
315 self.holds(&owner.identity())
316 }
317
318 pub(crate) fn holds(&self, identity: &Identity) -> bool {
320 self.account_uuid == identity.account_uuid
321 && match &self.detail {
322 Detail::Claude {
324 organization_uuid, ..
325 } => *organization_uuid == identity.organization_uuid,
326 Detail::Codex { .. } => true,
328 Detail::Desktop {} => true,
330 }
331 }
332
333 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 #[serde(default)]
356 pub(crate) in_use: BTreeMap<String, InUse>,
357 #[serde(default)]
361 pub(crate) slot: BTreeMap<String, String>,
362 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
366 pub(crate) other_slots: BTreeMap<String, BTreeMap<String, InUse>>,
367 #[serde(default)]
370 pub discarded: Vec<Dropped>,
371 #[serde(default)]
378 pub(crate) foreign: Vec<String>,
379 #[serde(default, skip_serializing_if = "Vec::is_empty")]
385 pub(crate) from_file: Vec<String>,
386 #[serde(default, skip_serializing_if = "Vec::is_empty")]
389 pub(crate) renewing: Vec<Renewing>,
390 #[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 #[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 pub(crate) fn in_use(&self, which: ProviderId) -> Option<&InUse> {
423 self.in_use.get(which.code())
424 }
425
426 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 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 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 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 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 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 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 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 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 pub(crate) fn get(&self, key: &Key) -> Option<&Account> {
579 self.accounts.iter().find(|a| a.is(key))
580 }
581
582 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 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 pub(crate) fn id_of(&self, provider: ProviderId, owner: &Owner) -> String {
608 self.id_for(provider, &owner.identity())
609 }
610
611 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 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 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 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 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 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 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 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 pub(crate) fn is_foreign(&self, service: &str) -> bool {
714 self.foreign.iter().any(|listed| listed == service)
715 }
716
717 pub(crate) fn used_here(&mut self, service: &str) {
720 self.foreign.retain(|listed| listed != service);
721 }
722
723 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 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 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 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 pub(crate) fn is_from_file(&self, service: &str) -> bool {
775 self.from_file.iter().any(|listed| listed == service)
776 }
777
778 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 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 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 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 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 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 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
885pub(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
900pub(crate) const UNKNOWN_MACHINE: &str = "unknown";
902
903pub(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
916pub(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
948pub(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 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 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
997fn 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
1008fn migrate(document: &mut Value, path: &std::path::Path) -> Result<()> {
1014 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 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
1052fn 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
1080fn 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
1097fn 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
1135fn six_to_seven(document: &mut Value) {
1140 document["schema"] = serde_json::json!(7);
1141}
1142
1143fn 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}