1use crate::context::Context;
9use crate::error::{Error, Result};
10use crate::{atomic, home};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use std::path::PathBuf;
14
15const SCHEMA: u32 = 3;
16
17#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
21pub struct Park {
22 pub service: String,
23 pub parked_at: i64,
24 pub refresh_fingerprint: String,
25 pub access_expires_at: Option<i64>,
27 pub refresh_expires_at: Option<i64>,
29}
30
31impl Park {
32 pub fn restorable_at(&self, now: i64) -> bool {
33 self.refresh_expires_at.is_none_or(|at| at > now)
34 }
35
36 pub fn askable_at(&self, now: i64) -> bool {
37 self.access_expires_at.is_none_or(|at| at > now)
38 }
39}
40
41#[derive(Serialize, Deserialize, Debug, Clone)]
42pub struct Account {
43 pub label: String,
44 pub account_uuid: String,
45 pub email: String,
46 pub organization_uuid: String,
47 pub oauth_account: Value,
50 pub parked: Option<Park>,
51}
52
53#[derive(Serialize, Deserialize, Debug, Clone)]
54pub struct State {
55 pub schema: u32,
56 pub machine: String,
57 pub accounts: Vec<Account>,
58 pub active: Option<String>,
59 #[serde(default)]
63 pub slot: Option<String>,
64 #[serde(default)]
67 pub discarded: Vec<String>,
68}
69
70impl Default for State {
71 fn default() -> Self {
72 State {
73 schema: SCHEMA,
74 machine: machine_id(),
75 accounts: Vec::new(),
76 active: None,
77 slot: None,
78 discarded: Vec::new(),
79 }
80 }
81}
82
83impl State {
84 pub fn labels(&self) -> crate::error::Enrolled {
87 crate::error::Enrolled(self.accounts.iter().map(|a| a.label.clone()).collect())
88 }
89
90 pub fn get(&self, label: &str) -> Option<&Account> {
91 self.accounts.iter().find(|a| a.label == label)
92 }
93
94 pub fn by_uuid(&self, uuid: &str) -> Option<&Account> {
95 self.accounts.iter().find(|a| a.account_uuid == uuid)
96 }
97
98 fn get_mut(&mut self, label: &str) -> Option<&mut Account> {
99 self.accounts.iter_mut().find(|a| a.label == label)
100 }
101
102 pub fn park(&mut self, label: &str, park: Park) {
104 let service = park.service.clone();
105 if let Some(previous) = self
106 .get_mut(label)
107 .and_then(|account| account.parked.replace(park))
108 && previous.service != service
109 {
110 self.discard(&previous.service);
111 }
112 }
113
114 pub fn discard(&mut self, service: &str) {
117 for account in &mut self.accounts {
118 account.parked.take_if(|p| p.service == service);
119 }
120 if !self.discarded.iter().any(|listed| listed == service) {
121 self.discarded.push(service.to_string());
122 }
123 }
124
125 pub fn references(&self, service: &str) -> bool {
126 self.accounts
127 .iter()
128 .any(|a| a.parked.as_ref().is_some_and(|p| p.service == service))
129 }
130
131 pub fn upsert(&mut self, account: Account) {
132 match self.get_mut(&account.label) {
133 Some(existing) => *existing = account,
134 None => self.accounts.push(account),
135 }
136 }
137
138 pub fn relabel(&mut self, from: &str, to: &str) -> Result<&Account> {
141 if from != to
142 && let Some(taken) = self.get(to)
143 {
144 return Err(Error::LabelTaken {
145 label: to.to_string(),
146 email: taken.email.clone(),
147 });
148 }
149 if self.active.as_deref() == Some(from) {
150 self.active = Some(to.to_string());
151 }
152 let enrolled = self.labels();
153 let account = self.get_mut(from).ok_or_else(|| Error::AccountUnknown {
154 label: from.to_string(),
155 enrolled,
156 })?;
157 account.label = to.to_string();
158 Ok(account)
159 }
160
161 pub fn remove(&mut self, label: &str) -> Option<Account> {
163 let index = self.accounts.iter().position(|a| a.label == label)?;
164 let account = self.accounts.remove(index);
165 if let Some(park) = &account.parked {
166 self.discard(&park.service);
167 }
168 Some(account)
169 }
170}
171
172pub fn machine_id() -> String {
174 use sha2::{Digest, Sha256};
175 match machine_uid::get() {
176 Ok(raw) => hex::encode(Sha256::digest(raw.as_bytes())),
177 Err(_) => String::from("unknown"),
178 }
179}
180
181fn file(ctx: &Context) -> PathBuf {
182 home::dir(ctx).join("state.json")
183}
184
185pub fn load(ctx: &Context) -> Result<State> {
186 let path = file(ctx);
187 home::check_location(&home::dir(ctx))?;
188 let raw = match std::fs::read_to_string(&path) {
189 Ok(s) => s,
190 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(State::default()),
191 Err(source) => return Err(Error::StateUnreadable { path, source }),
192 };
193 let mut document: serde_json::Value =
194 serde_json::from_str(&raw).map_err(|source| Error::StateCorrupt {
195 path: path.clone(),
196 source,
197 })?;
198 migrate(&mut document, &path)?;
199 let state: State = serde_json::from_value(document).map_err(|source| Error::StateCorrupt {
200 path: path.clone(),
201 source,
202 })?;
203 if state.machine != machine_id() {
204 return Err(Error::StateWrongMachine { path });
205 }
206 let mut state = state;
207 let slot = crate::claude::live_service(ctx);
210 if state.slot.is_some() && state.slot.as_deref() != Some(slot.as_str()) {
211 state.active = None;
212 }
213 Ok(state)
214}
215
216fn migrate(document: &mut serde_json::Value, path: &std::path::Path) -> Result<()> {
222 let found = document
225 .get("schema")
226 .and_then(serde_json::Value::as_u64)
227 .unwrap_or_default() as u32;
228 match found {
229 SCHEMA => Ok(()),
230 0..SCHEMA => Err(Error::StateVersionUnknown {
232 path: path.to_path_buf(),
233 found,
234 }),
235 _ => Err(Error::StateFromNewerVersion {
236 path: path.to_path_buf(),
237 found,
238 expected: SCHEMA,
239 }),
240 }
241}
242
243pub fn save(ctx: &Context, state: &State) -> Result<()> {
244 home::check_location(&home::dir(ctx))?;
245 let mut state = state.clone();
246 state.slot = Some(crate::claude::live_service(ctx));
247 let state = &state;
248 let path = file(ctx);
249 let write = |source| Error::StateWriteFailed {
250 path: path.clone(),
251 source,
252 };
253 home::ensure(ctx).map_err(write)?;
254 let body = serde_json::to_string_pretty(state).expect("State is always serialisable");
255 atomic::write(&path, body.as_bytes(), atomic::Perms::Secret).map_err(write)
256}
257
258#[cfg(test)]
259mod tests {
260 #[test]
264 fn what_was_active_in_another_slot_is_not_claimed_here() {
265 let home = std::env::temp_dir().join(format!("pitboard-slots-{}", std::process::id()));
266 let _ = std::fs::remove_dir_all(&home);
267 let here = Context::new(home.clone()).with_pitboard_home(home.clone());
268 let elsewhere = here
269 .clone()
270 .with_claude_config_dir("/somewhere/else".into());
271
272 let mut state = State::default();
273 state.accounts.push(Account {
274 label: "work".into(),
275 account_uuid: "acc".into(),
276 email: "a@b.c".into(),
277 organization_uuid: "org".into(),
278 oauth_account: serde_json::json!({}),
279 parked: None,
280 });
281 state.active = Some("work".into());
282 save(&here, &state).expect("saved");
283
284 assert_eq!(load(&here).unwrap().active.as_deref(), Some("work"));
285 assert_eq!(
286 load(&elsewhere).unwrap().active,
287 None,
288 "another slot's record of what is in use is not this slot's"
289 );
290 let _ = std::fs::remove_dir_all(&home);
291 }
292
293 #[test]
296 fn the_format_shipped_in_0_1_x_still_loads() {
297 let written = serde_json::json!({
298 "schema": 3,
299 "machine": machine_id(),
300 "accounts": [{
301 "label": "work",
302 "account_uuid": "acc-1",
303 "email": "a@b.c",
304 "organization_uuid": "org-1",
305 "oauth_account": {"emailAddress": "a@b.c"},
306 "parked": {
307 "service": "pitboard-park-acc-1-1789935600123",
308 "parked_at": 1_789_935_600,
309 "refresh_fingerprint": "abcd",
310 "access_expires_at": 1_789_999_999,
311 "refresh_expires_at": 1_792_000_000
312 }
313 }],
314 "active": "work",
315 "discarded": []
316 });
317 let mut document = written.clone();
318 migrate(&mut document, std::path::Path::new("/tmp/state.json")).expect("still current");
319 let state: State = serde_json::from_value(document).expect("still parses");
320 assert_eq!(state.get("work").unwrap().email, "a@b.c");
321 assert_eq!(state.active.as_deref(), Some("work"));
322 }
323
324 #[test]
326 fn a_file_from_a_newer_pitboard_says_so() {
327 let mut document = serde_json::json!({"schema": SCHEMA + 1});
328 let err = migrate(&mut document, std::path::Path::new("/tmp/state.json")).unwrap_err();
329 assert_eq!(err.code(), "state_from_newer_version");
330 assert!(err.to_string().contains("upgrade whichever"), "{err}");
331 }
332
333 use super::*;
334
335 #[test]
336 fn machine_id_is_stable_and_real() {
337 let a = machine_id();
338 assert_eq!(a, machine_id());
339 assert_eq!(
340 a.len(),
341 64,
342 "expected a sha256 of the platform id, got {a:?}"
343 );
344 assert_ne!(
345 a, "unknown",
346 "this platform should report a stable machine id"
347 );
348 }
349
350 fn park(service: &str) -> Park {
351 Park {
352 service: service.into(),
353 parked_at: 100,
354 refresh_fingerprint: "f".into(),
355 access_expires_at: Some(200),
356 refresh_expires_at: Some(300),
357 }
358 }
359
360 fn account(label: &str, parked: Option<Park>) -> Account {
361 Account {
362 label: label.into(),
363 account_uuid: format!("{label}-uuid"),
364 email: format!("{label}@example.com"),
365 organization_uuid: "o".into(),
366 oauth_account: serde_json::json!({}),
367 parked,
368 }
369 }
370
371 #[test]
372 fn a_new_park_discards_the_one_it_replaces() {
373 let mut s = State::default();
374 s.upsert(account("work", Some(park("old"))));
375 s.park("work", park("new"));
376 assert_eq!(
377 s.get("work").unwrap().parked.as_ref().unwrap().service,
378 "new"
379 );
380 assert_eq!(s.discarded, ["old"]);
381 assert!(!s.references("old"));
382 }
383
384 #[test]
385 fn discarding_releases_whichever_account_held_it_and_lists_it_once() {
386 let mut s = State::default();
387 s.upsert(account("work", Some(park("current"))));
388 s.discard("something-else");
389 assert!(s.get("work").unwrap().parked.is_some());
390 s.discard("current");
391 s.discard("current");
392 assert!(s.get("work").unwrap().parked.is_none());
393 assert_eq!(s.discarded, ["something-else", "current"]);
394 }
395
396 #[test]
397 fn relabelling_keeps_the_account_its_park_and_whether_it_is_active() {
398 let mut s = State::default();
399 s.upsert(account("wrong", Some(park("p"))));
400 s.upsert(account("other", None));
401 s.active = Some("wrong".into());
402
403 assert_eq!(
404 s.relabel("wrong", "right").unwrap().email,
405 "wrong@example.com"
406 );
407 assert!(s.get("wrong").is_none());
408 let renamed = s.get("right").unwrap();
409 assert_eq!(renamed.account_uuid, "wrong-uuid");
410 assert_eq!(renamed.parked.as_ref().unwrap().service, "p");
411 assert_eq!(s.active.as_deref(), Some("right"));
412 assert!(s.discarded.is_empty(), "nothing is deleted by a rename");
413 }
414
415 #[test]
416 fn relabelling_refuses_a_taken_label_and_an_unknown_one() {
417 let mut s = State::default();
418 s.upsert(account("a", None));
419 s.upsert(account("b", None));
420 s.active = Some("a".into());
421 assert!(matches!(s.relabel("a", "b"), Err(Error::LabelTaken { .. })));
422 assert!(matches!(
423 s.relabel("nobody", "c"),
424 Err(Error::AccountUnknown { .. })
425 ));
426 assert_eq!(
427 s.active.as_deref(),
428 Some("a"),
429 "a refused rename changes nothing"
430 );
431 assert!(s.relabel("a", "a").is_ok());
432 }
433
434 #[test]
435 fn removing_an_account_lists_its_park_for_deletion() {
436 let mut s = State::default();
437 s.upsert(account("work", Some(park("p"))));
438 assert_eq!(s.remove("work").unwrap().label, "work");
439 assert!(s.accounts.is_empty());
440 assert_eq!(s.discarded, ["p"]);
441 }
442
443 #[test]
444 fn a_park_is_restorable_until_its_login_expires() {
445 let p = park("p");
446 assert!(p.askable_at(199) && !p.askable_at(200));
447 assert!(p.restorable_at(299) && !p.restorable_at(300));
448 let unknown = Park {
449 access_expires_at: None,
450 refresh_expires_at: None,
451 ..park("p")
452 };
453 assert!(
454 unknown.restorable_at(i64::MAX),
455 "no expiry recorded is not expired"
456 );
457 }
458
459 #[test]
460 fn accounts_are_replaced_by_label_not_duplicated() {
461 let mut s = State::default();
462 s.upsert(account("work", None));
463 s.upsert(Account {
464 email: "d@e.f".into(),
465 ..account("work", None)
466 });
467 assert_eq!(s.accounts.len(), 1);
468 assert_eq!(s.get("work").unwrap().email, "d@e.f");
469 }
470}