1use crate::api::{self, ApiError, Owner};
9use crate::context::Context;
10use crate::state::{Park, State};
11use crate::usage::{Snapshot, Source};
12use crate::{claude, park, readings, store, time};
13use serde_json::Value;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
17#[serde(rename_all = "snake_case")]
18pub enum Stale {
19 NothingSignedIn,
20 SessionExpired,
22 ParkedAccessExpired,
24 NothingParked,
25 ParkUnreadable,
26 RateLimited,
27 Unreachable,
28 Unexpected,
29}
30
31impl Stale {
32 fn of(error: &ApiError, signed_in: bool) -> Stale {
33 match error {
34 ApiError::Unauthorized if signed_in => Stale::SessionExpired,
35 ApiError::Unauthorized => Stale::ParkedAccessExpired,
36 ApiError::RateLimited => Stale::RateLimited,
37 ApiError::Network(_) => Stale::Unreachable,
38 ApiError::Unexpected { .. } | ApiError::Malformed(_) | ApiError::InvalidGrant => {
39 Stale::Unexpected
40 }
41 }
42 }
43
44 pub fn code(self) -> &'static str {
46 match self {
47 Stale::NothingSignedIn => "nothing_signed_in",
48 Stale::SessionExpired => "session_expired",
49 Stale::ParkedAccessExpired => "parked_access_expired",
50 Stale::NothingParked => "nothing_parked",
51 Stale::ParkUnreadable => "park_unreadable",
52 Stale::RateLimited => "rate_limited",
53 Stale::Unreachable => "unreachable",
54 Stale::Unexpected => "unexpected",
55 }
56 }
57
58 pub fn explanation(self) -> Option<&'static str> {
60 match self {
61 Stale::NothingSignedIn => Some("nothing is signed in"),
62 Stale::SessionExpired => Some("Claude Code's session has expired; `claude` renews it"),
63 Stale::ParkedAccessExpired | Stale::NothingParked => None,
64 Stale::ParkUnreadable => Some("its parked login cannot be read; run `pitboard doctor`"),
65 Stale::RateLimited => Some("Anthropic is rate limiting usage checks"),
66 Stale::Unreachable => Some("Anthropic could not be reached"),
67 Stale::Unexpected => Some("Anthropic's answer was not understood"),
68 }
69 }
70}
71
72pub struct Row {
73 pub label: Option<String>,
75 pub email: String,
76 pub account_uuid: String,
77 pub signed_in: bool,
78 pub parked: Option<Park>,
79 pub usage: Option<Snapshot>,
80 pub stale: Option<Stale>,
81}
82
83impl Row {
84 pub fn switchable(&self, now: i64) -> bool {
86 !self.signed_in && self.parked.as_ref().is_some_and(|p| p.restorable_at(now))
87 }
88}
89
90pub struct Report {
91 pub now: i64,
92 pub rows: Vec<Row>,
93 pub signed_in: Result<Owner, String>,
96}
97
98struct Facts {
100 signed_in: Option<Result<Owner, ApiError>>,
101 live_usage: Result<Snapshot, Stale>,
102 parked_usage: Vec<Result<Snapshot, Stale>>,
104 claude_code_cache: Option<Snapshot>,
105}
106
107fn access_token(oauth: &Value) -> Option<String> {
108 oauth
109 .get("accessToken")
110 .and_then(Value::as_str)
111 .map(str::to_owned)
112}
113
114fn parked_token(
116 ctx: &Context,
117 label: &str,
118 parked: Option<&Park>,
119 now: i64,
120) -> Result<String, Stale> {
121 match parked {
122 None => Err(Stale::NothingParked),
123 Some(p) if !p.askable_at(now) => Err(Stale::ParkedAccessExpired),
124 Some(p) => park::load(ctx, label, p)
125 .ok()
126 .and_then(|oauth| access_token(&oauth))
127 .ok_or(Stale::ParkUnreadable),
128 }
129}
130
131pub fn gather(ctx: &Context, state: &State) -> Report {
132 let now = time::now();
133 let live_token = store::read(ctx, &claude::live_service(ctx))
134 .ok()
135 .flatten()
136 .and_then(|doc| access_token(&doc["claudeAiOauth"]));
137 let parked_tokens: Vec<Result<String, Stale>> = state
138 .accounts
139 .iter()
140 .map(|a| parked_token(ctx, &a.label, a.parked.as_ref(), now))
141 .collect();
142
143 let (signed_in, live_usage, parked_usage) = std::thread::scope(|scope| {
144 let owner = scope.spawn(|| live_token.as_deref().map(|t| api::owner(ctx, t)));
145 let live = scope.spawn(|| match live_token.as_deref() {
146 Some(token) => api::usage(ctx, token).map_err(|e| Stale::of(&e, true)),
147 None => Err(Stale::NothingSignedIn),
148 });
149 let parked: Vec<_> = parked_tokens
150 .iter()
151 .map(|token| {
152 scope.spawn(move || {
153 let token = token.as_deref().map_err(|stale| *stale)?;
154 api::usage(ctx, token).map_err(|e| Stale::of(&e, false))
155 })
156 })
157 .collect();
158 (
159 owner.join().ok().flatten(),
160 live.join().unwrap_or(Err(Stale::Unexpected)),
161 parked
162 .into_iter()
163 .map(|h| h.join().unwrap_or(Err(Stale::Unexpected)))
164 .collect(),
165 )
166 });
167
168 let facts = Facts {
169 signed_in,
170 live_usage,
171 parked_usage,
172 claude_code_cache: claude::load_config(ctx)
173 .ok()
174 .as_ref()
175 .and_then(crate::usage::from_config_cache),
176 };
177 let remembered = readings::load(ctx);
178 let rows = assemble(state, &facts, |uuid| remembered.get(uuid).cloned());
179 readings::remember(
180 ctx,
181 &rows
182 .iter()
183 .filter_map(|r| {
184 r.usage
185 .as_ref()
186 .filter(|u| u.source == Source::Live)
187 .map(|u| (r.account_uuid.clone(), u.clone()))
188 })
189 .collect::<Vec<_>>(),
190 );
191
192 Report {
193 now,
194 rows,
195 signed_in: match facts.signed_in {
196 Some(Ok(owner)) => Ok(owner),
197 Some(Err(e)) => Err(e.to_string()),
198 None => Err("nothing is signed in".into()),
199 },
200 }
201}
202
203fn assemble(state: &State, facts: &Facts, recall: impl Fn(&str) -> Option<Snapshot>) -> Vec<Row> {
204 let live_uuid = match &facts.signed_in {
205 Some(Ok(owner)) => Some(owner.account_uuid.as_str()),
206 _ => None,
207 };
208 let cached_for = |uuid: &str| {
210 facts
211 .claude_code_cache
212 .clone()
213 .filter(|c| c.account_uuid.as_deref() == Some(uuid))
214 };
215 let reading = |asked: &Result<Snapshot, Stale>, fallback: Option<Snapshot>| match asked {
216 Ok(live) => (Some(live.clone()), None),
217 Err(stale) => (fallback, Some(*stale)),
218 };
219
220 let mut rows: Vec<Row> = state
221 .accounts
222 .iter()
223 .zip(&facts.parked_usage)
224 .map(|(account, parked)| {
225 let uuid = account.account_uuid.as_str();
226 let signed_in = live_uuid == Some(uuid);
227 let (usage, stale) = if signed_in {
228 reading(&facts.live_usage, cached_for(uuid).or_else(|| recall(uuid)))
229 } else {
230 reading(parked, recall(uuid))
231 };
232 Row {
233 label: Some(account.label.clone()),
234 email: account.email.clone(),
235 account_uuid: account.account_uuid.clone(),
236 signed_in,
237 parked: account.parked.clone(),
238 usage,
239 stale,
240 }
241 })
242 .collect();
243
244 if let Some(Ok(owner)) = &facts.signed_in
245 && !rows.iter().any(|r| r.signed_in)
246 {
247 let (usage, stale) = reading(&facts.live_usage, cached_for(&owner.account_uuid));
248 rows.push(Row {
249 label: None,
250 email: owner.email.clone(),
251 account_uuid: owner.account_uuid.clone(),
252 signed_in: true,
253 parked: None,
254 usage,
255 stale,
256 });
257 }
258
259 rows.sort_by_key(|r| !r.signed_in);
260 rows
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use crate::state::Account;
267 use crate::usage::Window;
268 use serde_json::json;
269
270 const NOW: i64 = 1_789_935_000;
271
272 fn owner(uuid: &str) -> Owner {
273 Owner {
274 account_uuid: uuid.into(),
275 email: format!("{uuid}@example.com"),
276 organization_uuid: "org".into(),
277 }
278 }
279
280 fn reading(percent: f64, source: Source, account: Option<&str>) -> Snapshot {
281 Snapshot {
282 windows: vec![Window {
283 kind: "session".into(),
284 scope: None,
285 percent,
286 resets_at: Some(NOW + 3_600),
287 is_active: true,
288 }],
289 observed_at: Some(NOW - 7_200),
290 account_uuid: account.map(str::to_owned),
291 source,
292 }
293 }
294
295 fn parked(refresh_expires_at: i64) -> Park {
296 Park {
297 service: "pitboard-park-x-1".into(),
298 parked_at: NOW - 86_400,
299 refresh_fingerprint: "f".into(),
300 access_expires_at: Some(NOW - 3_600),
301 refresh_expires_at: Some(refresh_expires_at),
302 }
303 }
304
305 fn account(label: &str) -> Account {
306 Account {
307 label: label.into(),
308 account_uuid: format!("{label}-uuid"),
309 email: format!("{label}@example.com"),
310 organization_uuid: "org".into(),
311 oauth_account: json!({}),
312 parked: Some(parked(NOW + 20 * 86_400)),
313 }
314 }
315
316 fn state(labels: &[&str]) -> State {
317 State {
318 accounts: labels.iter().map(|l| account(l)).collect(),
319 ..State::default()
320 }
321 }
322
323 fn facts(
324 signed_in: &str,
325 live: Result<Snapshot, Stale>,
326 parked: Vec<Result<Snapshot, Stale>>,
327 ) -> Facts {
328 Facts {
329 signed_in: Some(Ok(owner(signed_in))),
330 live_usage: live,
331 parked_usage: parked,
332 claude_code_cache: None,
333 }
334 }
335
336 fn nothing_remembered(_: &str) -> Option<Snapshot> {
337 None
338 }
339
340 #[test]
341 fn every_stale_code_is_its_json_form() {
342 for stale in [
343 Stale::NothingSignedIn,
344 Stale::SessionExpired,
345 Stale::ParkedAccessExpired,
346 Stale::NothingParked,
347 Stale::ParkUnreadable,
348 Stale::RateLimited,
349 Stale::Unreachable,
350 Stale::Unexpected,
351 ] {
352 assert_eq!(serde_json::to_value(stale).unwrap(), stale.code());
353 }
354 }
355
356 #[test]
357 fn a_live_reading_wins_over_claude_codes_cache() {
358 let s = state(&["work"]);
359 let mut f = facts(
360 "work-uuid",
361 Ok(reading(30.0, Source::Live, None)),
362 vec![Err(Stale::NothingParked)],
363 );
364 f.claude_code_cache = Some(reading(2.0, Source::ClaudeCodeCache, Some("work-uuid")));
365 let rows = assemble(&s, &f, nothing_remembered);
366 let usage = rows[0].usage.as_ref().unwrap();
367 assert_eq!(usage.source, Source::Live);
368 assert_eq!(usage.windows[0].percent, 30.0);
369 assert_eq!(rows[0].stale, None);
370 }
371
372 #[test]
373 fn an_expired_session_falls_back_to_claude_codes_cache_and_says_why() {
374 let s = state(&["work"]);
375 let mut f = facts(
376 "work-uuid",
377 Err(Stale::SessionExpired),
378 vec![Err(Stale::NothingParked)],
379 );
380 f.claude_code_cache = Some(reading(2.0, Source::ClaudeCodeCache, Some("work-uuid")));
381 let rows = assemble(&s, &f, nothing_remembered);
382 assert_eq!(
383 rows[0].usage.as_ref().unwrap().source,
384 Source::ClaudeCodeCache
385 );
386 assert_eq!(rows[0].stale, Some(Stale::SessionExpired));
387 }
388
389 #[test]
390 fn claude_codes_cache_for_another_account_is_never_shown_as_this_one() {
391 let s = state(&["work"]);
392 let mut f = facts(
393 "work-uuid",
394 Err(Stale::Unreachable),
395 vec![Err(Stale::NothingParked)],
396 );
397 f.claude_code_cache = Some(reading(99.0, Source::ClaudeCodeCache, Some("someone-else")));
398 assert!(assemble(&s, &f, nothing_remembered)[0].usage.is_none());
399 }
400
401 #[test]
402 fn a_parked_account_is_asked_with_its_own_login() {
403 let s = state(&["work", "personal"]);
404 let f = facts(
405 "work-uuid",
406 Ok(reading(30.0, Source::Live, None)),
407 vec![
408 Err(Stale::NothingParked),
409 Ok(reading(12.0, Source::Live, None)),
410 ],
411 );
412 let rows = assemble(&s, &f, nothing_remembered);
413 let personal = rows
414 .iter()
415 .find(|r| r.label.as_deref() == Some("personal"))
416 .unwrap();
417 assert_eq!(personal.usage.as_ref().unwrap().windows[0].percent, 12.0);
418 assert!(personal.switchable(NOW));
419 }
420
421 #[test]
422 fn a_parked_login_past_its_access_expiry_is_not_asked() {
423 let ctx = Context::from_env();
424 let token = parked_token(&ctx, "personal", Some(&parked(NOW + 86_400)), NOW);
425 assert_eq!(token, Err(Stale::ParkedAccessExpired));
426 assert_eq!(
427 parked_token(&ctx, "personal", None, NOW),
428 Err(Stale::NothingParked)
429 );
430 }
431
432 #[test]
433 fn what_cannot_be_asked_falls_back_to_what_was_remembered() {
434 let s = state(&["work", "personal"]);
435 let f = facts(
436 "work-uuid",
437 Ok(reading(30.0, Source::Live, None)),
438 vec![Err(Stale::NothingParked), Err(Stale::ParkedAccessExpired)],
439 );
440 let remembered = |uuid: &str| {
441 (uuid == "personal-uuid").then(|| reading(44.0, Source::Remembered, Some(uuid)))
442 };
443 let rows = assemble(&s, &f, remembered);
444 let personal = rows
445 .iter()
446 .find(|r| r.label.as_deref() == Some("personal"))
447 .unwrap();
448 assert_eq!(personal.usage.as_ref().unwrap().source, Source::Remembered);
449 assert_eq!(personal.stale, Some(Stale::ParkedAccessExpired));
450 }
451
452 #[test]
453 fn the_signed_in_account_comes_first_and_is_taken_from_the_server_not_the_config() {
454 let s = state(&["alpha", "beta"]);
455 let f = facts(
456 "beta-uuid",
457 Ok(reading(5.0, Source::Live, None)),
458 vec![Err(Stale::NothingParked), Err(Stale::NothingParked)],
459 );
460 let rows = assemble(&s, &f, nothing_remembered);
461 assert_eq!(rows[0].label.as_deref(), Some("beta"));
462 assert!(rows[0].signed_in && !rows[0].switchable(NOW));
463 assert!(!rows[1].signed_in);
464 }
465}