1use crate::context::Context;
9use crate::error::{Error, Result};
10use crate::provider::ProviderId;
11use crate::{atomic, home};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::BTreeMap;
15use std::path::PathBuf;
16
17const SCHEMA: u32 = 4;
18
19#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
23pub struct Park {
24 pub service: String,
25 pub parked_at: i64,
26 pub refresh_fingerprint: String,
27 pub access_expires_at: Option<i64>,
29 pub refresh_expires_at: Option<i64>,
31}
32
33impl Park {
34 pub fn restorable_at(&self, now: i64) -> bool {
35 self.refresh_expires_at.is_none_or(|at| at > now)
36 }
37
38 pub fn askable_at(&self, now: i64) -> bool {
39 self.access_expires_at.is_none_or(|at| at > now)
40 }
41}
42
43#[derive(Serialize, Deserialize, Debug, Clone)]
50#[serde(tag = "provider", rename_all = "snake_case")]
51#[non_exhaustive]
52pub enum Detail {
53 Claude {
54 organization_uuid: String,
55 oauth_account: Value,
58 },
59 Codex {
60 #[serde(default)]
62 workspace_id: Option<String>,
63 #[serde(default)]
66 plan: Option<String>,
67 },
68}
69
70pub struct ClaudeDetail<'a> {
73 pub organization_uuid: &'a str,
74 pub oauth_account: &'a Value,
75}
76
77#[derive(Serialize, Deserialize, Debug, Clone)]
78pub struct Account {
79 pub label: String,
80 pub account_uuid: String,
81 pub email: String,
82 pub parked: Option<Park>,
83 #[serde(default)]
93 pub last_used_at: Option<i64>,
94 #[serde(flatten)]
96 pub detail: Detail,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
106pub struct Key {
107 pub provider: ProviderId,
108 pub label: String,
109}
110
111impl Key {
112 pub fn new(provider: ProviderId, label: impl Into<String>) -> Key {
113 Key {
114 provider,
115 label: label.into(),
116 }
117 }
118
119 pub fn typed(&self) -> String {
123 if self.provider == crate::label::DEFAULT {
124 self.label.clone()
125 } else {
126 self.qualified()
127 }
128 }
129
130 pub fn qualified(&self) -> String {
132 format!(
133 "{}{}{}",
134 self.provider.code(),
135 crate::label::SEPARATOR,
136 self.label
137 )
138 }
139}
140
141impl std::fmt::Display for Key {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 f.write_str(&self.typed())
144 }
145}
146
147impl Account {
148 pub fn key(&self) -> Key {
149 Key::new(self.provider(), self.label.clone())
150 }
151
152 pub fn is(&self, key: &Key) -> bool {
153 self.label == key.label && self.provider() == key.provider
154 }
155
156 pub fn provider(&self) -> ProviderId {
157 match self.detail {
158 Detail::Claude { .. } => ProviderId::Claude,
159 Detail::Codex { .. } => ProviderId::Codex,
160 }
161 }
162
163 pub fn claude(&self) -> Option<ClaudeDetail<'_>> {
165 match &self.detail {
166 Detail::Claude {
167 organization_uuid,
168 oauth_account,
169 } => Some(ClaudeDetail {
170 organization_uuid,
171 oauth_account,
172 }),
173 Detail::Codex { .. } => None,
174 }
175 }
176}
177
178#[derive(Serialize, Deserialize, Debug, Clone)]
179pub struct State {
180 pub schema: u32,
181 pub machine: String,
182 pub accounts: Vec<Account>,
183 #[serde(default)]
189 pub active: BTreeMap<String, String>,
190 #[serde(default)]
194 pub slot: BTreeMap<String, String>,
195 #[serde(default)]
198 pub discarded: Vec<String>,
199 #[serde(default)]
206 pub foreign: Vec<String>,
207}
208
209impl Default for State {
210 fn default() -> Self {
211 State {
212 schema: SCHEMA,
213 machine: machine_id(),
214 accounts: Vec::new(),
215 active: BTreeMap::new(),
216 slot: BTreeMap::new(),
217 discarded: Vec::new(),
218 foreign: Vec::new(),
219 }
220 }
221}
222
223impl State {
224 pub fn active_for(&self, provider: ProviderId) -> Option<&str> {
226 self.active.get(provider.code()).map(String::as_str)
227 }
228
229 pub fn set_active(&mut self, provider: ProviderId, label: Option<String>) {
230 match label {
231 Some(label) => self.active.insert(provider.code().to_string(), label),
232 None => self.active.remove(provider.code()),
233 };
234 }
235
236 pub fn slot_for(&self, provider: ProviderId) -> Option<&str> {
238 self.slot.get(provider.code()).map(String::as_str)
239 }
240
241 pub fn set_slot(&mut self, provider: ProviderId, slot: String) {
242 self.slot.insert(provider.code().to_string(), slot);
243 }
244
245 pub fn typed(&self, key: &Key) -> String {
252 let shared = self
253 .accounts
254 .iter()
255 .any(|a| a.label == key.label && a.provider() != key.provider);
256 if shared { key.qualified() } else { key.typed() }
257 }
258
259 pub fn labels(&self, provider: ProviderId) -> crate::error::Enrolled {
262 crate::error::Enrolled(
263 self.accounts
264 .iter()
265 .filter(|a| a.provider() == provider)
266 .map(|a| a.label.clone())
267 .collect(),
268 )
269 }
270
271 pub fn names(&self, service: &str) -> bool {
274 self.accounts
275 .iter()
276 .filter_map(|a| a.parked.as_ref())
277 .any(|p| p.service == service)
278 || self.discarded.iter().any(|s| s == service)
279 }
280
281 pub fn get(&self, key: &Key) -> Option<&Account> {
286 self.accounts.iter().find(|a| a.is(key))
287 }
288
289 pub fn by_uuid(&self, provider: ProviderId, uuid: &str) -> Option<&Account> {
291 self.accounts
292 .iter()
293 .find(|a| a.provider() == provider && a.account_uuid == uuid)
294 }
295
296 pub fn owner_of_park(&self, uuid: &str) -> Option<&Account> {
303 self.accounts.iter().find(|a| a.account_uuid == uuid)
304 }
305
306 fn get_mut(&mut self, key: &Key) -> Option<&mut Account> {
307 self.accounts.iter_mut().find(|a| a.is(key))
308 }
309
310 pub fn park(&mut self, key: &Key, park: Park) {
313 let service = park.service.clone();
314 if let Some(previous) = self
315 .get_mut(key)
316 .and_then(|account| account.parked.replace(park))
317 && previous.service != service
318 {
319 self.release(&previous.service);
320 }
321 }
322
323 pub fn park_foreign(&mut self, key: &Key, park: Park) {
326 let service = park.service.clone();
327 self.park(key, park);
328 if self.references(&service) && !self.is_foreign(&service) {
329 self.foreign.push(service);
330 }
331 }
332
333 pub fn is_foreign(&self, service: &str) -> bool {
335 self.foreign.iter().any(|listed| listed == service)
336 }
337
338 pub fn used_here(&mut self, service: &str) {
341 self.foreign.retain(|listed| listed != service);
342 }
343
344 pub fn discard(&mut self, service: &str) {
349 self.let_go(service);
350 if !self.discarded.iter().any(|listed| listed == service) {
351 self.discarded.push(service.to_string());
352 }
353 }
354
355 pub fn release(&mut self, service: &str) {
361 if self.is_foreign(service) {
362 self.let_go(service);
363 } else {
364 self.discard(service);
365 }
366 }
367
368 fn let_go(&mut self, service: &str) {
370 for account in &mut self.accounts {
371 account.parked.take_if(|p| p.service == service);
372 }
373 self.foreign.retain(|listed| listed != service);
374 }
375
376 pub fn references(&self, service: &str) -> bool {
377 self.accounts
378 .iter()
379 .any(|a| a.parked.as_ref().is_some_and(|p| p.service == service))
380 }
381
382 pub fn used(&mut self, key: &Key, at: i64) {
384 if let Some(account) = self.get_mut(key) {
385 account.last_used_at = Some(at);
386 }
387 }
388
389 pub fn upsert(&mut self, account: Account) {
391 match self.get_mut(&account.key()) {
392 Some(existing) => *existing = account,
393 None => self.accounts.push(account),
394 }
395 }
396
397 pub fn relabel(&mut self, from: &Key, to: &str) -> Result<&Account> {
400 let target = Key::new(from.provider, to);
401 if from.label != to
402 && let Some(taken) = self.get(&target)
403 {
404 return Err(Error::LabelTaken {
405 label: target.typed(),
406 email: taken.email.clone(),
407 });
408 }
409 if self.active_for(from.provider) == Some(from.label.as_str()) {
410 self.set_active(from.provider, Some(to.to_string()));
411 }
412 let enrolled = self.labels(from.provider);
413 let account = self.get_mut(from).ok_or_else(|| Error::AccountUnknown {
414 label: from.typed(),
415 enrolled,
416 })?;
417 account.label = to.to_string();
418 Ok(account)
419 }
420
421 pub fn remove(&mut self, key: &Key) -> Option<Account> {
423 let index = self.accounts.iter().position(|a| a.is(key))?;
424 let account = self.accounts.remove(index);
425 if let Some(park) = &account.parked {
426 self.release(&park.service);
427 }
428 if self.active_for(key.provider) == Some(key.label.as_str()) {
429 self.set_active(key.provider, None);
430 }
431 Some(account)
432 }
433}
434
435pub fn machine_id() -> String {
437 use sha2::{Digest, Sha256};
438 match machine_uid::get() {
439 Ok(raw) => hex::encode(Sha256::digest(raw.as_bytes())),
440 Err(_) => String::from("unknown"),
441 }
442}
443
444fn file(ctx: &Context) -> PathBuf {
445 home::dir(ctx).join("state.json")
446}
447
448pub fn changed_at(ctx: &Context) -> i64 {
460 std::fs::metadata(file(ctx))
461 .and_then(|m| m.modified())
462 .ok()
463 .and_then(|at| at.duration_since(std::time::UNIX_EPOCH).ok())
464 .map_or(0, |since| {
465 i64::try_from(since.as_secs()).unwrap_or(i64::MAX)
466 })
467}
468
469pub fn load(ctx: &Context) -> Result<State> {
470 let (state, here) = load_any_machine(ctx)?;
471 if !here {
472 return Err(Error::StateWrongMachine { path: file(ctx) });
473 }
474 Ok(state)
475}
476
477pub(crate) fn load_any_machine(ctx: &Context) -> Result<(State, bool)> {
483 let path = file(ctx);
484 home::check_location(&home::dir(ctx))?;
485 let raw = match std::fs::read_to_string(&path) {
486 Ok(s) => s,
487 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((State::default(), true)),
488 Err(source) => return Err(Error::StateUnreadable { path, source }),
489 };
490 let mut document: serde_json::Value =
491 serde_json::from_str(&raw).map_err(|source| Error::StateCorrupt {
492 path: path.clone(),
493 source,
494 })?;
495 migrate(&mut document, &path)?;
496 if let Some(unknown) = unknown_tool(&document) {
500 return Err(Error::StateNamesUnknownTool {
501 path: path.clone(),
502 tool: unknown,
503 });
504 }
505 let state: State = serde_json::from_value(document).map_err(|source| Error::StateCorrupt {
506 path: path.clone(),
507 source,
508 })?;
509 let here = state.machine == machine_id();
510 let mut state = state;
511 for &tool in ProviderId::ALL {
516 let slot = crate::provider::of(tool).slot(ctx);
517 if state
518 .slot_for(tool)
519 .is_some_and(|recorded| recorded != slot)
520 {
521 state.set_active(tool, None);
522 }
523 }
524 Ok((state, here))
525}
526
527fn unknown_tool(document: &Value) -> Option<String> {
529 document
530 .get("accounts")?
531 .as_array()?
532 .iter()
533 .filter_map(|account| account.get("provider")?.as_str())
534 .find(|code| ProviderId::parse(code).is_none())
535 .map(str::to_owned)
536}
537
538fn migrate(document: &mut serde_json::Value, path: &std::path::Path) -> Result<()> {
544 let found = document
547 .get("schema")
548 .and_then(serde_json::Value::as_u64)
549 .unwrap_or_default() as u32;
550 match found {
551 SCHEMA => Ok(()),
552 3 => {
553 three_to_four(document);
554 Ok(())
555 }
556 0..3 => Err(Error::StateVersionUnknown {
558 path: path.to_path_buf(),
559 found,
560 }),
561 _ => Err(Error::StateFromNewerVersion {
562 path: path.to_path_buf(),
563 found,
564 expected: SCHEMA,
565 }),
566 }
567}
568
569fn three_to_four(document: &mut serde_json::Value) {
579 let claude = serde_json::Value::from(ProviderId::Claude.code());
580 if let Some(accounts) = document.get_mut("accounts").and_then(Value::as_array_mut) {
581 for account in accounts {
582 if let Some(fields) = account.as_object_mut() {
583 fields.insert("provider".into(), claude.clone());
584 }
585 }
586 }
587 for singular in ["active", "slot"] {
588 let was = document.get(singular).cloned().unwrap_or(Value::Null);
589 document[singular] = match was {
590 Value::String(label) => serde_json::json!({ ProviderId::Claude.code(): label }),
591 _ => serde_json::json!({}),
592 };
593 }
594 document["schema"] = serde_json::json!(SCHEMA);
595}
596
597pub(crate) fn save(ctx: &Context, state: &State) -> Result<()> {
598 home::check_location(&home::dir(ctx))?;
599 let mut state = state.clone();
600 for &tool in ProviderId::ALL {
601 state.set_slot(tool, crate::provider::of(tool).slot(ctx));
602 }
603 let state = &state;
604 let path = file(ctx);
605 let write = |source| Error::StateWriteFailed {
606 path: path.clone(),
607 source,
608 };
609 home::ensure(ctx).map_err(write)?;
610 let body = serde_json::to_string_pretty(state).expect("State is always serialisable");
611 atomic::write(&path, body.as_bytes(), atomic::Perms::Secret).map_err(write)
612}
613
614#[cfg(test)]
615mod tests {
616 #[test]
620 fn what_was_active_in_another_slot_is_not_claimed_here() {
621 let home = std::env::temp_dir().join(format!("pitboard-slots-{}", std::process::id()));
622 let _ = std::fs::remove_dir_all(&home);
623 let here = Context::new(home.clone()).with_pitboard_home(home.clone());
624 let elsewhere = here
625 .clone()
626 .with_claude_config_dir("/somewhere/else".into());
627
628 let mut state = State::default();
629 state.accounts.push(Account {
630 last_used_at: None,
631 label: "work".into(),
632 account_uuid: "acc".into(),
633 email: "a@b.c".into(),
634 detail: Detail::Claude {
635 organization_uuid: "org".into(),
636 oauth_account: serde_json::json!({}),
637 },
638 parked: None,
639 });
640 state.set_active(ProviderId::Claude, Some("work".into()));
641 save(&here, &state).expect("saved");
642
643 assert_eq!(
644 load(&here).unwrap().active_for(ProviderId::Claude),
645 Some("work")
646 );
647 assert_eq!(
648 load(&elsewhere).unwrap().active_for(ProviderId::Claude),
649 None,
650 "another slot's record of what is in use is not this slot's"
651 );
652 let _ = std::fs::remove_dir_all(&home);
653 }
654
655 #[test]
659 fn another_codex_home_is_another_codex_slot() {
660 let home = std::env::temp_dir().join(format!(
661 "pitboard-codex-slots-{}-{:?}",
662 std::process::id(),
663 std::thread::current().id()
664 ));
665 let _ = std::fs::remove_dir_all(&home);
666 let here = Context::new(home.clone()).with_pitboard_home(home.clone());
667 let mut state = State::default();
668 state.set_active(ProviderId::Claude, Some("work".into()));
669 state.set_active(ProviderId::Codex, Some("work".into()));
670 save(&here, &state).expect("saved");
671
672 let moved = here.clone().with_codex_home("/somewhere/else".into());
673 let loaded = load(&moved).unwrap();
674 assert_eq!(loaded.active_for(ProviderId::Codex), None);
675 assert_eq!(
676 loaded.active_for(ProviderId::Claude),
677 Some("work"),
678 "Claude Code's slot did not move"
679 );
680 assert_eq!(
681 load(&here).unwrap().active_for(ProviderId::Codex),
682 Some("work")
683 );
684 let _ = std::fs::remove_dir_all(&home);
685 }
686
687 #[test]
690 fn the_format_shipped_in_0_1_x_still_loads() {
691 let written = serde_json::json!({
692 "schema": 3,
693 "machine": machine_id(),
694 "accounts": [{
695 "label": "work",
696 "account_uuid": "acc-1",
697 "email": "a@b.c",
698 "organization_uuid": "org-1",
699 "oauth_account": {"emailAddress": "a@b.c"},
700 "parked": {
701 "service": "pitboard-park-acc-1-1789935600123",
702 "parked_at": 1_789_935_600,
703 "refresh_fingerprint": "abcd",
704 "access_expires_at": 1_789_999_999,
705 "refresh_expires_at": 1_792_000_000
706 }
707 }],
708 "active": "work",
709 "discarded": []
710 });
711 let mut document = written.clone();
712 migrate(&mut document, std::path::Path::new("/tmp/state.json")).expect("still current");
713 let state: State = serde_json::from_value(document).expect("still parses");
714 assert_eq!(state.get(&claude("work")).unwrap().email, "a@b.c");
715 assert_eq!(state.active_for(ProviderId::Claude), Some("work"));
716 }
717
718 #[test]
724 fn migrating_a_current_file_is_a_no_op() {
725 let mut once = serde_json::json!({
726 "schema": 3,
727 "machine": machine_id(),
728 "accounts": [{
729 "label": "work", "account_uuid": "acc-1", "email": "a@b.c",
730 "organization_uuid": "org-1", "oauth_account": {}, "parked": null
731 }],
732 "active": "work",
733 "slot": "Claude Code-credentials",
734 "discarded": []
735 });
736 migrate(&mut once, std::path::Path::new("/tmp/state.json")).expect("3 to 4");
737 let mut twice = once.clone();
738 migrate(&mut twice, std::path::Path::new("/tmp/state.json")).expect("4 is current");
739 assert_eq!(once, twice, "a second migration must change nothing");
740 assert_eq!(once["active"], serde_json::json!({"claude": "work"}));
741 assert_eq!(
742 once["slot"],
743 serde_json::json!({"claude": "Claude Code-credentials"})
744 );
745 assert_eq!(once["accounts"][0]["provider"], "claude");
746 }
747
748 #[test]
751 fn a_file_that_never_switched_migrates_to_no_active_account() {
752 let mut document = serde_json::json!({
753 "schema": 3, "machine": machine_id(), "accounts": [], "discarded": []
754 });
755 migrate(&mut document, std::path::Path::new("/tmp/state.json")).expect("3 to 4");
756 let state: State = serde_json::from_value(document).expect("parses");
757 assert_eq!(state.active_for(ProviderId::Claude), None);
758 assert!(state.active.is_empty() && state.slot.is_empty());
759 }
760
761 #[test]
765 fn a_providers_own_fields_sit_beside_the_shared_ones() {
766 let account = Account {
767 label: "work".into(),
768 account_uuid: "acc".into(),
769 email: "a@b.c".into(),
770 parked: None,
771 last_used_at: None,
772 detail: Detail::Claude {
773 organization_uuid: "org".into(),
774 oauth_account: serde_json::json!({"emailAddress": "a@b.c"}),
775 },
776 };
777 let written = serde_json::to_value(&account).expect("writes");
778 assert_eq!(written["provider"], "claude");
779 assert_eq!(written["organization_uuid"], "org");
780 assert_eq!(written["label"], "work");
781 assert!(
782 written.get("detail").is_none(),
783 "flattened, so there is no nested object: {written}"
784 );
785 let back: Account = serde_json::from_value(written).expect("reads back");
786 assert_eq!(back.provider(), ProviderId::Claude);
787 assert_eq!(back.claude().unwrap().organization_uuid, "org");
788 }
789
790 #[test]
794 fn a_changed_slot_clears_only_that_providers_record() {
795 let mut state = State::default();
796 state.set_active(ProviderId::Claude, Some("work".into()));
797 state.set_slot(ProviderId::Claude, "some-other-slot".into());
798 state
799 .active
800 .insert("pretend-other-provider".into(), "personal".into());
801
802 if state
804 .slot_for(ProviderId::Claude)
805 .is_some_and(|recorded| recorded != "Claude Code-credentials")
806 {
807 state.set_active(ProviderId::Claude, None);
808 }
809
810 assert_eq!(state.active_for(ProviderId::Claude), None);
811 assert_eq!(
812 state
813 .active
814 .get("pretend-other-provider")
815 .map(String::as_str),
816 Some("personal"),
817 "another provider's record is not this provider's to clear"
818 );
819 }
820
821 #[test]
824 fn an_account_of_an_unknown_tool_asks_for_an_upgrade_not_a_deletion() {
825 let home = std::env::temp_dir().join(format!(
826 "pitboard-unknown-tool-{}-{:?}",
827 std::process::id(),
828 std::thread::current().id()
829 ));
830 let _ = std::fs::remove_dir_all(&home);
831 std::fs::create_dir_all(&home).unwrap();
832 let ctx = Context::new(home.clone()).with_pitboard_home(home.clone());
833 std::fs::write(
834 home.join("state.json"),
835 serde_json::json!({
836 "schema": SCHEMA,
837 "machine": machine_id(),
838 "accounts": [{
839 "label": "work", "account_uuid": "u", "email": "a@b.c",
840 "parked": null, "provider": "somethingnew"
841 }],
842 "active": {}, "slot": {}, "discarded": []
843 })
844 .to_string(),
845 )
846 .unwrap();
847 let err = load(&ctx).unwrap_err();
848 assert_eq!(err.code(), "state_names_unknown_tool");
849 assert!(err.to_string().contains("somethingnew"), "{err}");
850 assert!(!err.to_string().contains("Delete"), "{err}");
851 let _ = std::fs::remove_dir_all(&home);
852 }
853
854 #[test]
856 fn a_file_from_a_newer_pitboard_says_so() {
857 let mut document = serde_json::json!({"schema": SCHEMA + 1});
858 let err = migrate(&mut document, std::path::Path::new("/tmp/state.json")).unwrap_err();
859 assert_eq!(err.code(), "state_from_newer_version");
860 let said = err.to_string();
861 assert!(said.contains("Update this pitboard"), "{said}");
862 assert!(
863 !said.contains("brew") && !said.contains("cargo"),
864 "it names no one way of installing pitboard: {said}"
865 );
866 }
867
868 use super::*;
869
870 fn claude(label: &str) -> Key {
871 Key::new(ProviderId::Claude, label)
872 }
873
874 #[test]
875 fn machine_id_is_stable_and_real() {
876 let a = machine_id();
877 assert_eq!(a, machine_id());
878 assert_eq!(
879 a.len(),
880 64,
881 "expected a sha256 of the platform id, got {a:?}"
882 );
883 assert_ne!(
884 a, "unknown",
885 "this platform should report a stable machine id"
886 );
887 }
888
889 fn park(service: &str) -> Park {
890 Park {
891 service: service.into(),
892 parked_at: 100,
893 refresh_fingerprint: "f".into(),
894 access_expires_at: Some(200),
895 refresh_expires_at: Some(300),
896 }
897 }
898
899 fn account(label: &str, parked: Option<Park>) -> Account {
900 Account {
901 last_used_at: None,
902 label: label.into(),
903 account_uuid: format!("{label}-uuid"),
904 email: format!("{label}@example.com"),
905 detail: Detail::Claude {
906 organization_uuid: "o".into(),
907 oauth_account: serde_json::json!({}),
908 },
909 parked,
910 }
911 }
912
913 #[test]
914 fn a_new_park_discards_the_one_it_replaces() {
915 let mut s = State::default();
916 s.upsert(account("work", Some(park("old"))));
917 s.park(&claude("work"), park("new"));
918 assert_eq!(
919 s.get(&claude("work"))
920 .unwrap()
921 .parked
922 .as_ref()
923 .unwrap()
924 .service,
925 "new"
926 );
927 assert_eq!(s.discarded, ["old"]);
928 assert!(!s.references("old"));
929 }
930
931 #[test]
932 fn discarding_releases_whichever_account_held_it_and_lists_it_once() {
933 let mut s = State::default();
934 s.upsert(account("work", Some(park("current"))));
935 s.discard("something-else");
936 assert!(s.get(&claude("work")).unwrap().parked.is_some());
937 s.discard("current");
938 s.discard("current");
939 assert!(s.get(&claude("work")).unwrap().parked.is_none());
940 assert_eq!(s.discarded, ["something-else", "current"]);
941 }
942
943 #[test]
944 fn relabelling_keeps_the_account_its_park_and_whether_it_is_active() {
945 let mut s = State::default();
946 s.upsert(account("wrong", Some(park("p"))));
947 s.upsert(account("other", None));
948 s.set_active(ProviderId::Claude, Some("wrong".into()));
949
950 assert_eq!(
951 s.relabel(&claude("wrong"), "right").unwrap().email,
952 "wrong@example.com"
953 );
954 assert!(s.get(&claude("wrong")).is_none());
955 let renamed = s.get(&claude("right")).unwrap();
956 assert_eq!(renamed.account_uuid, "wrong-uuid");
957 assert_eq!(renamed.parked.as_ref().unwrap().service, "p");
958 assert_eq!(s.active_for(ProviderId::Claude), Some("right"));
959 assert!(s.discarded.is_empty(), "nothing is deleted by a rename");
960 }
961
962 #[test]
963 fn relabelling_refuses_a_taken_label_and_an_unknown_one() {
964 let mut s = State::default();
965 s.upsert(account("a", None));
966 s.upsert(account("b", None));
967 s.set_active(ProviderId::Claude, Some("a".into()));
968 assert!(matches!(
969 s.relabel(&claude("a"), "b"),
970 Err(Error::LabelTaken { .. })
971 ));
972 assert!(matches!(
973 s.relabel(&claude("nobody"), "c"),
974 Err(Error::AccountUnknown { .. })
975 ));
976 assert_eq!(
977 s.active_for(ProviderId::Claude),
978 Some("a"),
979 "a refused rename changes nothing"
980 );
981 assert!(s.relabel(&claude("a"), "a").is_ok());
982 }
983
984 #[test]
985 fn removing_an_account_lists_its_park_for_deletion() {
986 let mut s = State::default();
987 s.upsert(account("work", Some(park("p"))));
988 assert_eq!(s.remove(&claude("work")).unwrap().label, "work");
989 assert!(s.accounts.is_empty());
990 assert_eq!(s.discarded, ["p"]);
991 }
992
993 #[test]
996 fn a_park_this_home_did_not_write_is_let_go_and_never_listed_for_deletion() {
997 let mut s = State::default();
998 s.upsert(account("work", None));
999 s.upsert(account("home", None));
1000 s.park_foreign(&claude("work"), park("found"));
1001 s.park_foreign(&claude("home"), park("also-found"));
1002 assert_eq!(s.foreign, ["found", "also-found"]);
1003
1004 s.park(&claude("work"), park("ours"));
1005 assert_eq!(
1006 s.get(&claude("work"))
1007 .unwrap()
1008 .parked
1009 .as_ref()
1010 .unwrap()
1011 .service,
1012 "ours"
1013 );
1014 s.remove(&claude("home"));
1015 s.release("ours");
1016
1017 assert_eq!(s.discarded, ["ours"], "only the park this home wrote");
1018 assert!(s.foreign.is_empty(), "and nothing here holds the others");
1019 }
1020
1021 #[test]
1024 fn a_park_this_home_did_not_write_is_listed_once_it_is_used() {
1025 let mut s = State::default();
1026 s.upsert(account("work", None));
1027 s.park_foreign(&claude("work"), park("found"));
1028 s.discard("found");
1029 assert!(s.get(&claude("work")).unwrap().parked.is_none());
1030 assert_eq!(s.discarded, ["found"]);
1031 assert!(s.foreign.is_empty());
1032 }
1033
1034 #[test]
1037 fn a_state_file_from_before_foreign_parks_were_recorded_still_loads() {
1038 let written = serde_json::json!({
1039 "schema": SCHEMA,
1040 "machine": machine_id(),
1041 "accounts": [{
1042 "label": "work", "account_uuid": "acc-1", "email": "a@b.c",
1043 "provider": "codex", "workspace_id": null, "plan": "pro",
1044 "parked": {
1045 "service": "pitboard-park-acc-1-1789935600123",
1046 "parked_at": 1_789_935_600,
1047 "refresh_fingerprint": "abcd",
1048 "access_expires_at": null,
1049 "refresh_expires_at": null
1050 }
1051 }],
1052 "active": {}, "slot": {}, "discarded": []
1053 });
1054 let mut document = written.clone();
1055 migrate(&mut document, std::path::Path::new("/tmp/state.json")).expect("current");
1056 let mut state: State = serde_json::from_value(document).expect("still parses");
1057 assert!(state.foreign.is_empty());
1058 state.remove(&Key::new(ProviderId::Codex, "work"));
1059 assert_eq!(
1060 state.discarded,
1061 ["pitboard-park-acc-1-1789935600123"],
1062 "a park recorded before is this home's own, and goes as it always did"
1063 );
1064 }
1065
1066 #[test]
1067 fn a_park_is_restorable_until_its_login_expires() {
1068 let p = park("p");
1069 assert!(p.askable_at(199) && !p.askable_at(200));
1070 assert!(p.restorable_at(299) && !p.restorable_at(300));
1071 let unknown = Park {
1072 access_expires_at: None,
1073 refresh_expires_at: None,
1074 ..park("p")
1075 };
1076 assert!(
1077 unknown.restorable_at(i64::MAX),
1078 "no expiry recorded is not expired"
1079 );
1080 }
1081
1082 #[test]
1083 fn accounts_are_replaced_by_label_not_duplicated() {
1084 let mut s = State::default();
1085 s.upsert(account("work", None));
1086 s.upsert(Account {
1087 email: "d@e.f".into(),
1088 ..account("work", None)
1089 });
1090 assert_eq!(s.accounts.len(), 1);
1091 assert_eq!(s.get(&claude("work")).unwrap().email, "d@e.f");
1092 }
1093
1094 fn codex_account(label: &str) -> Account {
1095 Account {
1096 last_used_at: None,
1097 label: label.into(),
1098 account_uuid: format!("codex-{label}-uuid"),
1099 email: format!("{label}@openai.example"),
1100 detail: Detail::Codex {
1101 workspace_id: None,
1102 plan: None,
1103 },
1104 parked: None,
1105 }
1106 }
1107
1108 #[test]
1112 fn two_tools_can_each_have_an_account_of_the_same_name() {
1113 let mut s = State::default();
1114 s.upsert(account("work", Some(park("claude-park"))));
1115 s.upsert(codex_account("work"));
1116 assert_eq!(
1117 s.accounts.len(),
1118 2,
1119 "the second is added, not a replacement"
1120 );
1121
1122 let codex = Key::new(ProviderId::Codex, "work");
1123 assert_eq!(s.get(&codex).unwrap().provider(), ProviderId::Codex);
1124 assert_eq!(
1125 s.get(&claude("work")).unwrap().provider(),
1126 ProviderId::Claude
1127 );
1128
1129 s.park(&codex, park("codex-park"));
1130 assert_eq!(
1131 s.get(&claude("work"))
1132 .unwrap()
1133 .parked
1134 .as_ref()
1135 .unwrap()
1136 .service,
1137 "claude-park",
1138 "parking one tool's account leaves the other's alone"
1139 );
1140
1141 s.remove(&codex);
1142 assert!(s.get(&claude("work")).is_some(), "and so does dropping it");
1143 assert_eq!(s.discarded, ["codex-park"]);
1144 }
1145
1146 #[test]
1149 fn a_rename_clashes_only_within_its_own_tool() {
1150 let mut s = State::default();
1151 s.upsert(account("personal", None));
1152 s.upsert(codex_account("work"));
1153 assert!(s.relabel(&claude("personal"), "work").is_ok());
1154 assert_eq!(
1155 s.get(&claude("work")).unwrap().email,
1156 "personal@example.com"
1157 );
1158 }
1159
1160 #[test]
1163 fn removing_the_account_in_use_clears_that_tools_record_only() {
1164 let mut s = State::default();
1165 s.upsert(account("work", None));
1166 s.upsert(codex_account("work"));
1167 s.set_active(ProviderId::Claude, Some("work".into()));
1168 s.set_active(ProviderId::Codex, Some("work".into()));
1169 s.remove(&Key::new(ProviderId::Codex, "work"));
1170 assert_eq!(s.active_for(ProviderId::Codex), None);
1171 assert_eq!(s.active_for(ProviderId::Claude), Some("work"));
1172 }
1173
1174 #[test]
1177 fn a_name_is_qualified_where_a_bare_one_would_be_ambiguous() {
1178 let mut s = State::default();
1179 s.upsert(account("work", None));
1180 s.upsert(account("personal", None));
1181 s.upsert(codex_account("work"));
1182 assert_eq!(s.typed(&claude("work")), "claude/work");
1183 assert_eq!(s.typed(&Key::new(ProviderId::Codex, "work")), "codex/work");
1184 assert_eq!(s.typed(&claude("personal")), "personal");
1185 }
1186
1187 #[test]
1188 fn a_key_reads_the_way_it_would_be_typed() {
1189 assert_eq!(claude("work").to_string(), "work");
1190 assert_eq!(
1191 Key::new(ProviderId::Codex, "work").to_string(),
1192 "codex/work"
1193 );
1194 assert_eq!(claude("work").qualified(), "claude/work");
1195 }
1196}