Skip to main content

pitboard_core/
words.rs

1//! What Pitboard says in more than one place, each written once as a function of typed
2//! values: its sentences, the words of its columns, and the level a limit's usage is at,
3//! where the command line's colours and the app's tints change. The command line calls
4//! these functions directly, and `pitboard-ffi`'s model calls them as it makes what an app
5//! shows, so where both say a thing they say it in the same words. The bindings export none
6//! of them: an app shows what the snapshot says. A thing said both in a column and in a
7//! sentence, such as a limit's name or a parked login's life, has a function for each form.
8//!
9//! All of it is English. Clock times are not here: the command line writes them with
10//! `time::moment`, and the app in the format its Mac is set to.
11
12use crate::doctor::{Level, renewal_due};
13use crate::pace::{Pace, Standing};
14
15const MINUTE: i64 = 60;
16const HOUR: i64 = 60 * MINUTE;
17const DAY: i64 = 24 * HOUR;
18const WEEK: i64 = 7 * DAY;
19
20/// A length of time to the precision a person reads: "6d 4h", "2h 05m", "47m", "<1m".
21///
22/// Minutes beside hours take two digits, so a column of them lines up as they tick. Under a
23/// minute is "<1m": "0m" reads as though nothing were left when the difference is seconds
24/// either way.
25pub fn span(seconds: i64) -> String {
26    let s = seconds.max(0);
27    let (days, hours, minutes) = (s / DAY, s % DAY / HOUR, s % HOUR / MINUTE);
28    match (days, hours) {
29        (0, 0) if minutes == 0 => "<1m".into(),
30        (0, 0) => format!("{minutes}m"),
31        (0, h) => format!("{h}h {minutes:02}m"),
32        (d, h) => format!("{d}d {h}h"),
33    }
34}
35
36/// A limit in the column form, beside its bar: "5h", "week", "day", "3d", "30m", "45s", and
37/// "week · Fable" for one scoped to a model.
38///
39/// By how long it runs, where that is known, and otherwise by its kind. The length is what
40/// makes two tools' limits comparable: OpenAI times every window and names none, Anthropic
41/// names every window and times none, and Pitboard knows the length of each either way. A
42/// kind is only the fallback, for a reading remembered from before the length was kept.
43pub fn limit_column(kind: &str, length_seconds: Option<i64>, scope: Option<&str>) -> String {
44    let base = match length_seconds.filter(|s| *s > 0) {
45        Some(WEEK) => "week".into(),
46        Some(DAY) => "day".into(),
47        Some(s) if s % DAY == 0 => format!("{}d", s / DAY),
48        Some(s) if s % HOUR == 0 => format!("{}h", s / HOUR),
49        Some(s) if s % MINUTE == 0 => format!("{}m", s / MINUTE),
50        Some(s) => format!("{s}s"),
51        None => match kind {
52            "session" | "five_hour" => "5h".into(),
53            "weekly_all" | "seven_day" | "weekly_scoped" => "week".into(),
54            other => other.into(),
55        },
56    };
57    match scope {
58        Some(scope) => format!("{base} · {scope}"),
59        None => base,
60    }
61}
62
63/// A limit in the sentence form: "5-hour", "weekly", "daily", "3-day", "30-minute".
64///
65/// From its length where that is known, as [`limit_column`] is: "session" means nothing to
66/// somebody reading about a Codex account. Without its scope, which the sentence places
67/// itself, as in "weekly Fable 98%".
68pub fn limit_name(kind: &str, length_seconds: Option<i64>) -> String {
69    match length_seconds.filter(|s| *s > 0) {
70        Some(WEEK) => "weekly".into(),
71        Some(DAY) => "daily".into(),
72        Some(s) if s % DAY == 0 => format!("{}-day", s / DAY),
73        Some(s) if s % HOUR == 0 => format!("{}-hour", s / HOUR),
74        Some(s) if s % MINUTE == 0 => format!("{}-minute", s / MINUTE),
75        Some(s) => format!("{s}-second"),
76        None => match kind {
77            "session" | "five_hour" => "5-hour".into(),
78            "seven_day" => "weekly".into(),
79            weekly if weekly.starts_with("weekly") => "weekly".into(),
80            other => other.replace('_', " "),
81        },
82    }
83}
84
85/// How much of a limit an account has used, in a sentence about an automatic switch:
86/// "96% of its 5-hour limit", or "97% of its weekly Opus limit" for one limit of a model.
87pub fn share_of_limit(limit: &crate::usage::Window) -> String {
88    let name = scoped_limit_name(&limit.kind, limit.length_seconds, limit.scope.as_deref());
89    format!("{:.0}% of its {name} limit", limit.percent)
90}
91
92/// Why Pitboard does not switch Claude Code by itself where it would have, as a clause that
93/// says what to do about it where anything can be done. It names no command: the app says
94/// it too.
95pub fn not_switching(why: &crate::autoswitch::Skip) -> String {
96    use crate::autoswitch::Skip;
97    match why {
98        Skip::SwitchInterrupted => {
99            "a switch was interrupted, and the next change you make finishes it".into()
100        }
101        Skip::CustomOauth => "Claude Code uses a custom OAuth endpoint, and Pitboard does not \
102                              act on Claude Code then"
103            .into(),
104        Skip::Overridden(names) => format!(
105            "Claude Code signs in another way, set by {}, so a switch would change nothing its \
106             sessions use",
107            names.join(", ")
108        ),
109    }
110}
111
112/// How much of a limit is used, in three steps. The command line's colours and the app's
113/// tints change where these do, and the words always say the number itself, because not
114/// everybody sees a colour.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum UsageLevel {
117    /// Under 70%.
118    Plenty,
119    /// From 70%.
120    Low,
121    /// From 90%, and past 100%.
122    Out,
123}
124
125/// The step a limit is at, from the share of it used: `percent` as a reading gives it, which
126/// passes 100 when a service reports more used than the limit.
127pub fn usage_level(percent: f64) -> UsageLevel {
128    if percent >= 90.0 {
129        UsageLevel::Out
130    } else if percent >= 70.0 {
131        UsageLevel::Low
132    } else {
133        UsageLevel::Plenty
134    }
135}
136
137/// When a limit resets, beside its bar: "resets in 2h 05m", and "resetting now" once that
138/// moment has come. Whether it did reset is the next reading's to say, and until then the
139/// figure beside it is the last one measured.
140pub fn resets(at: i64, now: i64) -> String {
141    if at <= now {
142        "resetting now".into()
143    } else {
144        format!("resets in {}", span(at - now))
145    }
146}
147
148/// How far a limit is from an even pace, beside its bar: "27% over pace", "13% under
149/// pace", "on pace". Over and under rather than ahead and behind, since ahead reads as good
150/// news and is the warning.
151pub fn pace_column(pace: &Pace) -> String {
152    let points = pace.delta.abs().round();
153    match pace.standing {
154        Standing::Over { .. } => format!("{points:.0}% over pace"),
155        Standing::Under => format!("{points:.0}% under pace"),
156        Standing::Even => "on pace".into(),
157    }
158}
159
160/// A limit's name in a sentence with its model, where it is scoped to one: "weekly",
161/// "weekly Fable", "5-hour".
162pub fn scoped_limit_name(kind: &str, length_seconds: Option<i64>, scope: Option<&str>) -> String {
163    let name = limit_name(kind, length_seconds);
164    match scope {
165        Some(scope) => format!("{name} {scope}"),
166        None => name,
167    }
168}
169
170/// The limit of an account that runs out first at its pace, named as a sentence names it:
171/// "weekly limit runs out in 19h 05m at this pace". Under a minute it is "about to run
172/// out", which reads more plainly than "<1m".
173pub fn runs_out(limit: &str, in_seconds: i64) -> String {
174    if in_seconds < MINUTE {
175        format!("{limit} limit is about to run out")
176    } else {
177        format!(
178            "{limit} limit runs out in {} at this pace",
179            span(in_seconds)
180        )
181    }
182}
183
184/// How long a parked login stays usable, in the sentence form: a note under an account not
185/// in use, counted in whole days. Without it nothing says a switch to the account is about
186/// to stop working. Nothing when the login says nothing about it.
187pub fn parked_life(refresh_expires_at: Option<i64>, now: i64) -> Option<String> {
188    let left = refresh_expires_at? - now;
189    Some(match left / DAY {
190        _ if left <= 0 => "Its parked login has expired".into(),
191        0 => "Parked login good for under a day".into(),
192        1 => "Parked login good for 1 more day".into(),
193        days => format!("Parked login good for {days} more days"),
194    })
195}
196
197/// How long a parked login stays usable, in the column form `pitboard status` puts after
198/// "ready" and doctor's parked login check after words of its own: "good for 20d 0h";
199/// "expires in 2d 4h" once it is due to be renewed, as [`renewal_due`] decides; and
200/// "expired" once it has, where doctor adds when and `pitboard status` says "login expired"
201/// in place of "ready".
202pub fn parked_life_column(refresh_expires_at: i64, now: i64) -> String {
203    match refresh_expires_at - now {
204        left if left <= 0 => "expired".into(),
205        left if renewal_due(refresh_expires_at, now) => format!("expires in {}", span(left)),
206        left => format!("good for {}", span(left)),
207    }
208}
209
210/// What a renewal run did: how many parked logins were due, and how many of those were
211/// renewed. Everything due was going to expire, so nothing due is the good answer, and it
212/// has to read like one rather than like a failure to do anything. The sentence does not say
213/// why the rest were not renewed, and they fare differently: one whose service could not be
214/// reached or asked for less traffic is tried again on the next run, one the service refused
215/// has been dropped and its account needs a sign-in, and one that failed is named by its
216/// error code in `pitboard renew --json`.
217pub fn renewal_note(due: usize, renewed: usize) -> String {
218    match (due, renewed) {
219        (0, _) => "No parked login was due.".into(),
220        (due, 0) => format!("{due} due; none could be renewed this time."),
221        (1, 1) => "Renewed one.".into(),
222        (due, done) if due == done => format!("Renewed all {done}."),
223        (due, done) => format!("Renewed {done} of {due}; the rest are tried again next time."),
224    }
225}
226
227/// A login as a message names it: by its email, and as another organisation's, or for
228/// Codex another workspace's, where the account it is told apart from has that email too.
229pub fn login(tool: crate::provider::ProviderId, email: &str, shares_email: bool) -> String {
230    use crate::provider::ProviderId;
231    if !shares_email {
232        return email.to_owned();
233    }
234    let group = match tool {
235        ProviderId::Claude => "organisation",
236        ProviderId::Codex => "workspace",
237    };
238    format!("{email} in another {group}")
239}
240
241/// The line under doctor's checks, or over them in the app. Checks that only warn are
242/// counted as worth looking at. One that fails outweighs every warning: the line then
243/// counts what is broken and says not to switch accounts, since a check fails only when
244/// something Pitboard relies on does not hold.
245pub fn doctor_summary(levels: impl IntoIterator<Item = Level>) -> String {
246    let (mut broken, mut worth_a_look) = (0, 0);
247    for level in levels {
248        match level {
249            Level::Fail => broken += 1,
250            Level::Warn => worth_a_look += 1,
251            Level::Ok => {}
252        }
253    }
254    match (broken, worth_a_look) {
255        (0, 0) => "Everything Pitboard checks is in order.".into(),
256        (0, 1) => "One thing is worth looking at.".into(),
257        (0, n) => format!("{n} things are worth looking at."),
258        (n, _) => format!("{n} broken: do not switch accounts until fixed."),
259    }
260}
261
262/// Things one after another, as a sentence lists them: "a", "a and b", "a, b and c".
263pub(crate) fn listed(mut items: Vec<String>) -> String {
264    match items.len() {
265        0 => String::new(),
266        1 => items.remove(0),
267        _ => {
268            let last = items.pop().unwrap_or_default();
269            format!("{} and {last}", items.join(", "))
270        }
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    /// Why Pitboard did not switch by itself is said in the app as well as in a terminal,
279    /// so it names no command: the app gives up on an interrupted switch with a button.
280    #[test]
281    fn why_it_did_not_switch_names_no_command() {
282        use crate::autoswitch::Skip;
283        for why in [
284            Skip::SwitchInterrupted,
285            Skip::CustomOauth,
286            Skip::Overridden(vec!["apiKeyHelper".into()]),
287        ] {
288            assert!(!not_switching(&why).contains("pitboard "), "{why:?}");
289        }
290    }
291
292    #[test]
293    fn spans_read_at_a_glance() {
294        assert_eq!(span(-5), "<1m");
295        assert_eq!(span(59), "<1m");
296        assert_eq!(span(60 * 47), "47m");
297        assert_eq!(span(3600 * 2 + 60 * 5), "2h 05m");
298        assert_eq!(span(86_400 * 6 + 3600 * 4 + 59), "6d 4h");
299    }
300
301    /// A limit is named by how long it runs, which both services agree on, so a Codex limit
302    /// reads the way a Claude Code one does, in a sentence and in a column alike.
303    #[test]
304    fn a_limit_is_named_for_its_length() {
305        for (length, column, sentence) in [
306            (18_000, "5h", "5-hour"),
307            (604_800, "week", "weekly"),
308            (86_400, "day", "daily"),
309            (10_800, "3h", "3-hour"),
310            (172_800, "2d", "2-day"),
311            (5_400, "90m", "90-minute"),
312            (1_800, "30m", "30-minute"),
313            (45, "45s", "45-second"),
314        ] {
315            assert_eq!(limit_column("any", Some(length), None), column, "{length}");
316            assert_eq!(limit_name("any", Some(length)), sentence, "{length}");
317        }
318    }
319
320    /// A reading taken before the length was kept names its limit the way it always did,
321    /// and so does one whose length makes no sense.
322    #[test]
323    fn a_limit_of_unknown_length_is_named_from_its_kind() {
324        assert_eq!(limit_name("session", None), "5-hour");
325        assert_eq!(limit_name("five_hour", Some(0)), "5-hour");
326        assert_eq!(limit_name("weekly_all", None), "weekly");
327        assert_eq!(limit_name("seven_day", None), "weekly");
328        assert_eq!(limit_name("primary_window", None), "primary window");
329        assert_eq!(limit_column("session", None, None), "5h");
330        assert_eq!(limit_column("seven_day", Some(-1), None), "week");
331        assert_eq!(limit_column("primary", None, None), "primary");
332        assert_eq!(
333            limit_column("weekly_scoped", None, Some("Fable")),
334            "week · Fable"
335        );
336        assert_eq!(
337            limit_column("weekly_scoped", Some(604_800), Some("Fable")),
338            "week · Fable"
339        );
340    }
341
342    /// A limit turns amber at 70% and red at 90%, and stays red past 100%. The steps are
343    /// where its colour changes, so each side of each is checked.
344    #[test]
345    fn a_limits_level_changes_at_seventy_and_at_ninety() {
346        assert_eq!(usage_level(0.0), UsageLevel::Plenty);
347        assert_eq!(usage_level(69.9), UsageLevel::Plenty);
348        assert_eq!(usage_level(70.0), UsageLevel::Low);
349        assert_eq!(usage_level(89.9), UsageLevel::Low);
350        assert_eq!(usage_level(90.0), UsageLevel::Out);
351        assert_eq!(usage_level(100.0), UsageLevel::Out);
352        assert_eq!(usage_level(130.0), UsageLevel::Out);
353    }
354
355    /// The column beside a bar says how long until a limit resets in the largest units that
356    /// stay true, and once that moment has come, that it is resetting.
357    #[test]
358    fn a_reset_is_said_in_the_largest_units_that_fit() {
359        const NOW: i64 = 1_789_935_000;
360        let resets = |left: i64| resets(NOW + left, NOW);
361        assert_eq!(resets(-5), "resetting now");
362        assert_eq!(resets(0), "resetting now");
363        assert_eq!(resets(1), "resets in <1m");
364        assert_eq!(resets(59), "resets in <1m");
365        assert_eq!(resets(125), "resets in 2m");
366        assert_eq!(resets(3599), "resets in 59m");
367        assert_eq!(resets(3600), "resets in 1h 00m");
368        assert_eq!(resets(3600 + 5 * 60), "resets in 1h 05m");
369        assert_eq!(resets(3 * 3600 + 25 * 60), "resets in 3h 25m");
370        assert_eq!(resets(86_399), "resets in 23h 59m");
371        assert_eq!(resets(86_400), "resets in 1d 0h");
372        assert_eq!(resets(2 * 86_400 + 4 * 3600 + 59 * 60), "resets in 2d 4h");
373    }
374
375    /// A pace beside its bar is how far from even it is, said so that over reads as the
376    /// warning it is: "ahead" reads as good news.
377    #[test]
378    fn a_pace_is_said_as_over_under_or_on() {
379        let pace = |delta: f64, standing| crate::pace::Pace {
380            expected: 40.0,
381            delta,
382            standing,
383        };
384        use crate::pace::Standing;
385        assert_eq!(
386            pace_column(&pace(27.4, Standing::Over { runs_out_in: 9_000 })),
387            "27% over pace"
388        );
389        assert_eq!(pace_column(&pace(-12.6, Standing::Under)), "13% under pace");
390        assert_eq!(pace_column(&pace(3.0, Standing::Even)), "on pace");
391        assert_eq!(pace_column(&pace(-4.0, Standing::Even)), "on pace");
392    }
393
394    /// The limit that runs out first is named in the sentence, with its model where it has
395    /// one, and under a minute the sentence says it is about to.
396    #[test]
397    fn a_limit_running_out_is_named_with_when() {
398        assert_eq!(
399            runs_out("weekly", 19 * 3600 + 5 * 60),
400            "weekly limit runs out in 19h 05m at this pace"
401        );
402        assert_eq!(
403            runs_out(
404                &scoped_limit_name("weekly_scoped", Some(WEEK), Some("Fable")),
405                2 * DAY
406            ),
407            "weekly Fable limit runs out in 2d 0h at this pace"
408        );
409        assert_eq!(runs_out("5-hour", 30), "5-hour limit is about to run out");
410        assert_eq!(runs_out("5-hour", 0), "5-hour limit is about to run out");
411    }
412
413    /// When a parked login stops working decides whether a switch to it will. The sentence
414    /// form says nothing with nothing to go on, says a login past it has expired, and
415    /// otherwise counts whole days left, one day in the singular.
416    #[test]
417    fn a_parked_logins_life_in_a_sentence_is_counted_in_whole_days() {
418        const NOW: i64 = 1_789_935_000;
419        let life = |left: i64| parked_life(Some(NOW + left), NOW);
420        assert_eq!(parked_life(None, NOW), None);
421        assert_eq!(life(-60).as_deref(), Some("Its parked login has expired"));
422        assert_eq!(life(0).as_deref(), Some("Its parked login has expired"));
423        assert_eq!(
424            life(1).as_deref(),
425            Some("Parked login good for under a day")
426        );
427        assert_eq!(
428            life(86_399).as_deref(),
429            Some("Parked login good for under a day")
430        );
431        assert_eq!(
432            life(86_400).as_deref(),
433            Some("Parked login good for 1 more day")
434        );
435        assert_eq!(
436            life(2 * 86_400 - 1).as_deref(),
437            Some("Parked login good for 1 more day")
438        );
439        assert_eq!(
440            life(2 * 86_400).as_deref(),
441            Some("Parked login good for 2 more days")
442        );
443        assert_eq!(
444            life(30 * 86_400).as_deref(),
445            Some("Parked login good for 30 more days")
446        );
447    }
448
449    /// The column form beside "ready" in `pitboard status`: what is left of a parked login
450    /// to the minute, and from the moment it is due to be renewed, that it expires.
451    #[test]
452    fn a_parked_logins_life_in_a_column_says_when_it_expires() {
453        use crate::doctor::RENEW_WITHIN;
454        const NOW: i64 = 1_789_935_000;
455        let life = |left: i64| parked_life_column(NOW + left, NOW);
456        assert_eq!(life(20 * 86_400), "good for 20d 0h");
457        assert_eq!(life(RENEW_WITHIN), "good for 3d 0h");
458        assert_eq!(life(RENEW_WITHIN - 1), "expires in 2d 23h");
459        assert_eq!(life(3_900), "expires in 1h 05m");
460        assert_eq!(life(30), "expires in <1m");
461        assert_eq!(life(0), "expired");
462        assert_eq!(life(-60), "expired");
463    }
464
465    /// Nothing due is the ordinary case, and it has to read as ordinary rather than as a
466    /// failure to do anything. Otherwise the sentence counts what was renewed of what was
467    /// due.
468    #[test]
469    fn a_renewal_run_says_what_it_did() {
470        assert_eq!(renewal_note(0, 0), "No parked login was due.");
471        assert_eq!(renewal_note(1, 1), "Renewed one.");
472        assert_eq!(renewal_note(2, 2), "Renewed all 2.");
473        assert_eq!(
474            renewal_note(2, 1),
475            "Renewed 1 of 2; the rest are tried again next time."
476        );
477        assert_eq!(
478            renewal_note(1, 0),
479            "1 due; none could be renewed this time."
480        );
481    }
482
483    /// Warnings are counted as things worth looking at. A check that fails outweighs every
484    /// warning: the sentence then counts only what is broken, and says not to switch.
485    #[test]
486    fn the_doctor_summary_says_not_to_switch_while_a_check_fails() {
487        assert_eq!(
488            doctor_summary([Level::Ok, Level::Ok]),
489            "Everything Pitboard checks is in order."
490        );
491        assert_eq!(
492            doctor_summary([]),
493            "Everything Pitboard checks is in order."
494        );
495        assert_eq!(
496            doctor_summary([Level::Ok, Level::Warn]),
497            "One thing is worth looking at."
498        );
499        assert_eq!(
500            doctor_summary([Level::Warn, Level::Ok, Level::Warn]),
501            "2 things are worth looking at."
502        );
503        assert_eq!(
504            doctor_summary([Level::Fail]),
505            "1 broken: do not switch accounts until fixed."
506        );
507        assert_eq!(
508            doctor_summary([Level::Warn, Level::Ok, Level::Fail]),
509            "1 broken: do not switch accounts until fixed."
510        );
511        assert_eq!(
512            doctor_summary([Level::Fail, Level::Warn, Level::Fail]),
513            "2 broken: do not switch accounts until fixed."
514        );
515    }
516}