1use 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
20pub 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
36pub 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
63pub 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
85pub 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
92pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum UsageLevel {
117 Plenty,
119 Low,
121 Out,
123}
124
125pub 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
137pub 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
148pub 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
160pub 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
170pub 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
184pub 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
197pub 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
210pub 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
227pub 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
241pub 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
262pub(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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}