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;
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 = 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)
}
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 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,
})
}
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 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_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");
}
#[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"));
}
#[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."
);
}
}