1use crate::api::Owner;
9use crate::context::Context;
10use crate::error::{Error, Result};
11use crate::provider::ProviderId;
12use crate::service::Permit;
13use crate::{atomic, home};
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use std::collections::BTreeMap;
17use std::path::PathBuf;
18
19const SCHEMA: u32 = 5;
20
21#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
25pub struct Park {
26 pub service: String,
27 pub parked_at: i64,
28 pub refresh_fingerprint: String,
29 pub access_expires_at: Option<i64>,
31 pub refresh_expires_at: Option<i64>,
33}
34
35impl Park {
36 pub fn restorable_at(&self, now: i64) -> bool {
37 self.refresh_expires_at.is_none_or(|at| at > now)
38 }
39
40 pub fn askable_at(&self, now: i64) -> bool {
41 self.access_expires_at.is_none_or(|at| at > now)
42 }
43}
44
45#[derive(Serialize, Deserialize, Debug, Clone)]
52#[serde(tag = "provider", rename_all = "snake_case")]
53#[non_exhaustive]
54pub enum Detail {
55 Claude {
56 organization_uuid: String,
57 oauth_account: Value,
60 },
61 Codex {
62 #[serde(default)]
64 workspace_id: Option<String>,
65 #[serde(default)]
68 plan: Option<String>,
69 },
70}
71
72pub struct ClaudeDetail<'a> {
75 pub organization_uuid: &'a str,
76 pub oauth_account: &'a Value,
77}
78
79#[derive(Serialize, Deserialize, Debug, Clone)]
80pub struct Account {
81 pub label: String,
82 pub id: String,
85 pub account_uuid: String,
88 pub email: String,
89 pub parked: Option<Park>,
90 #[serde(default)]
100 pub last_used_at: Option<i64>,
101 #[serde(flatten)]
103 pub detail: Detail,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
113pub struct Key {
114 pub provider: ProviderId,
115 pub label: String,
116}
117
118impl Key {
119 pub fn new(provider: ProviderId, label: impl Into<String>) -> Key {
120 Key {
121 provider,
122 label: label.into(),
123 }
124 }
125
126 pub fn typed(&self) -> String {
130 if self.provider == crate::label::DEFAULT {
131 self.label.clone()
132 } else {
133 self.qualified()
134 }
135 }
136
137 pub fn qualified(&self) -> String {
139 format!(
140 "{}{}{}",
141 self.provider.code(),
142 crate::label::SEPARATOR,
143 self.label
144 )
145 }
146}
147
148impl std::fmt::Display for Key {
149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 f.write_str(&self.typed())
151 }
152}
153
154impl Account {
155 pub fn key(&self) -> Key {
156 Key::new(self.provider(), self.label.clone())
157 }
158
159 pub fn is(&self, key: &Key) -> bool {
160 self.label == key.label && self.provider() == key.provider
161 }
162
163 pub fn provider(&self) -> ProviderId {
164 match self.detail {
165 Detail::Claude { .. } => ProviderId::Claude,
166 Detail::Codex { .. } => ProviderId::Codex,
167 }
168 }
169
170 pub fn owned_by(&self, owner: &Owner) -> bool {
172 self.account_uuid == owner.account_uuid
173 && match &self.detail {
174 Detail::Claude {
176 organization_uuid, ..
177 } => *organization_uuid == owner.organization_uuid,
178 Detail::Codex { .. } => true,
180 }
181 }
182
183 pub fn claude(&self) -> Option<ClaudeDetail<'_>> {
185 match &self.detail {
186 Detail::Claude {
187 organization_uuid,
188 oauth_account,
189 } => Some(ClaudeDetail {
190 organization_uuid,
191 oauth_account,
192 }),
193 Detail::Codex { .. } => None,
194 }
195 }
196}
197
198#[derive(Serialize, Deserialize, Debug, Clone)]
199pub struct State {
200 pub schema: u32,
201 pub machine: String,
202 pub accounts: Vec<Account>,
203 #[serde(default)]
209 pub active: BTreeMap<String, String>,
210 #[serde(default)]
214 pub slot: BTreeMap<String, String>,
215 #[serde(default)]
218 pub discarded: Vec<String>,
219 #[serde(default)]
226 pub foreign: Vec<String>,
227}
228
229impl Default for State {
230 fn default() -> Self {
231 State {
232 schema: SCHEMA,
233 machine: machine_id(),
234 accounts: Vec::new(),
235 active: BTreeMap::new(),
236 slot: BTreeMap::new(),
237 discarded: Vec::new(),
238 foreign: Vec::new(),
239 }
240 }
241}
242
243impl State {
244 pub fn active_for(&self, provider: ProviderId) -> Option<&str> {
246 self.active.get(provider.code()).map(String::as_str)
247 }
248
249 pub fn set_active(&mut self, provider: ProviderId, label: Option<String>) {
250 match label {
251 Some(label) => self.active.insert(provider.code().to_string(), label),
252 None => self.active.remove(provider.code()),
253 };
254 }
255
256 pub fn slot_for(&self, provider: ProviderId) -> Option<&str> {
258 self.slot.get(provider.code()).map(String::as_str)
259 }
260
261 pub fn set_slot(&mut self, provider: ProviderId, slot: String) {
262 self.slot.insert(provider.code().to_string(), slot);
263 }
264
265 pub fn typed(&self, key: &Key) -> String {
272 let shared = self
273 .accounts
274 .iter()
275 .any(|a| a.label == key.label && a.provider() != key.provider);
276 if shared { key.qualified() } else { key.typed() }
277 }
278
279 pub fn labels(&self, provider: ProviderId) -> crate::error::Enrolled {
282 crate::error::Enrolled(
283 self.accounts
284 .iter()
285 .filter(|a| a.provider() == provider)
286 .map(|a| a.label.clone())
287 .collect(),
288 )
289 }
290
291 pub fn names(&self, service: &str) -> bool {
294 self.accounts
295 .iter()
296 .filter_map(|a| a.parked.as_ref())
297 .any(|p| p.service == service)
298 || self.discarded.iter().any(|s| s == service)
299 }
300
301 pub fn get(&self, key: &Key) -> Option<&Account> {
306 self.accounts.iter().find(|a| a.is(key))
307 }
308
309 pub fn account_of(&self, provider: ProviderId, owner: &Owner) -> Option<&Account> {
311 self.accounts
312 .iter()
313 .find(|a| a.provider() == provider && a.owned_by(owner))
314 }
315
316 pub fn id_of(&self, provider: ProviderId, owner: &Owner) -> String {
319 self.account_of(provider, owner)
320 .map_or_else(|| new_id(provider, owner), |a| a.id.clone())
321 }
322
323 pub fn by_id(&self, provider: ProviderId, id: &str) -> Option<&Account> {
325 self.accounts
326 .iter()
327 .find(|a| a.provider() == provider && a.id == id)
328 }
329
330 pub fn owner_of_park(&self, id: &str) -> Option<&Account> {
336 self.accounts.iter().find(|a| a.id == id)
337 }
338
339 fn get_mut(&mut self, key: &Key) -> Option<&mut Account> {
340 self.accounts.iter_mut().find(|a| a.is(key))
341 }
342
343 pub fn park(&mut self, key: &Key, park: Park) {
346 let service = park.service.clone();
347 if let Some(previous) = self
348 .get_mut(key)
349 .and_then(|account| account.parked.replace(park))
350 && previous.service != service
351 {
352 self.release(&previous.service);
353 }
354 }
355
356 pub fn park_foreign(&mut self, key: &Key, park: Park) {
359 let service = park.service.clone();
360 self.park(key, park);
361 if self.references(&service) && !self.is_foreign(&service) {
362 self.foreign.push(service);
363 }
364 }
365
366 pub fn is_foreign(&self, service: &str) -> bool {
368 self.foreign.iter().any(|listed| listed == service)
369 }
370
371 pub fn used_here(&mut self, service: &str) {
374 self.foreign.retain(|listed| listed != service);
375 }
376
377 pub fn discard(&mut self, service: &str) {
382 self.let_go(service);
383 if !self.discarded.iter().any(|listed| listed == service) {
384 self.discarded.push(service.to_string());
385 }
386 }
387
388 pub fn release(&mut self, service: &str) {
394 if self.is_foreign(service) {
395 self.let_go(service);
396 } else {
397 self.discard(service);
398 }
399 }
400
401 fn let_go(&mut self, service: &str) {
403 for account in &mut self.accounts {
404 account.parked.take_if(|p| p.service == service);
405 }
406 self.foreign.retain(|listed| listed != service);
407 }
408
409 pub fn references(&self, service: &str) -> bool {
410 self.accounts
411 .iter()
412 .any(|a| a.parked.as_ref().is_some_and(|p| p.service == service))
413 }
414
415 pub fn used(&mut self, key: &Key, at: i64) {
417 if let Some(account) = self.get_mut(key) {
418 account.last_used_at = Some(at);
419 }
420 }
421
422 pub fn upsert(&mut self, account: Account) {
424 match self.get_mut(&account.key()) {
425 Some(existing) => *existing = account,
426 None => self.accounts.push(account),
427 }
428 }
429
430 pub fn relabel(&mut self, from: &Key, to: &str) -> Result<&Account> {
433 let target = Key::new(from.provider, to);
434 if from.label != to
435 && let Some(taken) = self.get(&target)
436 {
437 let renamed = self.get(from).map(|a| a.email.as_str());
438 return Err(Error::LabelTaken {
439 label: target.typed(),
440 who: crate::words::login(
441 from.provider,
442 &taken.email,
443 renamed == Some(taken.email.as_str()),
444 ),
445 });
446 }
447 if self.active_for(from.provider) == Some(from.label.as_str()) {
448 self.set_active(from.provider, Some(to.to_string()));
449 }
450 let enrolled = self.labels(from.provider);
451 let account = self.get_mut(from).ok_or_else(|| Error::AccountUnknown {
452 label: from.typed(),
453 enrolled,
454 })?;
455 account.label = to.to_string();
456 Ok(account)
457 }
458
459 pub fn remove(&mut self, key: &Key) -> Option<Account> {
461 let index = self.accounts.iter().position(|a| a.is(key))?;
462 let account = self.accounts.remove(index);
463 if let Some(park) = &account.parked {
464 self.release(&park.service);
465 }
466 if self.active_for(key.provider) == Some(key.label.as_str()) {
467 self.set_active(key.provider, None);
468 }
469 Some(account)
470 }
471}
472
473pub fn new_id(provider: ProviderId, owner: &Owner) -> String {
477 match provider {
478 ProviderId::Claude => format!("{}_{}", owner.account_uuid, owner.organization_uuid),
479 ProviderId::Codex => owner.account_uuid.clone(),
480 }
481}
482
483pub fn machine_id() -> String {
485 use sha2::{Digest, Sha256};
486 match machine_uid::get() {
487 Ok(raw) => hex::encode(Sha256::digest(raw.as_bytes())),
488 Err(_) => String::from("unknown"),
489 }
490}
491
492fn file(ctx: &Context) -> PathBuf {
493 home::dir(ctx).join("state.json")
494}
495
496pub fn changed_at(ctx: &Context) -> i64 {
508 std::fs::metadata(file(ctx))
509 .and_then(|m| m.modified())
510 .ok()
511 .and_then(|at| at.duration_since(std::time::UNIX_EPOCH).ok())
512 .map_or(0, |since| {
513 i64::try_from(since.as_secs()).unwrap_or(i64::MAX)
514 })
515}
516
517pub fn load(ctx: &Context) -> Result<State> {
518 let (state, here) = load_any_machine(ctx)?;
519 if !here {
520 return Err(Error::StateWrongMachine { path: file(ctx) });
521 }
522 Ok(state)
523}
524
525pub(crate) fn load_any_machine(ctx: &Context) -> Result<(State, bool)> {
534 crate::release::check()?;
535 let path = file(ctx);
536 home::check_absolute(ctx)?;
537 home::check_location(&home::dir(ctx))?;
538 let raw = match std::fs::read_to_string(&path) {
539 Ok(s) => s,
540 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((State::default(), true)),
541 Err(source) => return Err(Error::StateUnreadable { path, source }),
542 };
543 let mut document: serde_json::Value =
544 serde_json::from_str(&raw).map_err(|source| Error::StateCorrupt {
545 path: path.clone(),
546 source,
547 })?;
548 migrate(&mut document, &path)?;
549 if let Some(unknown) = unknown_tool(&document) {
553 return Err(Error::StateNamesUnknownTool {
554 path: path.clone(),
555 tool: unknown,
556 });
557 }
558 let state: State = serde_json::from_value(document).map_err(|source| Error::StateCorrupt {
559 path: path.clone(),
560 source,
561 })?;
562 let here = state.machine == machine_id();
563 let mut state = state;
564 for &tool in ProviderId::ALL {
569 let slot = crate::provider::of(tool).slot(ctx);
570 if state
571 .slot_for(tool)
572 .is_some_and(|recorded| recorded != slot)
573 {
574 state.set_active(tool, None);
575 }
576 }
577 Ok((state, here))
578}
579
580fn unknown_tool(document: &Value) -> Option<String> {
582 document
583 .get("accounts")?
584 .as_array()?
585 .iter()
586 .filter_map(|account| account.get("provider")?.as_str())
587 .find(|code| ProviderId::parse(code).is_none())
588 .map(str::to_owned)
589}
590
591fn migrate(document: &mut serde_json::Value, path: &std::path::Path) -> Result<()> {
597 let found = document
600 .get("schema")
601 .and_then(serde_json::Value::as_u64)
602 .unwrap_or_default() as u32;
603 match found {
604 SCHEMA => Ok(()),
605 3 | 4 => {
606 if found == 3 {
607 three_to_four(document);
608 }
609 four_to_five(document);
610 Ok(())
611 }
612 0..3 => Err(Error::StateVersionUnknown {
614 path: path.to_path_buf(),
615 found,
616 }),
617 _ => Err(Error::StateFromNewerVersion {
618 path: path.to_path_buf(),
619 found,
620 expected: SCHEMA,
621 }),
622 }
623}
624
625fn three_to_four(document: &mut serde_json::Value) {
635 let claude = serde_json::Value::from(ProviderId::Claude.code());
636 if let Some(accounts) = document.get_mut("accounts").and_then(Value::as_array_mut) {
637 for account in accounts {
638 if let Some(fields) = account.as_object_mut() {
639 fields.insert("provider".into(), claude.clone());
640 }
641 }
642 }
643 for singular in ["active", "slot"] {
644 let was = document.get(singular).cloned().unwrap_or(Value::Null);
645 document[singular] = match was {
646 Value::String(label) => serde_json::json!({ ProviderId::Claude.code(): label }),
647 _ => serde_json::json!({}),
648 };
649 }
650 document["schema"] = serde_json::json!(4);
651}
652
653fn four_to_five(document: &mut serde_json::Value) {
658 if let Some(accounts) = document.get_mut("accounts").and_then(Value::as_array_mut) {
659 for account in accounts {
660 if let Some(fields) = account.as_object_mut()
661 && let Some(id) = fields.get("account_uuid").cloned()
662 {
663 fields.insert("id".into(), id);
664 }
665 }
666 }
667 document["schema"] = serde_json::json!(SCHEMA);
668}
669
670pub(crate) fn save(ctx: &Context, permit: Permit, state: &State) -> Result<()> {
671 home::check_absolute(ctx)?;
672 home::check_location(&home::dir(ctx))?;
673 let mut state = state.clone();
674 for &tool in ProviderId::ALL {
675 state.set_slot(tool, crate::provider::of(tool).slot(ctx));
676 }
677 let state = &state;
678 let path = file(ctx);
679 let write = |source| Error::StateWriteFailed {
680 path: path.clone(),
681 source,
682 };
683 home::ensure(ctx, permit).map_err(write)?;
684 let body = serde_json::to_string_pretty(state).expect("State is always serialisable");
685 atomic::write(permit, &path, body.as_bytes(), atomic::Perms::Secret).map_err(write)
686}
687
688#[cfg(test)]
689mod tests {
690 #[test]
694 fn what_was_active_in_another_slot_is_not_claimed_here() {
695 let home = std::env::temp_dir().join(format!("pitboard-slots-{}", std::process::id()));
696 let _ = std::fs::remove_dir_all(&home);
697 let here = Context::new(home.clone()).with_pitboard_home(home.clone());
698 let elsewhere = here
699 .clone()
700 .with_claude_config_dir("/somewhere/else".into());
701
702 let mut state = State::default();
703 state.accounts.push(Account {
704 last_used_at: None,
705 label: "work".into(),
706 id: "acc".into(),
707 account_uuid: "acc".into(),
708 email: "a@b.c".into(),
709 detail: Detail::Claude {
710 organization_uuid: "org".into(),
711 oauth_account: serde_json::json!({}),
712 },
713 parked: None,
714 });
715 state.set_active(ProviderId::Claude, Some("work".into()));
716 save(&here, Permit::for_a_test(), &state).expect("saved");
717
718 assert_eq!(
719 load(&here).unwrap().active_for(ProviderId::Claude),
720 Some("work")
721 );
722 assert_eq!(
723 load(&elsewhere).unwrap().active_for(ProviderId::Claude),
724 None,
725 "another slot's record of what is in use is not this slot's"
726 );
727 let _ = std::fs::remove_dir_all(&home);
728 }
729
730 #[test]
734 fn another_codex_home_is_another_codex_slot() {
735 let home = std::env::temp_dir().join(format!(
736 "pitboard-codex-slots-{}-{:?}",
737 std::process::id(),
738 std::thread::current().id()
739 ));
740 let _ = std::fs::remove_dir_all(&home);
741 let here = Context::new(home.clone()).with_pitboard_home(home.clone());
742 let mut state = State::default();
743 state.set_active(ProviderId::Claude, Some("work".into()));
744 state.set_active(ProviderId::Codex, Some("work".into()));
745 save(&here, Permit::for_a_test(), &state).expect("saved");
746
747 let moved = here.clone().with_codex_home("/somewhere/else".into());
748 let loaded = load(&moved).unwrap();
749 assert_eq!(loaded.active_for(ProviderId::Codex), None);
750 assert_eq!(
751 loaded.active_for(ProviderId::Claude),
752 Some("work"),
753 "Claude Code's slot did not move"
754 );
755 assert_eq!(
756 load(&here).unwrap().active_for(ProviderId::Codex),
757 Some("work")
758 );
759 let _ = std::fs::remove_dir_all(&home);
760 }
761
762 #[test]
765 fn the_format_shipped_in_0_1_x_still_loads() {
766 let written = serde_json::json!({
767 "schema": 3,
768 "machine": machine_id(),
769 "accounts": [{
770 "label": "work",
771 "account_uuid": "acc-1",
772 "email": "a@b.c",
773 "organization_uuid": "org-1",
774 "oauth_account": {"emailAddress": "a@b.c"},
775 "parked": {
776 "service": "pitboard-park-acc-1-1789935600123",
777 "parked_at": 1_789_935_600,
778 "refresh_fingerprint": "abcd",
779 "access_expires_at": 1_789_999_999,
780 "refresh_expires_at": 1_792_000_000
781 }
782 }],
783 "active": "work",
784 "discarded": []
785 });
786 let mut document = written.clone();
787 migrate(&mut document, std::path::Path::new("/tmp/state.json")).expect("still current");
788 let state: State = serde_json::from_value(document).expect("still parses");
789 assert_eq!(state.get(&claude("work")).unwrap().email, "a@b.c");
790 assert_eq!(state.get(&claude("work")).unwrap().id, "acc-1");
791 assert_eq!(state.active_for(ProviderId::Claude), Some("work"));
792 }
793
794 #[test]
800 fn migrating_a_current_file_is_a_no_op() {
801 let mut once = serde_json::json!({
802 "schema": 3,
803 "machine": machine_id(),
804 "accounts": [{
805 "label": "work", "account_uuid": "acc-1", "email": "a@b.c",
806 "organization_uuid": "org-1", "oauth_account": {}, "parked": null
807 }],
808 "active": "work",
809 "slot": "Claude Code-credentials",
810 "discarded": []
811 });
812 migrate(&mut once, std::path::Path::new("/tmp/state.json")).expect("3 to 5");
813 let mut twice = once.clone();
814 migrate(&mut twice, std::path::Path::new("/tmp/state.json")).expect("5 is current");
815 assert_eq!(once, twice, "a second migration must change nothing");
816 assert_eq!(once["active"], serde_json::json!({"claude": "work"}));
817 assert_eq!(
818 once["slot"],
819 serde_json::json!({"claude": "Claude Code-credentials"})
820 );
821 assert_eq!(once["accounts"][0]["provider"], "claude");
822 assert_eq!(once["accounts"][0]["id"], "acc-1");
823 assert_eq!(once["schema"], SCHEMA);
824 }
825
826 #[test]
829 fn a_file_that_never_switched_migrates_to_no_active_account() {
830 let mut document = serde_json::json!({
831 "schema": 3, "machine": machine_id(), "accounts": [], "discarded": []
832 });
833 migrate(&mut document, std::path::Path::new("/tmp/state.json")).expect("3 to 4");
834 let state: State = serde_json::from_value(document).expect("parses");
835 assert_eq!(state.active_for(ProviderId::Claude), None);
836 assert!(state.active.is_empty() && state.slot.is_empty());
837 }
838
839 #[test]
842 fn the_format_0_8_0_writes_keeps_every_account_where_it_is_filed() {
843 let mut document = serde_json::json!({
844 "schema": 4,
845 "machine": machine_id(),
846 "accounts": [
847 {
848 "label": "work",
849 "account_uuid": "acc-1",
850 "email": "a@b.c",
851 "parked": {
852 "service": "pitboard-park-acc-1-1789935600123",
853 "parked_at": 1_789_935_600,
854 "refresh_fingerprint": "abcd",
855 "access_expires_at": 1_789_999_999,
856 "refresh_expires_at": 1_792_000_000
857 },
858 "last_used_at": 1_789_935_000,
859 "provider": "claude",
860 "organization_uuid": "org-1",
861 "oauth_account": {
862 "accountUuid": "acc-1",
863 "emailAddress": "a@b.c",
864 "organizationUuid": "org-1"
865 }
866 },
867 {
868 "label": "work",
869 "account_uuid": "team_user-1",
870 "email": "a@b.c",
871 "parked": null,
872 "last_used_at": null,
873 "provider": "codex",
874 "workspace_id": "team",
875 "plan": "team"
876 }
877 ],
878 "active": {"claude": "work", "codex": "work"},
879 "slot": {"claude": "Claude Code-credentials", "codex": "/home/a/.codex"},
880 "discarded": [],
881 "foreign": []
882 });
883 migrate(&mut document, std::path::Path::new("/tmp/state.json")).expect("4 to 5");
884 let state: State = serde_json::from_value(document).expect("parses");
885
886 let work = state.get(&claude("work")).unwrap();
887 assert_eq!(
888 (work.id.as_str(), work.account_uuid.as_str()),
889 ("acc-1", "acc-1")
890 );
891 let codex = state.get(&Key::new(ProviderId::Codex, "work")).unwrap();
892 assert_eq!(codex.id, "team_user-1");
893 assert_eq!(
894 state.owner_of_park("acc-1").map(Account::key),
895 Some(work.key())
896 );
897 let signed_in = Owner {
898 account_uuid: "acc-1".into(),
899 email: "a@b.c".into(),
900 organization_uuid: "org-1".into(),
901 };
902 assert_eq!(
903 state.id_of(ProviderId::Claude, &signed_in),
904 "acc-1",
905 "the login it was enrolled with is still found by its organisation"
906 );
907 }
908
909 #[test]
912 fn one_person_in_two_organisations_is_two_accounts() {
913 let in_org = |label: &str, id: &str, org: &str| Account {
914 last_used_at: None,
915 label: label.into(),
916 id: id.into(),
917 account_uuid: "acc".into(),
918 email: "a@b.c".into(),
919 detail: Detail::Claude {
920 organization_uuid: org.into(),
921 oauth_account: serde_json::json!({}),
922 },
923 parked: None,
924 };
925 let mut state = State::default();
926 state.upsert(in_org("work", "acc", "org-a"));
927 state.upsert(in_org("team", "acc_org-b", "org-b"));
928 let signed_in = |org: &str| Owner {
929 account_uuid: "acc".into(),
930 email: "a@b.c".into(),
931 organization_uuid: org.into(),
932 };
933 let label_of = |org: &str| {
934 state
935 .account_of(ProviderId::Claude, &signed_in(org))
936 .map(|a| a.label.as_str())
937 };
938
939 assert_eq!(label_of("org-a"), Some("work"));
940 assert_eq!(label_of("org-b"), Some("team"));
941 assert_eq!(label_of("org-c"), None);
942 assert_eq!(
943 state.id_of(ProviderId::Claude, &signed_in("org-c")),
944 "acc_org-c"
945 );
946 assert!(
947 state
948 .account_of(ProviderId::Codex, &signed_in("org-a"))
949 .is_none()
950 );
951 }
952
953 #[test]
957 fn a_providers_own_fields_sit_beside_the_shared_ones() {
958 let account = Account {
959 label: "work".into(),
960 id: "acc".into(),
961 account_uuid: "acc".into(),
962 email: "a@b.c".into(),
963 parked: None,
964 last_used_at: None,
965 detail: Detail::Claude {
966 organization_uuid: "org".into(),
967 oauth_account: serde_json::json!({"emailAddress": "a@b.c"}),
968 },
969 };
970 let written = serde_json::to_value(&account).expect("writes");
971 assert_eq!(written["provider"], "claude");
972 assert_eq!(written["organization_uuid"], "org");
973 assert_eq!(written["label"], "work");
974 assert!(
975 written.get("detail").is_none(),
976 "flattened, so there is no nested object: {written}"
977 );
978 let back: Account = serde_json::from_value(written).expect("reads back");
979 assert_eq!(back.provider(), ProviderId::Claude);
980 assert_eq!(back.claude().unwrap().organization_uuid, "org");
981 }
982
983 #[test]
987 fn a_changed_slot_clears_only_that_providers_record() {
988 let mut state = State::default();
989 state.set_active(ProviderId::Claude, Some("work".into()));
990 state.set_slot(ProviderId::Claude, "some-other-slot".into());
991 state
992 .active
993 .insert("pretend-other-provider".into(), "personal".into());
994
995 if state
997 .slot_for(ProviderId::Claude)
998 .is_some_and(|recorded| recorded != "Claude Code-credentials")
999 {
1000 state.set_active(ProviderId::Claude, None);
1001 }
1002
1003 assert_eq!(state.active_for(ProviderId::Claude), None);
1004 assert_eq!(
1005 state
1006 .active
1007 .get("pretend-other-provider")
1008 .map(String::as_str),
1009 Some("personal"),
1010 "another provider's record is not this provider's to clear"
1011 );
1012 }
1013
1014 #[test]
1017 fn an_account_of_an_unknown_tool_asks_for_an_upgrade_not_a_deletion() {
1018 let home = std::env::temp_dir().join(format!(
1019 "pitboard-unknown-tool-{}-{:?}",
1020 std::process::id(),
1021 std::thread::current().id()
1022 ));
1023 let _ = std::fs::remove_dir_all(&home);
1024 std::fs::create_dir_all(&home).unwrap();
1025 let ctx = Context::new(home.clone()).with_pitboard_home(home.clone());
1026 std::fs::write(
1027 home.join("state.json"),
1028 serde_json::json!({
1029 "schema": SCHEMA,
1030 "machine": machine_id(),
1031 "accounts": [{
1032 "label": "work", "account_uuid": "u", "email": "a@b.c",
1033 "parked": null, "provider": "somethingnew"
1034 }],
1035 "active": {}, "slot": {}, "discarded": []
1036 })
1037 .to_string(),
1038 )
1039 .unwrap();
1040 let err = load(&ctx).unwrap_err();
1041 assert_eq!(err.code(), "state_names_unknown_tool");
1042 assert!(err.to_string().contains("somethingnew"), "{err}");
1043 assert!(!err.to_string().contains("Delete"), "{err}");
1044 let _ = std::fs::remove_dir_all(&home);
1045 }
1046
1047 #[test]
1049 fn a_file_from_a_newer_pitboard_says_so() {
1050 let mut document = serde_json::json!({"schema": SCHEMA + 1});
1051 let err = migrate(&mut document, std::path::Path::new("/tmp/state.json")).unwrap_err();
1052 assert_eq!(err.code(), "state_from_newer_version");
1053 let said = err.to_string();
1054 assert!(said.contains("Update this Pitboard"), "{said}");
1055 assert!(
1056 !said.contains("brew") && !said.contains("cargo"),
1057 "it names no one way of installing Pitboard: {said}"
1058 );
1059 }
1060
1061 use super::*;
1062
1063 fn claude(label: &str) -> Key {
1064 Key::new(ProviderId::Claude, label)
1065 }
1066
1067 #[test]
1068 fn machine_id_is_stable_and_real() {
1069 let a = machine_id();
1070 assert_eq!(a, machine_id());
1071 assert_eq!(
1072 a.len(),
1073 64,
1074 "expected a sha256 of the platform id, got {a:?}"
1075 );
1076 assert_ne!(
1077 a, "unknown",
1078 "this platform should report a stable machine id"
1079 );
1080 }
1081
1082 fn park(service: &str) -> Park {
1083 Park {
1084 service: service.into(),
1085 parked_at: 100,
1086 refresh_fingerprint: "f".into(),
1087 access_expires_at: Some(200),
1088 refresh_expires_at: Some(300),
1089 }
1090 }
1091
1092 fn account(label: &str, parked: Option<Park>) -> Account {
1093 Account {
1094 last_used_at: None,
1095 label: label.into(),
1096 id: format!("{label}-uuid"),
1097 account_uuid: format!("{label}-uuid"),
1098 email: format!("{label}@example.com"),
1099 detail: Detail::Claude {
1100 organization_uuid: "o".into(),
1101 oauth_account: serde_json::json!({}),
1102 },
1103 parked,
1104 }
1105 }
1106
1107 #[test]
1108 fn a_new_park_discards_the_one_it_replaces() {
1109 let mut s = State::default();
1110 s.upsert(account("work", Some(park("old"))));
1111 s.park(&claude("work"), park("new"));
1112 assert_eq!(
1113 s.get(&claude("work"))
1114 .unwrap()
1115 .parked
1116 .as_ref()
1117 .unwrap()
1118 .service,
1119 "new"
1120 );
1121 assert_eq!(s.discarded, ["old"]);
1122 assert!(!s.references("old"));
1123 }
1124
1125 #[test]
1126 fn discarding_releases_whichever_account_held_it_and_lists_it_once() {
1127 let mut s = State::default();
1128 s.upsert(account("work", Some(park("current"))));
1129 s.discard("something-else");
1130 assert!(s.get(&claude("work")).unwrap().parked.is_some());
1131 s.discard("current");
1132 s.discard("current");
1133 assert!(s.get(&claude("work")).unwrap().parked.is_none());
1134 assert_eq!(s.discarded, ["something-else", "current"]);
1135 }
1136
1137 #[test]
1138 fn relabelling_keeps_the_account_its_park_and_whether_it_is_active() {
1139 let mut s = State::default();
1140 s.upsert(account("wrong", Some(park("p"))));
1141 s.upsert(account("other", None));
1142 s.set_active(ProviderId::Claude, Some("wrong".into()));
1143
1144 assert_eq!(
1145 s.relabel(&claude("wrong"), "right").unwrap().email,
1146 "wrong@example.com"
1147 );
1148 assert!(s.get(&claude("wrong")).is_none());
1149 let renamed = s.get(&claude("right")).unwrap();
1150 assert_eq!(renamed.account_uuid, "wrong-uuid");
1151 assert_eq!(renamed.parked.as_ref().unwrap().service, "p");
1152 assert_eq!(s.active_for(ProviderId::Claude), Some("right"));
1153 assert!(s.discarded.is_empty(), "nothing is deleted by a rename");
1154 }
1155
1156 #[test]
1157 fn relabelling_refuses_a_taken_label_and_an_unknown_one() {
1158 let mut s = State::default();
1159 s.upsert(account("a", None));
1160 s.upsert(account("b", None));
1161 s.set_active(ProviderId::Claude, Some("a".into()));
1162 assert!(matches!(
1163 s.relabel(&claude("a"), "b"),
1164 Err(Error::LabelTaken { .. })
1165 ));
1166 assert!(matches!(
1167 s.relabel(&claude("nobody"), "c"),
1168 Err(Error::AccountUnknown { .. })
1169 ));
1170 assert_eq!(
1171 s.active_for(ProviderId::Claude),
1172 Some("a"),
1173 "a refused rename changes nothing"
1174 );
1175 assert!(s.relabel(&claude("a"), "a").is_ok());
1176 }
1177
1178 #[test]
1179 fn removing_an_account_lists_its_park_for_deletion() {
1180 let mut s = State::default();
1181 s.upsert(account("work", Some(park("p"))));
1182 assert_eq!(s.remove(&claude("work")).unwrap().label, "work");
1183 assert!(s.accounts.is_empty());
1184 assert_eq!(s.discarded, ["p"]);
1185 }
1186
1187 #[test]
1190 fn a_park_this_home_did_not_write_is_let_go_and_never_listed_for_deletion() {
1191 let mut s = State::default();
1192 s.upsert(account("work", None));
1193 s.upsert(account("home", None));
1194 s.park_foreign(&claude("work"), park("found"));
1195 s.park_foreign(&claude("home"), park("also-found"));
1196 assert_eq!(s.foreign, ["found", "also-found"]);
1197
1198 s.park(&claude("work"), park("ours"));
1199 assert_eq!(
1200 s.get(&claude("work"))
1201 .unwrap()
1202 .parked
1203 .as_ref()
1204 .unwrap()
1205 .service,
1206 "ours"
1207 );
1208 s.remove(&claude("home"));
1209 s.release("ours");
1210
1211 assert_eq!(s.discarded, ["ours"], "only the park this home wrote");
1212 assert!(s.foreign.is_empty(), "and nothing here holds the others");
1213 }
1214
1215 #[test]
1218 fn a_park_this_home_did_not_write_is_listed_once_it_is_used() {
1219 let mut s = State::default();
1220 s.upsert(account("work", None));
1221 s.park_foreign(&claude("work"), park("found"));
1222 s.discard("found");
1223 assert!(s.get(&claude("work")).unwrap().parked.is_none());
1224 assert_eq!(s.discarded, ["found"]);
1225 assert!(s.foreign.is_empty());
1226 }
1227
1228 #[test]
1231 fn a_state_file_from_before_foreign_parks_were_recorded_still_loads() {
1232 let written = serde_json::json!({
1233 "schema": 4,
1234 "machine": machine_id(),
1235 "accounts": [{
1236 "label": "work", "account_uuid": "acc-1", "email": "a@b.c",
1237 "provider": "codex", "workspace_id": null, "plan": "pro",
1238 "parked": {
1239 "service": "pitboard-park-acc-1-1789935600123",
1240 "parked_at": 1_789_935_600,
1241 "refresh_fingerprint": "abcd",
1242 "access_expires_at": null,
1243 "refresh_expires_at": null
1244 }
1245 }],
1246 "active": {}, "slot": {}, "discarded": []
1247 });
1248 let mut document = written.clone();
1249 migrate(&mut document, std::path::Path::new("/tmp/state.json")).expect("current");
1250 let mut state: State = serde_json::from_value(document).expect("still parses");
1251 assert!(state.foreign.is_empty());
1252 state.remove(&Key::new(ProviderId::Codex, "work"));
1253 assert_eq!(
1254 state.discarded,
1255 ["pitboard-park-acc-1-1789935600123"],
1256 "a park recorded before is this home's own, and goes as it always did"
1257 );
1258 }
1259
1260 #[test]
1261 fn a_park_is_restorable_until_its_login_expires() {
1262 let p = park("p");
1263 assert!(p.askable_at(199) && !p.askable_at(200));
1264 assert!(p.restorable_at(299) && !p.restorable_at(300));
1265 let unknown = Park {
1266 access_expires_at: None,
1267 refresh_expires_at: None,
1268 ..park("p")
1269 };
1270 assert!(
1271 unknown.restorable_at(i64::MAX),
1272 "no expiry recorded is not expired"
1273 );
1274 }
1275
1276 #[test]
1277 fn accounts_are_replaced_by_label_not_duplicated() {
1278 let mut s = State::default();
1279 s.upsert(account("work", None));
1280 s.upsert(Account {
1281 email: "d@e.f".into(),
1282 ..account("work", None)
1283 });
1284 assert_eq!(s.accounts.len(), 1);
1285 assert_eq!(s.get(&claude("work")).unwrap().email, "d@e.f");
1286 }
1287
1288 fn codex_account(label: &str) -> Account {
1289 Account {
1290 last_used_at: None,
1291 label: label.into(),
1292 id: format!("codex-{label}-uuid"),
1293 account_uuid: format!("codex-{label}-uuid"),
1294 email: format!("{label}@openai.example"),
1295 detail: Detail::Codex {
1296 workspace_id: None,
1297 plan: None,
1298 },
1299 parked: None,
1300 }
1301 }
1302
1303 #[test]
1307 fn two_tools_can_each_have_an_account_of_the_same_name() {
1308 let mut s = State::default();
1309 s.upsert(account("work", Some(park("claude-park"))));
1310 s.upsert(codex_account("work"));
1311 assert_eq!(
1312 s.accounts.len(),
1313 2,
1314 "the second is added, not a replacement"
1315 );
1316
1317 let codex = Key::new(ProviderId::Codex, "work");
1318 assert_eq!(s.get(&codex).unwrap().provider(), ProviderId::Codex);
1319 assert_eq!(
1320 s.get(&claude("work")).unwrap().provider(),
1321 ProviderId::Claude
1322 );
1323
1324 s.park(&codex, park("codex-park"));
1325 assert_eq!(
1326 s.get(&claude("work"))
1327 .unwrap()
1328 .parked
1329 .as_ref()
1330 .unwrap()
1331 .service,
1332 "claude-park",
1333 "parking one tool's account leaves the other's alone"
1334 );
1335
1336 s.remove(&codex);
1337 assert!(s.get(&claude("work")).is_some(), "and so does dropping it");
1338 assert_eq!(s.discarded, ["codex-park"]);
1339 }
1340
1341 #[test]
1344 fn a_rename_clashes_only_within_its_own_tool() {
1345 let mut s = State::default();
1346 s.upsert(account("personal", None));
1347 s.upsert(codex_account("work"));
1348 assert!(s.relabel(&claude("personal"), "work").is_ok());
1349 assert_eq!(
1350 s.get(&claude("work")).unwrap().email,
1351 "personal@example.com"
1352 );
1353 }
1354
1355 #[test]
1358 fn removing_the_account_in_use_clears_that_tools_record_only() {
1359 let mut s = State::default();
1360 s.upsert(account("work", None));
1361 s.upsert(codex_account("work"));
1362 s.set_active(ProviderId::Claude, Some("work".into()));
1363 s.set_active(ProviderId::Codex, Some("work".into()));
1364 s.remove(&Key::new(ProviderId::Codex, "work"));
1365 assert_eq!(s.active_for(ProviderId::Codex), None);
1366 assert_eq!(s.active_for(ProviderId::Claude), Some("work"));
1367 }
1368
1369 #[test]
1372 fn a_name_is_qualified_where_a_bare_one_would_be_ambiguous() {
1373 let mut s = State::default();
1374 s.upsert(account("work", None));
1375 s.upsert(account("personal", None));
1376 s.upsert(codex_account("work"));
1377 assert_eq!(s.typed(&claude("work")), "claude/work");
1378 assert_eq!(s.typed(&Key::new(ProviderId::Codex, "work")), "codex/work");
1379 assert_eq!(s.typed(&claude("personal")), "personal");
1380 }
1381
1382 #[test]
1383 fn a_key_reads_the_way_it_would_be_typed() {
1384 assert_eq!(claude("work").to_string(), "work");
1385 assert_eq!(
1386 Key::new(ProviderId::Codex, "work").to_string(),
1387 "codex/work"
1388 );
1389 assert_eq!(claude("work").qualified(), "claude/work");
1390 }
1391}