use crate::doctor::{Level, renewal_due};
use crate::host::{Os, WINDOWS_FLOOR, token};
use crate::pace::{Pace, Standing};
use crate::usage::whole;
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!("{}% of its {name} limit", whole(limit.percent))
}
pub fn watching(
account: &str,
used: Option<&str>,
as_of: Option<&str>,
held_until: Option<&str>,
) -> String {
let mut said = format!("Watching {account}");
if let Some(used) = used {
said.push_str(&format!(": {used}"));
}
if let Some(as_of) = as_of {
said.push_str(&format!(", as of {as_of}"));
}
said.push('.');
if let Some(until) = held_until {
said.push_str(&format!(
" Anthropic holds Pitboard off asking again until {until}."
));
}
said
}
pub fn not_switching(
why: &crate::autoswitch::Skip,
threshold: crate::autoswitch::Threshold,
) -> String {
use crate::autoswitch::{ATTEMPTS, SETTLING_SECONDS, Skip};
match why {
Skip::NoRoom { unread } => {
let room = format!(
"no other Claude Code account has room below {}% in every limit",
threshold.percent()
);
if unread.is_empty() {
room
} else {
format!(
"{room}, and there is no reading of {} from Anthropic yet",
listed(unread.clone())
)
}
}
Skip::AlreadyLeft => {
"this limit was switched away from once already, and is not again before it resets"
.into()
}
Skip::GaveUp => format!(
"{ATTEMPTS} attempts to switch away from this limit failed, and none is made again \
before it resets"
),
Skip::Settling { .. } => format!(
"the account was put in use less than {} minutes ago",
SETTLING_SECONDS / 60
),
Skip::Overridden(names) => format!(
"Claude Code signs in another way, set by {}, so a switch would change nothing its \
sessions use",
names.join(", ")
),
}
}
pub fn not_watching(why: &crate::autoswitch::Blind) -> String {
use crate::autoswitch::Blind;
match why {
Blind::SwitchInterrupted => {
"a switch was interrupted, and the next change you make finishes it".into()
}
Blind::CustomOauth => "Claude Code uses a custom OAuth endpoint, and Pitboard does not \
act on Claude Code then"
.into(),
Blind::NothingSignedIn => "Claude Code has no login stored".into(),
Blind::NotEnrolled { email } => {
format!("Claude Code has {email}'s login stored, and that account is not enrolled")
}
Blind::Unidentified { detail, .. } => {
format!("whose login Claude Code has stored could not be told ({detail})")
}
Blind::NoReading { account } => {
format!("there is no reading of {account} from Anthropic yet")
}
}
}
pub fn kept_until_renewed() -> &'static str {
"keep the account they are on until their login is next renewed, or until they are \
started again"
}
pub fn nothing_left() -> &'static str {
"Nothing is left behind the store Claude Code keeps its login in, so there is nothing to \
put away."
}
pub fn left_lines(left: &crate::switch::Left) -> Vec<String> {
use crate::switch::{Foreseen, Kept};
if let Foreseen::NotEnrolled(_) = left.login {
return vec![left_login(left)];
}
let held_a_login = left.login != Foreseen::Kept(Kept::NoLogin);
std::iter::once(left_login(left))
.chain(dropped_keys(&left.dropped, false))
.chain(std::iter::once(once_stowed(held_a_login)))
.collect()
}
pub fn stowed_lines(stowed: &crate::switch::Stowed) -> Vec<String> {
let held_a_login = stowed.kept != crate::switch::Kept::NoLogin;
std::iter::once(stowed_login(stowed))
.chain(dropped_keys(&stowed.dropped, true))
.chain(std::iter::once(once_stowed(held_a_login)))
.collect()
}
fn left_login(left: &crate::switch::Left) -> String {
use crate::switch::{Foreseen, Kept};
let path = left.path.display();
match &left.login {
Foreseen::Kept(Kept::NoLogin) => {
format!("{path} holds no Claude Code login. Putting it away deletes it.")
}
Foreseen::Kept(Kept::Refused) => format!(
"{path} holds a Claude Code login Anthropic no longer accepts. Putting it away \
deletes it."
),
Foreseen::Kept(Kept::Stored { label }) => format!(
"{path} holds the login Claude Code has stored{}. Putting it away deletes the file, \
and that login stays where it is.",
of_account(label.as_deref())
),
Foreseen::Kept(Kept::AlreadyParked { label }) => format!(
"{path} holds the login Pitboard keeps parked for `{label}`. Putting it away deletes \
the file, and the parked login stays."
),
Foreseen::Kept(Kept::SecondSignIn { label }) => format!(
"{path} holds another login of `{label}`, the account in use, whose own login Claude \
Code has stored. Putting it away drops this second sign-in with the file."
),
Foreseen::Kept(Kept::ParkedNow { label }) => format!(
"{path} holds `{label}`'s login, and Pitboard holds no parked login of `{label}` to \
keep in its place. Putting it away parks this one for `{label}`, then deletes the \
file."
),
Foreseen::Kept(Kept::ParkKept { label }) => format!(
"{path} holds another login of `{label}`, which keeps the login Pitboard has parked \
for it. Putting it away deletes the file."
),
Foreseen::NotEnrolled(owner) => format!(
"{path} holds {}'s login{}, an account not enrolled here, so Pitboard cannot put it \
away. Enrol that account first, with `pitboard enroll <label> --sign-in`, or with \
`pitboard enroll <label>` while Claude Code is signed in to it.",
owner.email,
in_organisation(&owner.organization_uuid)
),
Foreseen::Untold => format!(
"{path} holds a Claude Code login whose access token has expired. Putting it away \
renews it and writes it back to the file first, then asks Anthropic whose it is, \
and goes on only for an account enrolled here."
),
}
}
fn stowed_login(stowed: &crate::switch::Stowed) -> String {
use crate::switch::Kept;
let path = stowed.path.display();
match &stowed.kept {
Kept::NoLogin => format!("Deleted {path}, which held no Claude Code login."),
Kept::Refused => format!(
"Deleted {path}: Anthropic no longer accepts the login it held, so that login was \
no longer valid."
),
Kept::Stored { label } => format!(
"Deleted {path}, which held the login Claude Code has stored{}. That login stays \
where it is.",
of_account(label.as_deref())
),
Kept::AlreadyParked { label } => {
format!("Deleted {path}, which held the login Pitboard keeps parked for `{label}`.")
}
Kept::SecondSignIn { label } => format!(
"Deleted {path}, which held a second sign-in of `{label}`, the account in use. That \
login was dropped; the one Claude Code has stored stays."
),
Kept::ParkedNow { label } => {
format!("Parked the login {path} held for `{label}`, then deleted the file.")
}
Kept::ParkKept { label } => format!(
"Deleted {path}, which held another login of `{label}`. `{label}` keeps the login \
Pitboard had parked for it."
),
}
}
fn dropped_keys(keys: &[String], gone: bool) -> Option<String> {
let one = match keys.len() {
0 => return None,
n => n == 1,
};
let names = listed(keys.iter().map(|key| format!("`{key}`")).collect());
let (holds, goes) = match (gone, one) {
(false, true) => ("holds", "goes"),
(false, false) => ("holds", "go"),
(true, _) => ("held", "went"),
};
let (count, them) = if one {
("1 other key".to_string(), "it")
} else {
(format!("{} other keys", keys.len()), "them")
};
Some(format!(
"It also {holds} {count}, {names}, which {goes} with the file: Pitboard does not move \
{them}, and Claude Code's document in the keychain keeps its own."
))
}
fn once_stowed(held_a_login: bool) -> String {
let follow = format!(
"Once the file is gone, Claude Code sessions already running follow a switch within \
{} seconds again",
crate::switch::ADOPTION_CEILING_SECONDS
);
if held_a_login {
format!(
"{follow}, and one that signed in with its login, such as one over SSH, is signed \
out."
)
} else {
format!("{follow}.")
}
}
fn of_account(label: Option<&str>) -> String {
label.map_or_else(String::new, |label| format!(", `{label}`'s"))
}
pub(crate) fn in_organisation(organization: &str) -> String {
if organization.is_empty() {
String::new()
} else {
format!(", in organisation {organization}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UsageLevel {
Plenty,
Low,
Out,
}
pub fn usage_level(percent: f64) -> UsageLevel {
match whole(percent) {
90.. => UsageLevel::Out,
70.. => UsageLevel::Low,
_ => 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 = whole(pace.delta.abs());
match pace.standing {
Standing::Over { .. } => format!("{points}% over pace"),
Standing::Under => format!("{points}% 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) struct ChangesNothing {
pub(crate) refusal: String,
pub(crate) because: String,
pub(crate) way_out: &'static str,
pub(crate) to_ask_again: &'static str,
}
pub(crate) fn elevated(os: Os, why: Option<&'static str>) -> ChangesNothing {
let because = |cannot_tell: &str| match why {
Some(why) => format!("Pitboard runs {why}"),
None => format!("Pitboard cannot tell whether it runs {cannot_tell}"),
};
match os {
Os::MacOs | Os::Linux => ChangesNothing {
refusal: match why {
Some(_) => "Pitboard changes nothing when it runs as root or with sudo. Run it as \
yourself."
.into(),
None => "Pitboard changes nothing when it cannot tell whether it runs as root or \
with sudo. Run it as yourself."
.into(),
},
because: because("as root or with sudo"),
way_out: "Run it as yourself.",
to_ask_again: "Run it as yourself to ask again.",
},
Os::Windows => {
let (way_out, to_ask_again) = match why {
Some(token::IN_EVERY_PROGRAM) => (
"That is so with User Account Control off and in the built-in Administrator \
account: run it from a standard account, or turn User Account Control on.",
"Run it from a standard account, or with User Account Control on, to ask \
again.",
),
_ => (
"Run it from a terminal that is not elevated (not Run as administrator).",
"Run it from a terminal that is not elevated to ask again.",
),
};
ChangesNothing {
refusal: match why {
Some(why) => format!("Pitboard changes nothing when it runs {why}. {way_out}"),
None => format!(
"Pitboard changes nothing when it cannot tell whether it runs elevated. \
{way_out}"
),
},
because: because("elevated"),
way_out,
to_ask_again,
}
}
}
}
pub(crate) fn too_old(build: Option<u32>) -> ChangesNothing {
let way_out = "It changes things on Windows 11 24H2 and later, and on Windows Server 2025: \
update Windows to use it here.";
let to_ask_again = "Update Windows to ask again.";
match build {
Some(build) => ChangesNothing {
refusal: format!("Pitboard changes nothing on Windows build {build}. {way_out}"),
because: format!(
"Pitboard runs on Windows build {build}, older than Windows 11 24H2 (build \
{WINDOWS_FLOOR})"
),
way_out,
to_ask_again,
},
None => ChangesNothing {
refusal: format!(
"Pitboard changes nothing when it cannot tell which build of Windows it runs \
on. {way_out}"
),
because: "Pitboard cannot tell which build of Windows it runs on".into(),
way_out,
to_ask_again,
},
}
}
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::{Blind, Skip, Threshold};
for why in [
Skip::NoRoom {
unread: vec!["spare".into()],
},
Skip::AlreadyLeft,
Skip::GaveUp,
Skip::Settling { until: 0 },
Skip::Overridden(vec!["apiKeyHelper".into()]),
] {
let said = not_switching(&why, Threshold::DEFAULT);
assert!(!said.contains("pitboard "), "{why:?}");
}
for why in [
Blind::SwitchInterrupted,
Blind::CustomOauth,
Blind::NothingSignedIn,
Blind::NotEnrolled {
email: "me@example.com".into(),
},
Blind::Unidentified {
detail: "could not reach Anthropic: no route to host".into(),
until: 0,
},
Blind::NoReading {
account: "work".into(),
},
] {
assert!(!not_watching(&why).contains("pitboard "), "{why:?}");
}
}
#[test]
fn root_and_sudo_are_told_to_run_it_as_themselves() {
for os in [Os::MacOs, Os::Linux] {
for why in ["as root", "with sudo"] {
let said = elevated(os, Some(why));
assert_eq!(
said.refusal,
"Pitboard changes nothing when it runs as root or with sudo. Run it as \
yourself."
);
assert_eq!(said.because, format!("Pitboard runs {why}"));
assert_eq!(said.way_out, "Run it as yourself.");
assert_eq!(said.to_ask_again, "Run it as yourself to ask again.");
}
let unknown = elevated(os, None);
assert_eq!(
unknown.refusal,
"Pitboard changes nothing when it cannot tell whether it runs as root or with \
sudo. Run it as yourself."
);
assert_eq!(
unknown.because,
"Pitboard cannot tell whether it runs as root or with sudo"
);
}
}
#[test]
fn an_elevated_windows_run_is_told_which_terminal_to_use() {
let terminal = "Run it from a terminal that is not elevated (not Run as administrator).";
for (why, refusal) in [
(
Some(token::AS_ADMINISTRATOR),
"Pitboard changes nothing when it runs as administrator.",
),
(
Some(token::AS_A_SERVICE_ACCOUNT),
"Pitboard changes nothing when it runs as a service account.",
),
(
None,
"Pitboard changes nothing when it cannot tell whether it runs elevated.",
),
] {
let said = elevated(Os::Windows, why);
assert_eq!(said.refusal, format!("{refusal} {terminal}"), "{why:?}");
assert_eq!(said.way_out, terminal, "{why:?}");
assert_eq!(
said.to_ask_again,
"Run it from a terminal that is not elevated to ask again."
);
}
assert_eq!(
elevated(Os::Windows, Some(token::AS_ADMINISTRATOR)).because,
"Pitboard runs as administrator"
);
assert_eq!(
elevated(Os::Windows, None).because,
"Pitboard cannot tell whether it runs elevated"
);
let always = elevated(Os::Windows, Some(token::IN_EVERY_PROGRAM));
assert_eq!(
always.refusal,
"Pitboard changes nothing when it runs elevated, as every program this Windows \
account starts does. That is so with User Account Control off and in the \
built-in Administrator account: run it from a standard account, or turn User \
Account Control on."
);
assert_eq!(
always.because,
"Pitboard runs elevated, as every program this Windows account starts does"
);
assert_eq!(
always.to_ask_again,
"Run it from a standard account, or with User Account Control on, to ask again."
);
}
#[test]
fn an_old_windows_is_told_which_it_needs() {
let old = too_old(Some(22631));
assert_eq!(
old.refusal,
"Pitboard changes nothing on Windows build 22631. It changes things on Windows 11 \
24H2 and later, and on Windows Server 2025: update Windows to use it here."
);
assert_eq!(
old.because,
"Pitboard runs on Windows build 22631, older than Windows 11 24H2 (build 26100)"
);
assert_eq!(old.to_ask_again, "Update Windows to ask again.");
let unknown = too_old(None);
assert_eq!(
unknown.refusal,
"Pitboard changes nothing when it cannot tell which build of Windows it runs on. \
It changes things on Windows 11 24H2 and later, and on Windows Server 2025: update \
Windows to use it here."
);
assert_eq!(
unknown.because,
"Pitboard cannot tell which build of Windows it runs on"
);
}
#[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.4), UsageLevel::Plenty);
assert_eq!(usage_level(69.5), UsageLevel::Low);
assert_eq!(usage_level(89.4), UsageLevel::Low);
assert_eq!(usage_level(89.5), 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_share_of_a_limit_is_said_as_it_is_drawn() {
let limit = |percent: f64| crate::usage::Window {
kind: "session".into(),
scope: None,
percent,
resets_at: None,
is_active: true,
severity: None,
length_seconds: Some(5 * HOUR),
};
assert_eq!(share_of_limit(&limit(94.5)), "95% of its 5-hour limit");
assert_eq!(share_of_limit(&limit(96.4)), "96% of its 5-hour limit");
}
#[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."
);
}
}