pitboard-core 0.8.0

The engine behind Pitboard: parking and restoring Claude Code and Codex logins. Serves Pitboard's own front ends.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
//! What Pitboard says in more than one place, each written once as a function of typed
//! values: its sentences, the words of its columns, and the level a limit's usage is at,
//! where the command line's colours and the app's tints change. The command line calls
//! these functions directly, and `pitboard-ffi`'s model calls them as it makes what an app
//! shows, so where both say a thing they say it in the same words. The bindings export none
//! of them: an app shows what the snapshot says. A thing said both in a column and in a
//! sentence, such as a limit's name or a parked login's life, has a function for each form.
//!
//! All of it is English. Clock times are not here: the command line writes them with
//! `time::moment`, and the app in the format its Mac is set to.

use crate::doctor::{Level, renewal_due};
use crate::history::Runway;

const MINUTE: i64 = 60;
const HOUR: i64 = 60 * MINUTE;
const DAY: i64 = 24 * HOUR;
const WEEK: i64 = 7 * DAY;

/// A length of time to the precision a person reads: "6d 4h", "2h 05m", "47m", "<1m".
///
/// Minutes beside hours take two digits, so a column of them lines up as they tick. Under a
/// minute is "<1m": "0m" reads as though nothing were left when the difference is seconds
/// either way.
pub fn span(seconds: i64) -> String {
    let s = seconds.max(0);
    let (days, hours, minutes) = (s / DAY, s % DAY / HOUR, s % HOUR / MINUTE);
    match (days, hours) {
        (0, 0) if minutes == 0 => "<1m".into(),
        (0, 0) => format!("{minutes}m"),
        (0, h) => format!("{h}h {minutes:02}m"),
        (d, h) => format!("{d}d {h}h"),
    }
}

/// A limit in the column form, beside its bar: "5h", "week", "day", "3d", "30m", "45s", and
/// "week ยท Fable" for one scoped to a model.
///
/// By how long it runs, where that is known, and otherwise by its kind. The length is what
/// makes two tools' limits comparable: OpenAI times every window and names none, Anthropic
/// names every window and times none, and Pitboard knows the length of each either way. A
/// kind is only the fallback, for a reading remembered from before the length was kept.
pub fn limit_column(kind: &str, length_seconds: Option<i64>, scope: Option<&str>) -> String {
    let base = match length_seconds.filter(|s| *s > 0) {
        Some(WEEK) => "week".into(),
        Some(DAY) => "day".into(),
        Some(s) if s % DAY == 0 => format!("{}d", s / DAY),
        Some(s) if s % HOUR == 0 => format!("{}h", s / HOUR),
        Some(s) if s % MINUTE == 0 => format!("{}m", s / MINUTE),
        Some(s) => format!("{s}s"),
        None => match kind {
            "session" | "five_hour" => "5h".into(),
            "weekly_all" | "seven_day" | "weekly_scoped" => "week".into(),
            other => other.into(),
        },
    };
    match scope {
        Some(scope) => format!("{base} ยท {scope}"),
        None => base,
    }
}

/// A limit in the sentence form: "5-hour", "weekly", "daily", "3-day", "30-minute".
///
/// From its length where that is known, as [`limit_column`] is: "session" means nothing to
/// somebody reading about a Codex account. Without its scope, which the sentence places
/// itself, as in "weekly Fable 98%".
pub fn limit_name(kind: &str, length_seconds: Option<i64>) -> String {
    match length_seconds.filter(|s| *s > 0) {
        Some(WEEK) => "weekly".into(),
        Some(DAY) => "daily".into(),
        Some(s) if s % DAY == 0 => format!("{}-day", s / DAY),
        Some(s) if s % HOUR == 0 => format!("{}-hour", s / HOUR),
        Some(s) if s % MINUTE == 0 => format!("{}-minute", s / MINUTE),
        Some(s) => format!("{s}-second"),
        None => match kind {
            "session" | "five_hour" => "5-hour".into(),
            "seven_day" => "weekly".into(),
            weekly if weekly.starts_with("weekly") => "weekly".into(),
            other => other.replace('_', " "),
        },
    }
}

/// How much of a limit an account has used, in a sentence about an automatic switch:
/// "96% of its 5-hour limit", or "97% of its weekly Opus limit" for one limit of a model.
pub fn share_of_limit(limit: &crate::usage::Window) -> String {
    let name = limit_name(&limit.kind, limit.length_seconds);
    let name = match &limit.scope {
        Some(scope) => format!("{name} {scope}"),
        None => name,
    };
    format!("{:.0}% of its {name} limit", limit.percent)
}

/// Why Pitboard does not switch Claude Code by itself where it would have, as a clause that
/// says what to do about it where anything can be done. It names no command: the app says
/// it too.
pub fn not_switching(why: &crate::autoswitch::Skip) -> String {
    use crate::autoswitch::Skip;
    match why {
        Skip::SwitchInterrupted => {
            "a switch was interrupted, and the next change you make finishes it".into()
        }
        Skip::CustomOauth => "Claude Code uses a custom OAuth endpoint, and Pitboard does not \
                              act on Claude Code then"
            .into(),
        Skip::Overridden(names) => format!(
            "Claude Code signs in another way, set by {}, so a switch would change nothing its \
             sessions use",
            names.join(", ")
        ),
    }
}

/// How much of a limit is used, in three steps. The command line's colours and the app's
/// tints change where these do, and the words always say the number itself, because not
/// everybody sees a colour.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UsageLevel {
    /// Under 70%.
    Plenty,
    /// From 70%.
    Low,
    /// From 90%, and past 100%.
    Out,
}

/// The step a limit is at, from the share of it used: `percent` as a reading gives it, which
/// passes 100 when a service reports more used than the limit.
pub fn usage_level(percent: f64) -> UsageLevel {
    if percent >= 90.0 {
        UsageLevel::Out
    } else if percent >= 70.0 {
        UsageLevel::Low
    } else {
        UsageLevel::Plenty
    }
}

/// When a limit resets, beside its bar: "resets in 2h 05m", and "resetting now" once that
/// moment has come. Whether it did reset is the next reading's to say, and until then the
/// figure beside it is the last one measured.
pub fn resets(at: i64, now: i64) -> String {
    if at <= now {
        "resetting now".into()
    } else {
        format!("resets in {}", span(at - now))
    }
}

/// How long an account lasts, which is the question the whole tool exists for: "about 1h
/// 30m left at this rate" while a limit fills, or "resets in 1h 30m" when its reset comes
/// first. The difference is between "switch soon" and "whole again soon". Under a minute it
/// is said in words, "about to run out" or "resets any moment", which read more plainly in
/// a sentence than "<1m". Nothing until there is enough to go on.
pub fn runway(runway: Runway) -> Option<String> {
    Some(match runway {
        Runway::Burning(seconds) if seconds < MINUTE => "about to run out".into(),
        Runway::Burning(seconds) => format!("about {} left at this rate", span(seconds)),
        Runway::Resting(seconds) if seconds < MINUTE => "resets any moment".into(),
        Runway::Resting(seconds) => format!("resets in {}", span(seconds)),
        Runway::Unknown => return None,
    })
}

/// How long a parked login stays usable, in the sentence form: a note under an account not
/// in use, counted in whole days. Without it nothing says a switch to the account is about
/// to stop working. Nothing when the login says nothing about it.
pub fn parked_life(refresh_expires_at: Option<i64>, now: i64) -> Option<String> {
    let left = refresh_expires_at? - now;
    Some(match left / DAY {
        _ if left <= 0 => "Its parked login has expired".into(),
        0 => "Parked login good for under a day".into(),
        1 => "Parked login good for 1 more day".into(),
        days => format!("Parked login good for {days} more days"),
    })
}

/// How long a parked login stays usable, in the column form `pitboard status` puts after
/// "ready" and doctor's parked login check after words of its own: "good for 20d 0h";
/// "expires in 2d 4h" once it is due to be renewed, as [`renewal_due`] decides; and
/// "expired" once it has, where doctor adds when and `pitboard status` says "login expired"
/// in place of "ready".
pub fn parked_life_column(refresh_expires_at: i64, now: i64) -> String {
    match refresh_expires_at - now {
        left if left <= 0 => "expired".into(),
        left if renewal_due(refresh_expires_at, now) => format!("expires in {}", span(left)),
        left => format!("good for {}", span(left)),
    }
}

/// What a renewal run did: how many parked logins were due, and how many of those were
/// renewed. Everything due was going to expire, so nothing due is the good answer, and it
/// has to read like one rather than like a failure to do anything. The sentence does not say
/// why the rest were not renewed, and they fare differently: one whose service could not be
/// reached or asked for less traffic is tried again on the next run, one the service refused
/// has been dropped and its account needs a sign-in, and one that failed is named by its
/// error code in `pitboard renew --json`.
pub fn renewal_note(due: usize, renewed: usize) -> String {
    match (due, renewed) {
        (0, _) => "No parked login was due.".into(),
        (due, 0) => format!("{due} due; none could be renewed this time."),
        (1, 1) => "Renewed one.".into(),
        (due, done) if due == done => format!("Renewed all {done}."),
        (due, done) => format!("Renewed {done} of {due}; the rest are tried again next time."),
    }
}

/// The line under doctor's checks, or over them in the app. Checks that only warn are
/// counted as worth looking at. One that fails outweighs every warning: the line then
/// counts what is broken and says not to switch accounts, since a check fails only when
/// something Pitboard relies on does not hold.
pub fn doctor_summary(levels: impl IntoIterator<Item = Level>) -> String {
    let (mut broken, mut worth_a_look) = (0, 0);
    for level in levels {
        match level {
            Level::Fail => broken += 1,
            Level::Warn => worth_a_look += 1,
            Level::Ok => {}
        }
    }
    match (broken, worth_a_look) {
        (0, 0) => "Everything Pitboard checks is in order.".into(),
        (0, 1) => "One thing is worth looking at.".into(),
        (0, n) => format!("{n} things are worth looking at."),
        (n, _) => format!("{n} broken: do not switch accounts until fixed."),
    }
}

/// Things one after another, as a sentence lists them: "a", "a and b", "a, b and c".
pub(crate) fn listed(mut items: Vec<String>) -> String {
    match items.len() {
        0 => String::new(),
        1 => items.remove(0),
        _ => {
            let last = items.pop().unwrap_or_default();
            format!("{} and {last}", items.join(", "))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Why Pitboard did not switch by itself is said in the app as well as in a terminal,
    /// so it names no command: the app gives up on an interrupted switch with a button.
    #[test]
    fn why_it_did_not_switch_names_no_command() {
        use crate::autoswitch::Skip;
        for why in [
            Skip::SwitchInterrupted,
            Skip::CustomOauth,
            Skip::Overridden(vec!["apiKeyHelper".into()]),
        ] {
            assert!(!not_switching(&why).contains("pitboard "), "{why:?}");
        }
    }

    #[test]
    fn spans_read_at_a_glance() {
        assert_eq!(span(-5), "<1m");
        assert_eq!(span(59), "<1m");
        assert_eq!(span(60 * 47), "47m");
        assert_eq!(span(3600 * 2 + 60 * 5), "2h 05m");
        assert_eq!(span(86_400 * 6 + 3600 * 4 + 59), "6d 4h");
    }

    /// A limit is named by how long it runs, which both services agree on, so a Codex limit
    /// reads the way a Claude Code one does, in a sentence and in a column alike.
    #[test]
    fn a_limit_is_named_for_its_length() {
        for (length, column, sentence) in [
            (18_000, "5h", "5-hour"),
            (604_800, "week", "weekly"),
            (86_400, "day", "daily"),
            (10_800, "3h", "3-hour"),
            (172_800, "2d", "2-day"),
            (5_400, "90m", "90-minute"),
            (1_800, "30m", "30-minute"),
            (45, "45s", "45-second"),
        ] {
            assert_eq!(limit_column("any", Some(length), None), column, "{length}");
            assert_eq!(limit_name("any", Some(length)), sentence, "{length}");
        }
    }

    /// A reading taken before the length was kept names its limit the way it always did,
    /// and so does one whose length makes no sense.
    #[test]
    fn a_limit_of_unknown_length_is_named_from_its_kind() {
        assert_eq!(limit_name("session", None), "5-hour");
        assert_eq!(limit_name("five_hour", Some(0)), "5-hour");
        assert_eq!(limit_name("weekly_all", None), "weekly");
        assert_eq!(limit_name("seven_day", None), "weekly");
        assert_eq!(limit_name("primary_window", None), "primary window");
        assert_eq!(limit_column("session", None, None), "5h");
        assert_eq!(limit_column("seven_day", Some(-1), None), "week");
        assert_eq!(limit_column("primary", None, None), "primary");
        assert_eq!(
            limit_column("weekly_scoped", None, Some("Fable")),
            "week ยท Fable"
        );
        assert_eq!(
            limit_column("weekly_scoped", Some(604_800), Some("Fable")),
            "week ยท Fable"
        );
    }

    /// A limit turns amber at 70% and red at 90%, and stays red past 100%. The steps are
    /// where its colour changes, so each side of each is checked.
    #[test]
    fn a_limits_level_changes_at_seventy_and_at_ninety() {
        assert_eq!(usage_level(0.0), UsageLevel::Plenty);
        assert_eq!(usage_level(69.9), UsageLevel::Plenty);
        assert_eq!(usage_level(70.0), UsageLevel::Low);
        assert_eq!(usage_level(89.9), UsageLevel::Low);
        assert_eq!(usage_level(90.0), UsageLevel::Out);
        assert_eq!(usage_level(100.0), UsageLevel::Out);
        assert_eq!(usage_level(130.0), UsageLevel::Out);
    }

    /// The column beside a bar says how long until a limit resets in the largest units that
    /// stay true, and once that moment has come, that it is resetting.
    #[test]
    fn a_reset_is_said_in_the_largest_units_that_fit() {
        const NOW: i64 = 1_789_935_000;
        let resets = |left: i64| resets(NOW + left, NOW);
        assert_eq!(resets(-5), "resetting now");
        assert_eq!(resets(0), "resetting now");
        assert_eq!(resets(1), "resets in <1m");
        assert_eq!(resets(59), "resets in <1m");
        assert_eq!(resets(125), "resets in 2m");
        assert_eq!(resets(3599), "resets in 59m");
        assert_eq!(resets(3600), "resets in 1h 00m");
        assert_eq!(resets(3600 + 5 * 60), "resets in 1h 05m");
        assert_eq!(resets(3 * 3600 + 25 * 60), "resets in 3h 25m");
        assert_eq!(resets(86_399), "resets in 23h 59m");
        assert_eq!(resets(86_400), "resets in 1d 0h");
        assert_eq!(resets(2 * 86_400 + 4 * 3600 + 59 * 60), "resets in 2d 4h");
    }

    /// The difference between a limit filling and a limit resetting is the difference
    /// between "switch now" and "stay where you are", and both are a number of seconds.
    #[test]
    fn a_runway_reads_as_burning_or_as_resting() {
        assert_eq!(
            runway(Runway::Burning(5400)).as_deref(),
            Some("about 1h 30m left at this rate")
        );
        assert_eq!(
            runway(Runway::Resting(5400)).as_deref(),
            Some("resets in 1h 30m")
        );
        assert_eq!(
            runway(Runway::Resting(3 * 86_400 + 7200)).as_deref(),
            Some("resets in 3d 2h")
        );
        assert_eq!(runway(Runway::Unknown), None, "nothing to go on yet");
    }

    /// Under a minute a span would say "<1m", and a sentence says it more plainly: the limit
    /// is about to run out, or its reset is any moment now. From a minute it is a span again.
    #[test]
    fn under_a_minute_a_runway_is_said_in_words() {
        assert_eq!(
            runway(Runway::Burning(30)).as_deref(),
            Some("about to run out")
        );
        assert_eq!(
            runway(Runway::Burning(0)).as_deref(),
            Some("about to run out")
        );
        assert_eq!(
            runway(Runway::Burning(-10)).as_deref(),
            Some("about to run out")
        );
        assert_eq!(
            runway(Runway::Resting(1)).as_deref(),
            Some("resets any moment")
        );
        assert_eq!(
            runway(Runway::Resting(59)).as_deref(),
            Some("resets any moment")
        );
        assert_eq!(
            runway(Runway::Burning(60)).as_deref(),
            Some("about 1m left at this rate")
        );
        assert_eq!(runway(Runway::Resting(60)).as_deref(), Some("resets in 1m"));
    }

    /// When a parked login stops working decides whether a switch to it will. The sentence
    /// form says nothing with nothing to go on, says a login past it has expired, and
    /// otherwise counts whole days left, one day in the singular.
    #[test]
    fn a_parked_logins_life_in_a_sentence_is_counted_in_whole_days() {
        const NOW: i64 = 1_789_935_000;
        let life = |left: i64| parked_life(Some(NOW + left), NOW);
        assert_eq!(parked_life(None, NOW), None);
        assert_eq!(life(-60).as_deref(), Some("Its parked login has expired"));
        assert_eq!(life(0).as_deref(), Some("Its parked login has expired"));
        assert_eq!(
            life(1).as_deref(),
            Some("Parked login good for under a day")
        );
        assert_eq!(
            life(86_399).as_deref(),
            Some("Parked login good for under a day")
        );
        assert_eq!(
            life(86_400).as_deref(),
            Some("Parked login good for 1 more day")
        );
        assert_eq!(
            life(2 * 86_400 - 1).as_deref(),
            Some("Parked login good for 1 more day")
        );
        assert_eq!(
            life(2 * 86_400).as_deref(),
            Some("Parked login good for 2 more days")
        );
        assert_eq!(
            life(30 * 86_400).as_deref(),
            Some("Parked login good for 30 more days")
        );
    }

    /// The column form beside "ready" in `pitboard status`: what is left of a parked login
    /// to the minute, and from the moment it is due to be renewed, that it expires.
    #[test]
    fn a_parked_logins_life_in_a_column_says_when_it_expires() {
        use crate::doctor::RENEW_WITHIN;
        const NOW: i64 = 1_789_935_000;
        let life = |left: i64| parked_life_column(NOW + left, NOW);
        assert_eq!(life(20 * 86_400), "good for 20d 0h");
        assert_eq!(life(RENEW_WITHIN), "good for 3d 0h");
        assert_eq!(life(RENEW_WITHIN - 1), "expires in 2d 23h");
        assert_eq!(life(3_900), "expires in 1h 05m");
        assert_eq!(life(30), "expires in <1m");
        assert_eq!(life(0), "expired");
        assert_eq!(life(-60), "expired");
    }

    /// Nothing due is the ordinary case, and it has to read as ordinary rather than as a
    /// failure to do anything. Otherwise the sentence counts what was renewed of what was
    /// due.
    #[test]
    fn a_renewal_run_says_what_it_did() {
        assert_eq!(renewal_note(0, 0), "No parked login was due.");
        assert_eq!(renewal_note(1, 1), "Renewed one.");
        assert_eq!(renewal_note(2, 2), "Renewed all 2.");
        assert_eq!(
            renewal_note(2, 1),
            "Renewed 1 of 2; the rest are tried again next time."
        );
        assert_eq!(
            renewal_note(1, 0),
            "1 due; none could be renewed this time."
        );
    }

    /// Warnings are counted as things worth looking at. A check that fails outweighs every
    /// warning: the sentence then counts only what is broken, and says not to switch.
    #[test]
    fn the_doctor_summary_says_not_to_switch_while_a_check_fails() {
        assert_eq!(
            doctor_summary([Level::Ok, Level::Ok]),
            "Everything Pitboard checks is in order."
        );
        assert_eq!(
            doctor_summary([]),
            "Everything Pitboard checks is in order."
        );
        assert_eq!(
            doctor_summary([Level::Ok, Level::Warn]),
            "One thing is worth looking at."
        );
        assert_eq!(
            doctor_summary([Level::Warn, Level::Ok, Level::Warn]),
            "2 things are worth looking at."
        );
        assert_eq!(
            doctor_summary([Level::Fail]),
            "1 broken: do not switch accounts until fixed."
        );
        assert_eq!(
            doctor_summary([Level::Warn, Level::Ok, Level::Fail]),
            "1 broken: do not switch accounts until fixed."
        );
        assert_eq!(
            doctor_summary([Level::Fail, Level::Warn, Level::Fail]),
            "2 broken: do not switch accounts until fixed."
        );
    }
}