use crate::doctor::{Level, renewal_due};
use crate::host::{Os, WINDOWS_FLOOR, token};
use crate::usage::pace::{Pace, Standing};
use crate::usage::whole;
use std::fmt::Write as _;
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 {
let _ = write!(said, ": {used}");
}
if let Some(as_of) = as_of {
let _ = write!(said, ", as of {as_of}");
}
said.push('.');
if let Some(until) = held_until {
let _ = write!(
said,
" Anthropic holds Pitboard off asking again until {until}."
);
}
said
}
pub fn not_switching(
why: &crate::duty::autoswitch::Skip,
threshold: crate::duty::autoswitch::Threshold,
) -> String {
use crate::duty::autoswitch::{SETTLING_SECONDS, Skip};
use crate::duty::cadence::ATTEMPTS;
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(", ")
),
}
}
const INTERRUPTED: &str = "a switch was interrupted, and the next change you make finishes it";
const CUSTOM_OAUTH: &str =
"Claude Code uses a custom OAuth endpoint, and Pitboard does not act on Claude Code then";
pub fn not_watching(why: &crate::duty::autoswitch::Blind) -> String {
use crate::duty::autoswitch::Blind;
match why {
Blind::SwitchInterrupted => INTERRUPTED.into(),
Blind::CustomOauth => CUSTOM_OAUTH.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 not_warming(why: &crate::duty::warm::Halt) -> String {
use crate::duty::warm::Halt;
match why {
Halt::Unbuilt => "Pitboard does not warm up accounts on this system yet".into(),
Halt::SwitchInterrupted => INTERRUPTED.into(),
Halt::CustomOauth => CUSTOM_OAUTH.into(),
Halt::Overridden(names) => format!(
"Claude Code's managed settings make it sign in another way, set by {}",
names.join(", ")
),
Halt::ProgramMissing => "Claude Code is not installed where Pitboard looks".into(),
}
}
pub(crate) fn warm_failed(why: &crate::duty::warm::Failure) -> String {
use crate::duty::warm::Failure;
match why {
Failure::LimitReached => {
"a limit of it is used up, and Anthropic refused the request".into()
}
Failure::LoginRefused => "Anthropic refused its login; sign in to it again".into(),
Failure::NotStarted => {
"the request was answered, and Anthropic still has no period running".into()
}
Failure::Unreachable => "Anthropic could not be reached".into(),
Failure::TimedOut => "Claude Code did not finish in time".into(),
Failure::Failed(said) => format!("Claude Code failed ({said})"),
}
}
pub fn warm_standing(
standing: &crate::duty::warm::Standing,
moment: &dyn Fn(i64) -> String,
) -> String {
use crate::duty::warm::{Hold, Standing};
match standing {
Standing::Due => "warms up now".into(),
Standing::Running {
until,
next: Some(next),
} if next == until => format!("warms up again at {}", moment(*next)),
Standing::Running {
until,
next: Some(next),
} => format!(
"has its 5-hour period running until {}, and warms up again at {}",
moment(*until),
moment(*next)
),
Standing::Running { until, next: None } => {
format!("has its 5-hour period running until {}", moment(*until))
}
Standing::Resting { at } => format!("warms up at {}", moment(*at)),
Standing::Waiting { until } => format!(
"tries again at {}, after a warm-up came to nothing",
moment(*until)
),
Standing::Held(Hold::NoReading) => "waits for a reading from Anthropic".into(),
Standing::Held(Hold::NothingToStart) => "has no 5-hour limit to start".into(),
Standing::Held(Hold::LimitUsedUp { limit }) => {
let name = scoped_limit_name(&limit.kind, limit.length_seconds, limit.scope.as_deref());
match limit.resets_at {
Some(at) => format!("has its {name} limit used up until {}", moment(at)),
None => format!("has its {name} limit used up"),
}
}
Standing::Held(Hold::GaveUp { until }) => format!(
"tries again at {}, after {} warm-ups came to nothing today",
moment(*until),
crate::duty::cadence::ATTEMPTS
),
Standing::Held(Hold::LoginExpired) => {
"needs signing in again: its parked login has expired".into()
}
Standing::Held(Hold::NothingParked) => {
"has no login to warm up with: it is not in use, and nothing is parked".into()
}
}
}
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(crate) fn login(tool: crate::provider::ProviderId, email: &str, shares_email: bool) -> String {
use crate::provider::ProviderId;
let group = match tool {
ProviderId::Claude => "organisation",
ProviderId::Codex => "workspace",
ProviderId::Desktop if email.is_empty() => return "another Claude Desktop account".into(),
ProviderId::Desktop => "account",
};
if !shares_email {
return email.to_owned();
}
format!("{email} in another {group}")
}
pub(crate) fn sign_in_yourself(tool: crate::provider::ProviderId, starts: bool) -> String {
use crate::provider::ProviderId;
let said = match tool {
ProviderId::Claude => "Run `claude`",
ProviderId::Codex => "Run `codex login`",
ProviderId::Desktop => "Open Claude",
};
starting(said, starts)
}
pub(crate) fn run_it(tool: crate::provider::ProviderId, starts: bool) -> String {
use crate::provider::ProviderId;
let said = match tool {
ProviderId::Claude => "Run `claude`",
ProviderId::Codex => "Run `codex`",
ProviderId::Desktop => "Open Claude",
};
starting(said, starts)
}
pub(crate) fn pointing_where_it_did(tool: crate::provider::ProviderId) -> String {
use crate::provider::ProviderId;
match tool {
ProviderId::Claude => "Run Pitboard with CLAUDE_CONFIG_DIR pointing where it did",
ProviderId::Codex => "Run Pitboard with CODEX_HOME pointing where it did",
ProviderId::Desktop => "Run Pitboard as the account whose Claude that is",
}
.into()
}
fn starting(said: &str, starts: bool) -> String {
if starts {
return said.into();
}
let mut chars = said.chars();
chars
.next()
.map(|first| first.to_lowercase().chain(chars).collect())
.unwrap_or_default()
}
pub(crate) fn nothing_signed_in(tool: crate::provider::ProviderId, label: Option<&str>) -> String {
use crate::provider::ProviderId;
match tool {
ProviderId::Claude | ProviderId::Codex => format!(
"nothing is signed in to {} right now. {}, sign in, then try again.",
tool.name(),
sign_in_yourself(tool, true)
),
ProviderId::Desktop => format!(
"Claude is not signed in. Run `pitboard enroll {} --sign-in`, then sign in inside \
Claude and quit it: Pitboard waits for that, and sets aside any account name \
Claude kept first.",
label.unwrap_or("desktop/<label>")
),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum WaitLine<'a> {
Parked { label: &'a str },
Add { label: &'a str },
Again { label: &'a str },
NoLogOut,
Waiting { put_back: Option<&'a str> },
Open,
QuitSignedOut,
QuitUnfinished,
}
pub(crate) fn awaited(line: WaitLine<'_>) -> String {
let said = match line {
WaitLine::Parked { label } => format!(
"Parked `{label}`. Claude is signed out, and nothing was signed out at claude.ai."
),
WaitLine::Add { label } => format!(
"Open Claude and sign in to the account to add as `{label}`. When Claude shows \
your chats, quit it with Command-Q."
),
WaitLine::Again { label } => format!(
"Open Claude and sign in to `{label}` again. When Claude shows your chats, quit it \
with Command-Q."
),
WaitLine::NoLogOut => "Do not choose Log out in Claude: it ends the login Claude holds \
at claude.ai, and Pitboard cannot bring it back."
.into(),
WaitLine::Waiting {
put_back: Some(label),
} => format!(
"Waiting for Claude to quit. Control-C stops waiting and keeps `{label}` parked; \
`pitboard use {label}` puts it back."
),
WaitLine::Waiting { put_back: None } => {
"Waiting for Claude to quit. Control-C stops waiting.".into()
}
WaitLine::Open => "Claude is open.".into(),
WaitLine::QuitSignedOut => "Claude quit with nobody signed in. Still waiting.".into(),
WaitLine::QuitUnfinished => "Claude quit before it finished signing in. Open it, wait \
until it shows your chats, then quit it. Still waiting."
.into(),
};
format!("{said}\n")
}
pub(crate) fn sign_in_awaited(label: &str, put_back: &str, again: bool) -> String {
let whom = if again {
format!("`{label}` again")
} else {
"another account".to_string()
};
format!(
"Claude is signed out so you can sign in to {whom}, and `{put_back}` is parked. Sign in \
inside Claude, quit it, then run `pitboard enroll {label}`. To go back, run `pitboard \
use {put_back}`."
)
}
pub(crate) fn move_waiting(label: &str) -> String {
format!(
"A move of `{label}`'s login was interrupted. Keep Claude closed: the next change to a \
Claude Desktop account settles it first."
)
}
fn move_left(why: &crate::error::Error, command: &str) -> Option<(String, String)> {
use crate::error::Error;
let again = format!("run `{command}` again");
Some(match why {
Error::AppOpen { app, still } if still.is_empty() => (
format!("{app} is open"),
format!("Quit {app} with Command-Q, then {again}."),
),
Error::AppOpen { app, still } => (
format!("{} from {app} is still running", listed(still.clone())),
wait_for_still(&again),
),
Error::AppStateUnknown { app, why } => (
format!("it could not tell whether {app} is open ({why})"),
format!("Run `{command}` again."),
),
Error::LoginFolderBusy { app } => (
format!("another Pitboard is changing {app}'s login"),
format!("Run `{command}` again once it has finished."),
),
Error::LoginFolderUnreadable { tool, detail } => (
format!(
"it could not read {}'s cookie store ({detail})",
tool.program()
),
format!(
"Open {program}, quit it with Command-Q, then {again}.",
program = tool.program()
),
),
Error::AppOpenedMidway { app, .. } => (
format!("{app} was opened meanwhile"),
format!("Nothing is lost. Quit {app} with Command-Q, then {again}."),
),
Error::Store(crate::store::Error::Unreadable(detail)) => (
detail.clone(),
format!("Make that path, and each folder it is in, readable to you, then {again}."),
),
_ => return None,
})
}
pub(crate) fn move_not_given_up(label: &str, why: &crate::error::Error) -> String {
use crate::error::Error;
let (because, then) = match (move_left(why, "pitboard abandon"), why) {
(Some((because, then)), _) => (Some(because), then),
(None, Error::MoveMixed { places, .. }) => (
Some(format!(
"giving it up would leave parts of it beside another login in Claude's folder: \
{}",
places.join(", ")
)),
mixed_way_out(label),
),
(None, other) => (
None,
format!(
"It stopped at `{}`: run `pitboard abandon` again to see why.",
other.code()
),
),
};
match because {
Some(because) => format!(
"Pitboard did not give up the interrupted move of `{label}`'s login, because \
{because}. {then}"
),
None => {
format!("Pitboard did not give up the interrupted move of `{label}`'s login. {then}")
}
}
}
pub(crate) fn move_not_settled(label: &str, why: &crate::error::Error) -> String {
use crate::error::Error;
let (because, then) = match (move_left(why, "pitboard repair"), why) {
(Some(left), _) => left,
(None, Error::RecoveryUndetermined { detail, .. }) => (
format!("it cannot tell where it stopped ({detail})"),
undetermined_way_out(),
),
(None, Error::RecoveryStopped { detail, .. }) => (
format!("it stopped partway ({detail})"),
"Nothing is lost. Keep Claude closed and run `pitboard repair` again. If it stops \
again, `pitboard abandon` keeps every folder and moves on."
.into(),
),
(None, Error::RecoveryElsewhere { tool, slot, .. }) => (
format!(
"{} kept its data in {slot} when the move was interrupted, and this run finds \
it somewhere else",
tool.program()
),
elsewhere_way_out(*tool),
),
(None, other) => (
format!("it stopped at `{}`", other.code()),
"The next change to a Claude Desktop account settles it first, or says why it \
cannot."
.into(),
),
};
format!(
"Pitboard did not settle the interrupted move of `{label}`'s login, because {because}. \
{then}"
)
}
pub(crate) fn undetermined_way_out() -> String {
"Open Claude to see whether it is signed in and to which account, then quit it with \
Command-Q. If it is signed in, run `pitboard enroll desktop/<label>` for the account it \
shows. If it is signed out, `pitboard abandon` keeps every folder and moves on."
.into()
}
pub(crate) fn elsewhere_way_out(tool: crate::provider::ProviderId) -> String {
format!(
"{}, or `pitboard abandon` to keep every folder and move on.",
pointing_where_it_did(tool)
)
}
pub(crate) fn wait_for_still(then: &str) -> String {
format!("Wait until it ends, or quit it in Activity Monitor, then {then}.")
}
pub(crate) fn mixed_way_out(label: &str) -> String {
format!(
"Open Claude, check which account it shows, quit it with Command-Q, then run `pitboard \
enroll desktop/<label>` under that account's name: Pitboard sets the parts of \
`{label}` aside first."
)
}
pub(crate) fn move_settled(
label: &str,
half: crate::state::Half,
ended: crate::switch::Ended,
) -> String {
use crate::state::Half;
use crate::switch::Ended;
let came_to = match (half, ended) {
(Half::Leave, Ended::Undone) => format!("Pitboard undid it, and `{label}` is in use"),
(Half::Leave, Ended::Finished) => {
"Pitboard finished parking it, and Claude is signed out".to_string()
}
(Half::Arrive, Ended::Undone) => {
format!("Pitboard took it back out, `{label}` is parked and Claude is signed out")
}
(Half::Arrive, Ended::Finished) => {
"it had in fact finished, and Pitboard has recorded that".to_string()
}
};
format!("an earlier move of `{label}`'s login was interrupted; {came_to}")
}
pub(crate) fn item_gone(label: &str, item: &str) -> String {
format!(
"`{item}` of `{label}`'s login was in neither place after the move, so Pitboard went on \
without it. Claude makes it again."
)
}
pub(crate) fn given_up(given_up: &crate::switch::GivenUp) -> String {
let label = crate::state::Key::new(given_up.tool, &given_up.label).typed();
let enroll = crate::state::Key::new(given_up.tool, "<label>").typed();
let mut said = format!(
"Pitboard gave up the move of `{label}`'s login and kept every folder: {} parked, {} \
set aside.",
given_up.parked, given_up.set_aside
);
if let Some(slot) = &given_up.slot {
said.push_str(&format!(
" The move was in {slot}, which this run did not read."
));
}
let way_on = format!(
"`pitboard enroll {enroll}` for the account Claude shows, or `pitboard use {enroll}` \
where Claude is signed out."
);
if given_up.left.is_empty() {
said.push_str(&format!(" Run {way_on}"));
} else {
let left = listed(
given_up
.left
.iter()
.map(|item| format!("`{item}`"))
.collect(),
);
let stay = if given_up.left.len() == 1 {
"stays"
} else {
"stay"
};
said.push_str(&format!(
" {left} {stay} in Claude's folder. Open Claude once and quit it with Command-Q, \
then run {way_on}"
));
}
said
}
pub(crate) fn desktop_named(
named: &crate::service::Stored,
last: &crate::service::Stored,
) -> String {
use crate::service::Stored;
let since = match last {
Stored::Account(_) | Stored::Unenrolled(_) => format!(
"since Pitboard last read {} in use, so Claude may hold another login",
last.said("nobody")
),
Stored::Nothing | Stored::Unknown => {
"since Pitboard last read Claude signed out, so somebody may have signed in".into()
}
};
format!(
"Claude's settings have named {} {since}",
named.said("nobody")
)
}
pub fn session_life(session_expires_at: Option<i64>, now: i64) -> Option<String> {
let left = session_expires_at? - now;
Some(match left / DAY {
_ if left <= 0 => "Its sign-in may have lapsed".into(),
0 => "Its sign-in may lapse in under a day".into(),
1 => "Its sign-in may lapse in 1 day".into(),
days => format!("Its sign-in may lapse in {days} days"),
})
}
pub fn with_email(name: &str, email: &str) -> String {
if email.is_empty() {
name.into()
} else {
format!("{name} ({email})")
}
}
pub fn session_life_column(session_expires_at: i64, now: i64) -> String {
match session_expires_at - now {
left if left <= 0 => "sign-in may have lapsed".into(),
left => format!("sign-in may lapse in {}", span(left)),
}
}
pub fn enrolled_lines(
tool: crate::provider::ProviderId,
enrolled: &crate::switch::Enrolled,
name: &str,
to_use: &str,
another: &str,
) -> String {
use crate::provider::ProviderId;
use crate::switch::Enrolled;
let in_its_app = match (tool, enrolled) {
(ProviderId::Claude | ProviderId::Codex, _) => None,
(ProviderId::Desktop, Enrolled::Current { .. }) => Some(format!(
"Enrolled {name}, the account Claude is signed in to now. Check that Claude shows \
that account: Pitboard cannot ask claude.ai whose it is.\nAdd another: pitboard \
enroll {another} --sign-in\n"
)),
(ProviderId::Desktop, Enrolled::InUse { again: false, .. }) => Some(format!(
"Enrolled {name}, the account Claude is signed in to now.\n"
)),
(ProviderId::Desktop, Enrolled::InUse { again: true, .. }) => Some(format!(
"Signed in to {name} again. Claude is signed in to it now.\n"
)),
(ProviderId::Desktop, Enrolled::SignedIn { .. } | Enrolled::Renewed { .. }) => None,
};
if let Some(said) = in_its_app {
return said;
}
match enrolled {
Enrolled::Current { email } => format!(
"Enrolled {}, the account signed in now.\nAdd another without signing out of it: \
pitboard enroll {another} --sign-in\n",
with_email(name, email)
),
Enrolled::SignedIn { email } => format!(
"Enrolled {}. Switch to it with: pitboard use {to_use}\n",
with_email(name, email)
),
Enrolled::Renewed { email } => format!(
"Renewed {}: its parked login is a fresh one.\n",
with_email(name, email)
),
Enrolled::InUse { email, again: true } => format!(
"Signed in to {} again. Its new login is the one in use now.\n",
with_email(name, email)
),
Enrolled::InUse {
email,
again: false,
} => format!(
"Enrolled {}, the account signed in now. Its new login is the one in use.\n",
with_email(name, email)
),
}
}
pub fn forget_question(tool: Option<crate::provider::ProviderId>, label: &str) -> String {
use crate::provider::ProviderId;
match tool {
None | Some(ProviderId::Claude | ProviderId::Codex) => format!(
"Forget {label} and delete its parked login? Adding it again needs a browser \
sign-in. [y/N] "
),
Some(ProviderId::Desktop) => format!(
"Forget {label} and delete its parked login and the folders set aside for it? \
Adding it again needs a sign-in in Claude. [y/N] "
),
}
}
pub fn adopted_folders(labels: &[String]) -> String {
let named = listed(labels.iter().map(|label| format!("`{label}`")).collect());
format!(
"Pitboard set aside the parked Claude Desktop logins of {named}. They were parked on \
another Mac, and Claude cannot read them here where its key differs. `pitboard \
forget` of an account deletes its own."
)
}
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 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 claude_desktops_words_hedge_what_nobody_measured() {
use crate::provider::ProviderId;
let now = 1_791_000_000;
let life = |left: i64| session_life(Some(now + left), now);
assert_eq!(
life(25 * DAY + 60).as_deref(),
Some("Its sign-in may lapse in 25 days")
);
assert_eq!(life(DAY).as_deref(), Some("Its sign-in may lapse in 1 day"));
assert_eq!(
life(60).as_deref(),
Some("Its sign-in may lapse in under a day")
);
assert_eq!(life(0).as_deref(), Some("Its sign-in may have lapsed"));
assert_eq!(session_life(None, now), None);
assert_eq!(
adopted_folders(&["desktop/work".into(), "desktop/home".into()]),
"Pitboard set aside the parked Claude Desktop logins of `desktop/work` and \
`desktop/home`. They were parked on another Mac, and Claude cannot read them here \
where its key differs. `pitboard forget` of an account deletes its own."
);
assert_eq!(
login(ProviderId::Desktop, "", false),
"another Claude Desktop account"
);
assert_eq!(sign_in_yourself(ProviderId::Desktop, false), "open Claude");
assert_eq!(
pointing_where_it_did(ProviderId::Desktop),
"Run Pitboard as the account whose Claude that is"
);
}
#[test]
fn a_name_with_no_email_has_no_brackets() {
assert_eq!(with_email("work", "a@example.com"), "work (a@example.com)");
assert_eq!(with_email("desktop/work", ""), "desktop/work");
}
#[test]
fn enrolled_lines_say_what_main_said_for_claude_code_and_codex() {
use crate::provider::ProviderId;
use crate::switch::Enrolled;
let email = || "a@example.com".to_string();
for tool in [ProviderId::Claude, ProviderId::Codex] {
let said = |enrolled: Enrolled| {
enrolled_lines(tool, &enrolled, "work", "codex/work", "codex/<label>")
};
assert_eq!(
said(Enrolled::Current { email: email() }),
"Enrolled work (a@example.com), the account signed in now.\n\
Add another without signing out of it: pitboard enroll codex/<label> \
--sign-in\n"
);
assert_eq!(
said(Enrolled::SignedIn { email: email() }),
"Enrolled work (a@example.com). Switch to it with: pitboard use codex/work\n"
);
assert_eq!(
said(Enrolled::Renewed { email: email() }),
"Renewed work (a@example.com): its parked login is a fresh one.\n"
);
assert_eq!(
said(Enrolled::InUse {
email: email(),
again: true
}),
"Signed in to work (a@example.com) again. Its new login is the one in use now.\n"
);
assert_eq!(
said(Enrolled::InUse {
email: email(),
again: false
}),
"Enrolled work (a@example.com), the account signed in now. Its new login is the \
one in use.\n"
);
}
}
#[test]
fn enrolled_lines_for_claude_desktop_name_the_account_claude_is_signed_in_to() {
use crate::provider::ProviderId;
use crate::switch::Enrolled;
let said = |enrolled: Enrolled| {
enrolled_lines(
ProviderId::Desktop,
&enrolled,
"desktop/work",
"desktop/work",
"desktop/<label>",
)
};
assert_eq!(
said(Enrolled::Current {
email: String::new()
}),
"Enrolled desktop/work, the account Claude is signed in to now. Check that Claude \
shows that account: Pitboard cannot ask claude.ai whose it is.\n\
Add another: pitboard enroll desktop/<label> --sign-in\n"
);
assert_eq!(
said(Enrolled::InUse {
email: String::new(),
again: false
}),
"Enrolled desktop/work, the account Claude is signed in to now.\n"
);
assert_eq!(
said(Enrolled::InUse {
email: String::new(),
again: true
}),
"Signed in to desktop/work again. Claude is signed in to it now.\n"
);
assert_eq!(
said(Enrolled::SignedIn {
email: String::new()
}),
"Enrolled desktop/work. Switch to it with: pitboard use desktop/work\n"
);
assert!(
!said(Enrolled::Renewed {
email: String::new()
})
.contains("()")
);
}
#[test]
fn the_forget_question_says_a_claude_desktop_account_signs_in_again_in_claude() {
use crate::provider::ProviderId;
let main = "Forget work and delete its parked login? Adding it again needs a browser \
sign-in. [y/N] ";
for tool in [None, Some(ProviderId::Claude), Some(ProviderId::Codex)] {
assert_eq!(forget_question(tool, "work"), main, "{tool:?}");
}
assert_eq!(
forget_question(Some(ProviderId::Desktop), "desktop/home"),
"Forget desktop/home and delete its parked login and the folders set aside for it? \
Adding it again needs a sign-in in Claude. [y/N] "
);
}
#[test]
fn a_session_life_is_hedged_and_never_says_expired() {
const NOW: i64 = 1_789_935_000;
let column = |left: i64| session_life_column(NOW + left, NOW);
assert_eq!(column(25 * DAY + 3 * HOUR), "sign-in may lapse in 25d 3h");
assert_eq!(column(2 * HOUR + 5 * MINUTE), "sign-in may lapse in 2h 05m");
assert_eq!(column(30), "sign-in may lapse in <1m");
assert_eq!(column(0), "sign-in may have lapsed");
assert_eq!(column(-DAY), "sign-in may have lapsed");
for left in [-DAY, 0, 30, DAY, 40 * DAY] {
let said = format!(
"{} {}",
column(left),
session_life(Some(NOW + left), NOW).unwrap_or_default()
);
assert!(!said.contains("expire"), "{said}");
}
}
#[test]
fn why_it_did_not_switch_names_no_command() {
use crate::duty::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| Pace {
expected: 40.0,
delta,
standing,
};
use crate::usage::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."
);
}
#[test]
fn a_move_not_given_up_says_why_and_never_that_nothing_changed() {
use crate::error::Error;
use crate::provider::ProviderId;
let tool = ProviderId::Desktop;
let app = "Claude";
let said = |why| move_not_given_up("desktop/work", &why);
assert_eq!(
said(Error::AppOpen {
app,
still: Vec::new()
}),
"Pitboard did not give up the interrupted move of `desktop/work`'s login, because \
Claude is open. Quit Claude with Command-Q, then run `pitboard abandon` again."
);
assert_eq!(
said(Error::MachineUnknown),
"Pitboard did not give up the interrupted move of `desktop/work`'s login. It \
stopped at `machine_unknown`: run `pitboard abandon` again to see why."
);
assert_eq!(
said(crate::store::folder::unread(
"desktop/work",
std::path::Path::new("/Users/dana/.pitboard/parks/p/items/Cookies"),
"a test held the jar"
)),
"Pitboard did not give up the interrupted move of `desktop/work`'s login, because \
`desktop/work`'s parked login at /Users/dana/.pitboard/parks/p/items/Cookies could \
not be looked at (a test held the jar). Make that path, and each folder it is in, \
readable to you, then run `pitboard abandon` again."
);
for why in [
Error::AppOpen {
app,
still: vec!["ShipIt (pid 812)".into()],
},
Error::AppStateUnknown {
app,
why: "ps failed".into(),
},
Error::LoginFolderBusy { app },
Error::LoginFolderUnreadable {
tool,
detail: "a test held the jar".into(),
},
Error::AppOpenedMidway {
app,
label: Some("desktop/work".into()),
},
Error::MoveMixed {
label: "desktop/work".into(),
places: vec!["`Session Storage` in Claude's folder".into()],
},
] {
let sentence = said(why);
assert!(sentence.contains(", because "), "{sentence}");
assert!(
!sentence.to_lowercase().contains("nothing has changed"),
"{sentence}"
);
}
}
#[test]
fn a_move_not_settled_says_why_and_never_that_nothing_changed() {
use crate::error::{Error, Then};
use crate::provider::ProviderId;
let tool = ProviderId::Desktop;
let app = "Claude";
let label = || "desktop/work".to_string();
let said = |why| move_not_settled("desktop/work", &why);
assert_eq!(
said(Error::RecoveryUndetermined {
tool,
from: label(),
to: label(),
detail: "a settings file that is not valid JSON".into(),
}),
"Pitboard did not settle the interrupted move of `desktop/work`'s login, because it \
cannot tell where it stopped (a settings file that is not valid JSON). Open Claude \
to see whether it is signed in and to which account, then quit it with Command-Q. \
If it is signed in, run `pitboard enroll desktop/<label>` for the account it shows. \
If it is signed out, `pitboard abandon` keeps every folder and moves on."
);
assert_eq!(
said(Error::MachineUnknown),
"Pitboard did not settle the interrupted move of `desktop/work`'s login, because it \
stopped at `machine_unknown`. The next change to a Claude Desktop account settles \
it first, or says why it cannot."
);
for why in [
Error::AppOpen {
app,
still: Vec::new(),
},
Error::LoginFolderBusy { app },
Error::RecoveryStopped {
label: label(),
detail: "`WebStorage` could not be moved".into(),
},
Error::RecoveryElsewhere {
tool,
from: label(),
to: label(),
slot: "/Volumes/Old/Claude".into(),
},
Error::LoginFolderUnknown {
tool,
what: "does not exist yet".into(),
then: Then::OpenAndQuit,
},
Error::Store(crate::store::Error::Unreadable("denied".into())),
] {
let sentence = said(why);
assert!(sentence.contains(", because "), "{sentence}");
assert!(sentence.ends_with('.'), "{sentence}");
let lower = sentence.to_lowercase();
assert!(
!lower.contains("nothing has changed") && !lower.contains("nothing was changed"),
"{sentence}"
);
}
}
}