use crate::doctor::{Level, renewal_due};
use crate::pace::{Pace, Standing};
const MINUTE: i64 = 60;
const HOUR: i64 = 60 * MINUTE;
const DAY: i64 = 24 * HOUR;
const WEEK: i64 = 7 * DAY;
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"),
}
}
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,
}
}
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('_', " "),
},
}
}
pub fn share_of_limit(limit: &crate::usage::Window) -> String {
let name = scoped_limit_name(&limit.kind, limit.length_seconds, limit.scope.as_deref());
format!("{:.0}% of its {name} limit", limit.percent)
}
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(", ")
),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UsageLevel {
Plenty,
Low,
Out,
}
pub fn usage_level(percent: f64) -> UsageLevel {
if percent >= 90.0 {
UsageLevel::Out
} else if percent >= 70.0 {
UsageLevel::Low
} else {
UsageLevel::Plenty
}
}
pub fn resets(at: i64, now: i64) -> String {
if at <= now {
"resetting now".into()
} else {
format!("resets in {}", span(at - now))
}
}
pub fn pace_column(pace: &Pace) -> String {
let points = pace.delta.abs().round();
match pace.standing {
Standing::Over { .. } => format!("{points:.0}% over pace"),
Standing::Under => format!("{points:.0}% under pace"),
Standing::Even => "on pace".into(),
}
}
pub fn scoped_limit_name(kind: &str, length_seconds: Option<i64>, scope: Option<&str>) -> String {
let name = limit_name(kind, length_seconds);
match scope {
Some(scope) => format!("{name} {scope}"),
None => name,
}
}
pub fn runs_out(limit: &str, in_seconds: i64) -> String {
if in_seconds < MINUTE {
format!("{limit} limit is about to run out")
} else {
format!(
"{limit} limit runs out in {} at this pace",
span(in_seconds)
)
}
}
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"),
})
}
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)),
}
}
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."),
}
}
pub fn login(tool: crate::provider::ProviderId, email: &str, shares_email: bool) -> String {
use crate::provider::ProviderId;
if !shares_email {
return email.to_owned();
}
let group = match tool {
ProviderId::Claude => "organisation",
ProviderId::Codex => "workspace",
};
format!("{email} in another {group}")
}
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."),
}
}
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::*;
#[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");
}
#[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}");
}
}
#[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"
);
}
#[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);
}
#[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");
}
#[test]
fn a_pace_is_said_as_over_under_or_on() {
let pace = |delta: f64, standing| crate::pace::Pace {
expected: 40.0,
delta,
standing,
};
use crate::pace::Standing;
assert_eq!(
pace_column(&pace(27.4, Standing::Over { runs_out_in: 9_000 })),
"27% over pace"
);
assert_eq!(pace_column(&pace(-12.6, Standing::Under)), "13% under pace");
assert_eq!(pace_column(&pace(3.0, Standing::Even)), "on pace");
assert_eq!(pace_column(&pace(-4.0, Standing::Even)), "on pace");
}
#[test]
fn a_limit_running_out_is_named_with_when() {
assert_eq!(
runs_out("weekly", 19 * 3600 + 5 * 60),
"weekly limit runs out in 19h 05m at this pace"
);
assert_eq!(
runs_out(
&scoped_limit_name("weekly_scoped", Some(WEEK), Some("Fable")),
2 * DAY
),
"weekly Fable limit runs out in 2d 0h at this pace"
);
assert_eq!(runs_out("5-hour", 30), "5-hour limit is about to run out");
assert_eq!(runs_out("5-hour", 0), "5-hour limit is about to run out");
}
#[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")
);
}
#[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");
}
#[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."
);
}
#[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."
);
}
}