Skip to main content

pitboard_core/
error.rs

1//! Every way pitboard can fail. Each variant has a message naming the cause and an action,
2//! a stable code for programs to branch on, and an exit code.
3
4use crate::provider::ProviderId;
5use crate::state::Key;
6use std::path::PathBuf;
7
8/// The labels an account list holds, rendered for a message: " Enrolled: `a`, `b`." or
9/// nothing at all when none are.
10#[derive(Debug, Clone, PartialEq, Eq, Default)]
11pub struct Enrolled(pub Vec<String>);
12
13impl std::fmt::Display for Enrolled {
14    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15        if self.0.is_empty() {
16            return write!(f, " Nothing is enrolled yet.");
17        }
18        let labels: Vec<String> = self.0.iter().map(|l| format!("`{l}`")).collect();
19        write!(f, " Enrolled: {}.", labels.join(", "))
20    }
21}
22
23/// Why a request to Anthropic did not produce an answer pitboard could use.
24///
25/// The code on an error says what pitboard was doing; this says what went wrong underneath
26/// it, and whether trying again is worth anything. Without it every failure that was not a
27/// 401 arrived at a front end as one code with a sentence of prose, so neither the command
28/// line nor the app could tell being offline from being rate limited from a login Anthropic
29/// has finished with.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
31#[serde(rename_all = "snake_case")]
32#[non_exhaustive]
33pub enum Cause {
34    /// Anthropic could not be reached at all.
35    Unreachable,
36    /// Anthropic asked for less traffic.
37    RateLimited,
38    /// Anthropic answered, badly, and may answer well later.
39    ServerError,
40    /// Anthropic answered something pitboard does not understand, which means its shape
41    /// moved. Trying again will produce the same thing.
42    AnswerNotUnderstood,
43    /// This login is finished: revoked, or already used somewhere else.
44    LoginRefused,
45    /// The token has expired. For the signed-in login that is ordinary and Claude Code
46    /// renews it; for a parked one it means the park needs renewing first.
47    TokenExpired,
48}
49
50impl Cause {
51    pub fn of(error: &crate::api::ApiError) -> Cause {
52        use crate::api::ApiError;
53        match error {
54            ApiError::Unauthorized => Cause::TokenExpired,
55            ApiError::RateLimited { .. } => Cause::RateLimited,
56            ApiError::Network(_) => Cause::Unreachable,
57            ApiError::Unexpected { .. } => Cause::ServerError,
58            ApiError::Malformed(_) => Cause::AnswerNotUnderstood,
59            ApiError::InvalidGrant => Cause::LoginRefused,
60        }
61    }
62
63    /// The same question asked of a provider rather than of Anthropic directly.
64    pub fn of_provider(error: &crate::provider::ProviderError) -> Cause {
65        use crate::provider::ProviderError as P;
66        match error {
67            P::Unauthorized => Cause::TokenExpired,
68            P::RateLimited { .. } => Cause::RateLimited,
69            P::Network { .. } => Cause::Unreachable,
70            P::Unexpected { .. } => Cause::ServerError,
71            P::Malformed { .. }
72            | P::ShapeUnexpected { .. }
73            | P::Unsupported { .. }
74            | P::NoLogin { .. } => Cause::AnswerNotUnderstood,
75            P::InvalidGrant { .. } => Cause::LoginRefused,
76        }
77    }
78
79    /// Stable, for a program to branch on; the same as its JSON form.
80    pub fn code(self) -> &'static str {
81        match self {
82            Cause::Unreachable => "unreachable",
83            Cause::RateLimited => "rate_limited",
84            Cause::ServerError => "server_error",
85            Cause::AnswerNotUnderstood => "answer_not_understood",
86            Cause::LoginRefused => "login_refused",
87            Cause::TokenExpired => "token_expired",
88        }
89    }
90
91    /// Whether the same request, later, could answer differently. A front end deciding
92    /// whether to back off or to give up reads this and nothing else.
93    pub fn worth_retrying(self) -> bool {
94        match self {
95            Cause::Unreachable | Cause::RateLimited | Cause::ServerError => true,
96            Cause::AnswerNotUnderstood | Cause::LoginRefused | Cause::TokenExpired => false,
97        }
98    }
99}
100
101#[derive(Debug, thiserror::Error)]
102#[non_exhaustive]
103pub enum Error {
104    #[error(
105        "{path} looks like it is inside {marker}, which syncs to other machines. \
106         Parked logins belong to one machine; set PITBOARD_HOME to a local folder."
107    )]
108    StateOnSyncedDrive { path: PathBuf, marker: String },
109
110    #[error(
111        "the login for `{label}` needs {bytes} bytes and `security` reads {limit} from \
112         stdin, so it can only be written on the argument line, which PITBOARD_NO_ARGV \
113         forbids. {}",
114        smaller(*tool)
115    )]
116    CredentialTooLarge {
117        tool: ProviderId,
118        label: String,
119        bytes: usize,
120        limit: usize,
121    },
122
123    #[error(
124        "`{program}` is not on this machine, and pitboard signs in with {}'s own sign-in. \
125         Install {}, or point pitboard at it.",
126        tool.name(),
127        tool.name()
128    )]
129    ProgramMissing { tool: ProviderId, program: String },
130
131    #[error(
132        "CLAUDE_CODE_CUSTOM_OAUTH_URL is set, so Claude Code keeps its login under a \
133         different name than the one pitboard reads. Unset it to use pitboard."
134    )]
135    CustomOauthEndpoint,
136
137    #[error("could not read pitboard's account list at {path}: {source}")]
138    StateUnreadable {
139        path: PathBuf,
140        #[source]
141        source: std::io::Error,
142    },
143
144    #[error(
145        "pitboard's account list at {path} is corrupt ({source}). \
146         Delete it and enroll your accounts again; parked logins will be lost."
147    )]
148    StateCorrupt {
149        path: PathBuf,
150        #[source]
151        source: serde_json::Error,
152    },
153
154    #[error(
155        "{path} was written by a newer pitboard (its format is {found}, this one reads \
156         {expected}). Update this pitboard the way you installed it. The app, and the command \
157         line inside it, update with the app's Check for Updates."
158    )]
159    StateFromNewerVersion {
160        path: PathBuf,
161        found: u32,
162        expected: u32,
163    },
164
165    #[error(
166        "{path} is in a format ({found}) no version of pitboard has ever written. Delete it \
167         and enroll your accounts again."
168    )]
169    StateVersionUnknown { path: PathBuf, found: u32 },
170
171    #[error(
172        "{path} has an account for `{tool}`, a tool this pitboard does not know, so it was \
173         written by a newer one. Update this pitboard the way you installed it. The app, and \
174         the command line inside it, update with the app's Check for Updates."
175    )]
176    StateNamesUnknownTool { path: PathBuf, tool: String },
177
178    #[error(
179        "{path} was written on another computer. Parked logins do not move between \
180         machines, because two machines taking turns presenting one refresh token ends the \
181         login for both. Run `pitboard adopt` to keep your accounts here and drop the \
182         logins they came with; each then needs one `pitboard enroll <label> --sign-in`."
183    )]
184    StateWrongMachine { path: PathBuf },
185
186    #[error(
187        "this platform has no scheduler pitboard knows how to write. Keeping parked logins \
188         alive here means running `pitboard` yourself from time to time."
189    )]
190    ScheduleUnsupported,
191
192    #[error("the scheduler refused: {detail}")]
193    ScheduleRefused { detail: String },
194
195    #[error("the renewal schedule would run {path}, which is not there. Nothing was scheduled.")]
196    ScheduleProgramMissing { path: PathBuf },
197
198    /// macOS runs an app opened where it was downloaded from a copy it makes somewhere
199    /// temporary, which is there while the app runs and gone once it quits.
200    #[error(
201        "the renewal schedule would run {path}, which is in a temporary copy macOS made of \
202         the app and is gone once the app quits. Move pitboard to your Applications folder, \
203         open it from there, and turn on daily renewal again."
204    )]
205    ScheduleProgramTemporary { path: PathBuf },
206
207    /// An app that names no command line for the schedule, where the only other thing to
208    /// schedule is the app itself, which renews nothing.
209    #[error("this copy of pitboard has no command line inside it for the renewal schedule to run.")]
210    ScheduleProgramUnnamed,
211
212    #[error("could not write to pitboard's directory at {path}: {source}")]
213    HomeUnwritable {
214        path: PathBuf,
215        #[source]
216        source: std::io::Error,
217    },
218
219    #[error("could not save pitboard's account list at {path}: {source}")]
220    StateWriteFailed {
221        path: PathBuf,
222        #[source]
223        source: std::io::Error,
224    },
225
226    #[error(
227        "Claude Code has not run on this machine yet ({path} does not exist). \
228         Run `claude` once, sign in, then try again."
229    )]
230    ClaudeConfigMissing { path: PathBuf },
231
232    #[error("could not read Claude Code's config at {path}: {source}")]
233    ClaudeConfigUnreadable {
234        path: PathBuf,
235        #[source]
236        source: std::io::Error,
237    },
238
239    #[error(
240        "Claude Code's config at {path} is not valid JSON right now ({source}). \
241         It may be mid-write; wait a few seconds and try again."
242    )]
243    ClaudeConfigNotJson {
244        path: PathBuf,
245        #[source]
246        source: serde_json::Error,
247    },
248
249    #[error(
250        "nothing is signed in to {} right now. Run `{}`, sign in, then try again.",
251        tool.name(),
252        tool.login_command()
253    )]
254    LiveCredentialAbsent { tool: ProviderId },
255
256    #[error(
257        "Claude Code's config says {email} is signed in, but pitboard cannot find that \
258         login in the keychain or in the file it also reads. It will not write a login \
259         where nobody reads it. This usually means Claude Code has started keeping logins \
260         somewhere pitboard does not know about yet: check for a pitboard update, and \
261         report it with `pitboard doctor --json` if there is none."
262    )]
263    LiveCredentialElsewhere { email: String },
264
265    #[error(
266        "the signed-in credential is not shaped like a {} login ({detail}). \
267         Run `pitboard doctor` before switching again.",
268        tool.name()
269    )]
270    LiveCredentialShapeUnexpected { tool: ProviderId, detail: String },
271
272    /// The tool is configured to keep its login somewhere pitboard does not handle.
273    #[error("{reason}.")]
274    LiveStoreUnsupported { tool: ProviderId, reason: String },
275
276    #[error(
277        "no account is enrolled as `{label}`.{enrolled} Run `pitboard enroll {label} \
278         --sign-in` to add it."
279    )]
280    AccountUnknown {
281        label: String,
282        /// The labels that do exist, so a typo costs no second command.
283        enrolled: Enrolled,
284    },
285
286    #[error(
287        "`{label}` has no parked login to switch to: the last one went back into use and \
288         {} has moved on from it. Run `pitboard enroll {label} --sign-in` to sign in to it \
289         again.",
290        tool.name()
291    )]
292    NothingParked { tool: ProviderId, label: String },
293
294    #[error(
295        "the parked login for `{label}` has expired. Run `pitboard enroll {label} --sign-in` \
296         to sign in to it again."
297    )]
298    ParkedLoginExpired { label: String },
299
300    #[error(
301        "{email} is signed in but not enrolled, so it cannot be parked. \
302         Run `pitboard enroll {}` for it first.",
303        Key::new(*tool, "<label>").typed()
304    )]
305    LiveAccountNotEnrolled { tool: ProviderId, email: String },
306
307    #[error(
308        "{email} is already enrolled as `{label}`. To add a different account, run \
309         `pitboard enroll {} --sign-in`.",
310        Key::new(*tool, "<label>").typed()
311    )]
312    AlreadyEnrolled {
313        tool: ProviderId,
314        email: String,
315        label: String,
316    },
317
318    #[error("`{label}` already refers to {email}. Choose a different label.")]
319    LabelTaken { label: String, email: String },
320
321    #[error(
322        "`{typed}` is not a tool pitboard knows. It knows: {}.",
323        known.join(", ")
324    )]
325    ProviderUnknown { typed: String, known: Vec<String> },
326
327    #[error(
328        "`{label}` is enrolled for more than one tool: {}. Say which one.",
329        matches.iter().map(|m| format!("`{m}`")).collect::<Vec<_>>().join(", ")
330    )]
331    LabelAmbiguous { label: String, matches: Vec<String> },
332
333    #[error("`{label}` is signed in; switch to another account before forgetting it.")]
334    CannotForgetActiveAccount { label: String },
335
336    #[error("could not find a free place to park this login. Run `pitboard doctor`.")]
337    ParkSlotExhausted,
338
339    #[error(
340        "the parked login for `{label}` is missing. Run `pitboard enroll {label} --sign-in` \
341         to sign in to it again."
342    )]
343    ParkedCredentialMissing { label: String },
344
345    #[error(
346        "the parked login for `{label}` is not the one pitboard recorded ({detail}). Run \
347         `pitboard enroll {label} --sign-in` to replace it."
348    )]
349    ParkedCredentialCorrupt { label: String, detail: String },
350
351    #[error("could not back up Claude Code's config ({path}): {source}. Nothing was changed.")]
352    ConfigBackupFailed {
353        path: PathBuf,
354        #[source]
355        source: std::io::Error,
356    },
357
358    /// Claude Code refetches its profile only once a day, so this does not correct itself.
359    #[error(
360        "the login moved, but Claude Code's config at {path} could not be updated ({detail}). \
361         Claude Code may show the previous account's name until the next switch."
362    )]
363    ConfigWriteFailed { path: PathBuf, detail: String },
364
365    #[error(
366        "{}'s session has expired, so pitboard cannot confirm which account is signed in. \
367         Run `{}` once so it refreshes, then try again.",
368        tool.name(),
369        tool.program()
370    )]
371    SessionExpired { tool: ProviderId },
372
373    #[error(
374        "pitboard could not confirm with {} which account is signed in ({detail}), and will \
375         not move a login it cannot identify. Check the connection and try again.",
376        tool.service()
377    )]
378    IdentityUnverifiable {
379        tool: ProviderId,
380        cause: Cause,
381        detail: String,
382    },
383
384    #[error("the signed-in account changed while switching. Nothing was moved; try again.")]
385    SignedInAccountChanged,
386
387    #[error(
388        "an earlier switch from `{from}` to `{to}` was interrupted, and pitboard cannot yet \
389         tell whether it finished ({detail}). Nothing was changed. Run `{}` once so its \
390         session is current, then try again.",
391        tool.program()
392    )]
393    RecoveryUndetermined {
394        tool: ProviderId,
395        from: String,
396        to: String,
397        detail: String,
398    },
399
400    #[error(
401        "could not sign in as `{to}` ({detail}); `{from}` is still signed in, nothing was lost."
402    )]
403    SwitchRolledBack {
404        from: String,
405        to: String,
406        detail: String,
407    },
408
409    #[error(
410        "{} no longer accepts `{label}`'s parked login, so pitboard did not move anything. \
411         The copy has been dropped; sign in to that account again with \
412         `pitboard enroll {label} --sign-in`.",
413        tool.service()
414    )]
415    ParkedLoginRefused { tool: ProviderId, label: String },
416
417    #[error(
418        "`{label}`'s parked login belongs to {email}, not to the account pitboard has \
419         under that label. Nothing was moved. Run `pitboard doctor`, then \
420         `pitboard enroll {label} --sign-in` to replace it."
421    )]
422    ParkedLoginBelongsElsewhere { label: String, email: String },
423
424    #[error(
425        "signed in as `{to}`, and the login was gone again before pitboard finished. {}",
426        after_it_did_not_hold(*tool, from, to)
427    )]
428    SwitchDidNotHold {
429        tool: ProviderId,
430        from: String,
431        to: String,
432    },
433
434    #[error(
435        "could not sign in as `{to}` ({detail}), and could not read the credential store \
436         back to find out whether anything changed. Nothing has been deleted and both \
437         logins are still here. {} and run `pitboard use {to}` again; it finishes or \
438         undoes this before doing anything else.",
439        make_readable(*tool)
440    )]
441    SwitchUnverified {
442        tool: ProviderId,
443        from: String,
444        to: String,
445        detail: String,
446    },
447
448    #[error(
449        "could not sign in as `{to}`, and could not put `{from}` back either ({detail}). \
450         `{from}`'s login is still parked: run `{}` and sign in to any enrolled account, \
451         then `pitboard use {from}`.",
452        tool.login_command()
453    )]
454    SwitchCorrupted {
455        tool: ProviderId,
456        from: String,
457        to: String,
458        detail: String,
459    },
460
461    #[error(
462        "signed in to `{label}` again, and pitboard could not confirm that its new login \
463         took the place of the one in use ({detail}), so {} may have no login for it now. {}",
464        tool.name(),
465        not_in_use(*tool, label, *parked)
466    )]
467    SignInNotInstalled {
468        tool: ProviderId,
469        label: String,
470        detail: String,
471        /// Whether the new login was parked instead, which keeps the one copy of it.
472        parked: bool,
473        /// What writing it and parking it warned about, which is still true of a change
474        /// that failed afterwards. Taken out by the service and reported beside the error.
475        warnings: Vec<crate::service::Warning>,
476    },
477
478    #[error(
479        "the new login for `{label}` could not be put in use ({detail}), so it was not \
480         kept, and {} keeps the login it has. Run `pitboard enroll {label} --sign-in` to \
481         sign in again.",
482        tool.name()
483    )]
484    SignInNotKept {
485        tool: ProviderId,
486        label: String,
487        detail: String,
488    },
489
490    #[error(
491        "the record of an interrupted switch at {path} is damaged ({source}), so pitboard \
492         cannot tell what that switch did. Nothing was changed. Check that `pitboard status` \
493         shows the account you expect, then delete the file to continue."
494    )]
495    RecoveryRecordCorrupt {
496        path: PathBuf,
497        #[source]
498        source: serde_json::Error,
499    },
500
501    #[error(
502        "an earlier switch of {} from `{from}` to `{to}` was interrupted while its login was \
503         at {slot}, and this run reads it from somewhere else, so it cannot tell what that \
504         switch did. Nothing was changed. Run pitboard with {} pointing where it did to \
505         finish it, or `pitboard abandon` to keep every login it names and move on.",
506        tool.name(),
507        tool.home_variable()
508    )]
509    RecoveryElsewhere {
510        tool: ProviderId,
511        from: String,
512        to: String,
513        slot: String,
514    },
515
516    #[error("could not read or write pitboard's recovery record at {path}: {source}")]
517    RecoveryFailed {
518        path: PathBuf,
519        #[source]
520        source: std::io::Error,
521    },
522
523    #[error(
524        "`{}` was not found on PATH. Install {}, run it once, then try again.",
525        tool.program(),
526        tool.name()
527    )]
528    ProgramNotFound { tool: ProviderId },
529
530    #[error(
531        "could not renew the parked login for `{label}` ({detail}); its last reading is shown \
532         instead. Run `pitboard doctor` if this keeps happening."
533    )]
534    RenewalFailed {
535        label: String,
536        cause: Option<Cause>,
537        detail: String,
538    },
539
540    #[error("the sign-in did not finish, so nothing was enrolled.")]
541    SignInIncomplete,
542
543    /// Signing in to a second account works by pointing the tool's own login at a scratch
544    /// directory. Where that does not isolate it from the live login, running one would
545    /// write over the account somebody is using, so pitboard will not.
546    #[error(
547        "pitboard will not sign in to a second account on this machine: {reason} Signing \
548         in would write over the login you are using."
549    )]
550    SignInNotIsolated { reason: String },
551
552    #[error(
553        "another `pitboard enroll --sign-in` is already waiting for its sign-in. Finish or \
554         cancel that one first."
555    )]
556    SignInInProgress,
557
558    /// The command line itself was wrong; the message is clap's.
559    #[error("{0}")]
560    Usage(String),
561
562    #[error(transparent)]
563    Store(#[from] crate::store::Error),
564
565    #[error(transparent)]
566    Lock(#[from] crate::lock::LockError),
567}
568
569impl Error {
570    /// A stable identifier a program can branch on. Adding one is safe; renaming one is not.
571    pub fn code(&self) -> &'static str {
572        use Error::*;
573        match self {
574            StateOnSyncedDrive { .. } => "state_on_synced_drive",
575            ProgramMissing { tool, .. } => match tool {
576                ProviderId::Claude => "claude_program_missing",
577                ProviderId::Codex => "codex_program_missing",
578            },
579            CredentialTooLarge { .. } => "credential_too_large",
580            CustomOauthEndpoint => "custom_oauth_endpoint",
581            StateUnreadable { .. } => "state_unreadable",
582            StateCorrupt { .. } => "state_corrupt",
583            StateFromNewerVersion { .. } => "state_from_newer_version",
584            StateVersionUnknown { .. } => "state_version_unknown",
585            StateNamesUnknownTool { .. } => "state_names_unknown_tool",
586            StateWrongMachine { .. } => "state_wrong_machine",
587            StateWriteFailed { .. } => "state_write_failed",
588            ScheduleUnsupported => "schedule_unsupported",
589            ScheduleRefused { .. } => "schedule_refused",
590            ScheduleProgramMissing { .. } => "schedule_program_missing",
591            ScheduleProgramTemporary { .. } => "schedule_program_temporary",
592            ScheduleProgramUnnamed => "schedule_program_unnamed",
593            HomeUnwritable { .. } => "home_unwritable",
594            ClaudeConfigMissing { .. } => "claude_config_missing",
595            ClaudeConfigUnreadable { .. } => "claude_config_unreadable",
596            ClaudeConfigNotJson { .. } => "claude_config_not_json",
597            LiveCredentialAbsent { .. } => "live_credential_absent",
598            LiveStoreUnsupported { .. } => "live_store_unsupported",
599            LiveCredentialElsewhere { .. } => "live_credential_elsewhere",
600            LiveCredentialShapeUnexpected { .. } => "live_credential_shape_unexpected",
601            AccountUnknown { .. } => "account_unknown",
602            NothingParked { .. } => "nothing_parked",
603            ParkedLoginExpired { .. } => "parked_login_expired",
604            ParkedLoginRefused { .. } => "parked_login_refused",
605            ParkedLoginBelongsElsewhere { .. } => "parked_login_belongs_elsewhere",
606            LiveAccountNotEnrolled { .. } => "live_account_not_enrolled",
607            AlreadyEnrolled { .. } => "already_enrolled",
608            LabelTaken { .. } => "label_taken",
609            ProviderUnknown { .. } => "provider_unknown",
610            LabelAmbiguous { .. } => "label_ambiguous",
611            CannotForgetActiveAccount { .. } => "cannot_forget_active_account",
612            ParkSlotExhausted => "park_slot_exhausted",
613            ParkedCredentialMissing { .. } => "parked_credential_missing",
614            ParkedCredentialCorrupt { .. } => "parked_credential_corrupt",
615            ConfigBackupFailed { .. } => "config_backup_failed",
616            ConfigWriteFailed { .. } => "config_write_failed",
617            SwitchRolledBack { .. } => "switch_rolled_back",
618            SwitchDidNotHold { .. } => "switch_did_not_hold",
619            SwitchUnverified { .. } => "switch_unverified",
620            SwitchCorrupted { .. } => "switch_corrupted",
621            SignInNotInstalled { .. } => "sign_in_not_installed",
622            SignInNotKept { .. } => "sign_in_not_kept",
623            RecoveryFailed { .. } => "recovery_failed",
624            RecoveryRecordCorrupt { .. } => "recovery_record_corrupt",
625            SessionExpired { .. } => "session_expired",
626            IdentityUnverifiable { .. } => "identity_unverifiable",
627            SignedInAccountChanged => "signed_in_account_changed",
628            RecoveryUndetermined { .. } => "recovery_undetermined",
629            RecoveryElsewhere { .. } => "recovery_elsewhere",
630            ProgramNotFound { tool } => match tool {
631                ProviderId::Claude => "claude_not_found",
632                ProviderId::Codex => "codex_not_found",
633            },
634            SignInIncomplete => "sign_in_incomplete",
635            SignInNotIsolated { .. } => "sign_in_not_isolated",
636            RenewalFailed { .. } => "renewal_failed",
637            SignInInProgress => "sign_in_in_progress",
638            Usage(_) => "usage",
639            Store(e) => e.code(),
640            Lock(e) => e.code(),
641        }
642    }
643
644    /// 1 when a request could not be met; 2 when the command line was wrong; 3 when a login
645    /// or Claude Code's files are in a state pitboard cannot safely act on: an unexpected
646    /// format, or a login that could not be put back.
647    /// What went wrong underneath, where the failure came from a request to Anthropic.
648    /// `None` where nothing was asked.
649    pub fn cause(&self) -> Option<Cause> {
650        use Error::*;
651        match self {
652            IdentityUnverifiable { cause, .. } => Some(*cause),
653            RenewalFailed { cause, .. } => *cause,
654            SessionExpired { .. } => Some(Cause::TokenExpired),
655            _ => None,
656        }
657    }
658
659    /// Warnings a failed change carries in itself, for the caller to report beside it. Only
660    /// a failure that happened after something worth warning about was done carries any.
661    pub(crate) fn take_warnings(&mut self) -> Vec<crate::service::Warning> {
662        match self {
663            Error::SignInNotInstalled { warnings, .. } => std::mem::take(warnings),
664            _ => Vec::new(),
665        }
666    }
667
668    pub fn exit_code(&self) -> u8 {
669        use Error::*;
670        match self {
671            // A login or Claude Code's files in a state pitboard will not act on, which is
672            // what exit 3 means: not a failure of the attempt, a refusal to attempt.
673            LiveCredentialShapeUnexpected { .. }
674            | LiveStoreUnsupported { .. }
675            | ClaudeConfigNotJson { .. }
676            | SwitchCorrupted { .. }
677            | SwitchUnverified { .. }
678            | SwitchDidNotHold { .. }
679            | SignInNotInstalled { .. }
680            | LiveCredentialElsewhere { .. }
681            | CredentialTooLarge { .. }
682            | CustomOauthEndpoint
683            | RecoveryRecordCorrupt { .. } => 3,
684            Usage(_) => 2,
685            Store(e) => e.exit_code(),
686            _ => 1,
687        }
688    }
689}
690
691pub type Result<T> = std::result::Result<T, Error>;
692
693/// What makes a tool's live login readable again, where it could not be read.
694fn make_readable(tool: ProviderId) -> &'static str {
695    match tool {
696        ProviderId::Claude => "Unlock the keychain",
697        ProviderId::Codex => "Make Codex's auth.json readable to you again",
698    }
699}
700
701/// What makes a login smaller, where anything does.
702fn smaller(tool: ProviderId) -> &'static str {
703    match tool {
704        ProviderId::Claude => {
705            "Unset it, or sign out of MCP servers you no longer use to make the login smaller."
706        }
707        ProviderId::Codex => "Unset it to let pitboard write it.",
708    }
709}
710
711/// Which write took a switched-in login away again, as far as each tool is known to make
712/// one, and what that leaves.
713fn after_it_did_not_hold(tool: ProviderId, from: &str, to: &str) -> String {
714    match tool {
715        ProviderId::Claude => format!(
716            "Claude Code removes a login without taking the write lock when `/logout` has \
717             given up waiting, which is the one write pitboard cannot exclude. Nothing was \
718             lost: both `{from}` and `{to}` are parked. Run `claude` and sign in to any \
719             enrolled account, then `pitboard use {to}`."
720        ),
721        // Not "nothing was lost": the likeliest writer is a codex session refreshing the
722        // outgoing account, which spends the token in that account's park, and `codex
723        // login` would revoke whatever login is left in auth.json.
724        ProviderId::Codex => format!(
725            "Codex takes no lock, so a codex session still running from before the switch, \
726             refreshing or signing out, rewrote auth.json underneath it. `{to}` is still \
727             parked. `{from}`'s park may hold a token that refresh spent. Quit every running \
728             codex, then run `pitboard` to see what is signed in; do not run `codex login` \
729             or `codex logout` until you have, because either revokes the login they find."
730        ),
731    }
732}
733
734/// Where a new login that could not be put in use went, and the way back from there.
735fn not_in_use(tool: ProviderId, label: &str, parked: bool) -> String {
736    if parked {
737        format!(
738            "The new login is parked, so it is not lost. Run `pitboard` to see what is \
739             signed in; if nothing is, run `{}` and sign in to any enrolled account, then \
740             `pitboard use {label}`.",
741            tool.login_command()
742        )
743    } else {
744        format!(
745            "It could not be parked either: run `{}` and sign in to `{label}` again.",
746            tool.login_command()
747        )
748    }
749}
750
751#[cfg(test)]
752mod tests {
753    use super::*;
754
755    #[test]
756    fn codes_are_unique_so_a_caller_can_branch_on_them() {
757        let samples = [
758            Error::LiveCredentialAbsent {
759                tool: ProviderId::Claude,
760            },
761            Error::ParkSlotExhausted,
762            Error::AccountUnknown {
763                label: "x".into(),
764                enrolled: Enrolled::default(),
765            },
766            Error::NothingParked {
767                tool: ProviderId::Claude,
768                label: "x".into(),
769            },
770            Error::ParkedLoginExpired { label: "x".into() },
771            Error::LabelTaken {
772                label: "x".into(),
773                email: "e".into(),
774            },
775            Error::SwitchRolledBack {
776                from: "x".into(),
777                to: "y".into(),
778                detail: "d".into(),
779            },
780            Error::SignInNotKept {
781                tool: ProviderId::Codex,
782                label: "x".into(),
783                detail: "d".into(),
784            },
785        ];
786        let mut codes: Vec<&str> = samples.iter().map(Error::code).collect();
787        codes.sort_unstable();
788        let before = codes.len();
789        codes.dedup();
790        assert_eq!(codes.len(), before);
791    }
792
793    #[test]
794    fn a_broken_assumption_exits_differently_from_a_bad_request() {
795        assert_eq!(
796            Error::LiveCredentialShapeUnexpected {
797                tool: ProviderId::Claude,
798                detail: "x".into()
799            }
800            .exit_code(),
801            3
802        );
803        assert_eq!(
804            Error::AccountUnknown {
805                label: "x".into(),
806                enrolled: Enrolled::default()
807            }
808            .exit_code(),
809            1
810        );
811    }
812
813    #[test]
814    fn every_message_tells_the_user_something_to_do() {
815        // A message that only states a fact leaves the user stuck.
816        let actionable = [
817            Error::LiveCredentialAbsent {
818                tool: ProviderId::Claude,
819            }
820            .to_string(),
821            Error::LiveCredentialAbsent {
822                tool: ProviderId::Codex,
823            }
824            .to_string(),
825            Error::AccountUnknown {
826                label: "work".into(),
827                enrolled: Enrolled(vec!["personal".into()]),
828            }
829            .to_string(),
830            Error::NothingParked {
831                tool: ProviderId::Claude,
832                label: "work".into(),
833            }
834            .to_string(),
835            Error::ParkedLoginExpired {
836                label: "work".into(),
837            }
838            .to_string(),
839            Error::ParkedCredentialMissing {
840                label: "work".into(),
841            }
842            .to_string(),
843            Error::LiveAccountNotEnrolled {
844                tool: ProviderId::Claude,
845                email: "a@b.c".into(),
846            }
847            .to_string(),
848            Error::SignInNotKept {
849                tool: ProviderId::Codex,
850                label: "codex/work".into(),
851                detail: "the keychain is locked".into(),
852            }
853            .to_string(),
854        ];
855        for message in actionable {
856            assert!(
857                message.contains("Run ") || message.contains("Sign in"),
858                "no action offered: {message}"
859            );
860        }
861    }
862
863    /// Codex's messages name Codex and the command that signs in to it; Claude Code's name
864    /// Claude Code. A message about the wrong tool sends somebody to run the wrong program.
865    #[test]
866    fn a_message_names_the_tool_it_is_about() {
867        let codex = Error::LiveCredentialAbsent {
868            tool: ProviderId::Codex,
869        }
870        .to_string();
871        assert!(
872            codex.contains("Codex") && codex.contains("`codex login`"),
873            "{codex}"
874        );
875        assert!(!codex.contains("Claude"), "{codex}");
876
877        let unverifiable = Error::IdentityUnverifiable {
878            tool: ProviderId::Codex,
879            cause: Cause::Unreachable,
880            detail: "offline".into(),
881        }
882        .to_string();
883        assert!(unverifiable.contains("OpenAI"), "{unverifiable}");
884
885        let claude = Error::IdentityUnverifiable {
886            tool: ProviderId::Claude,
887            cause: Cause::Unreachable,
888            detail: "offline".into(),
889        }
890        .to_string();
891        assert!(claude.contains("confirm with Anthropic"), "{claude}");
892    }
893
894    /// The codes of the two tools' missing programs stay distinct, and Claude Code's keep
895    /// the names they were released under.
896    #[test]
897    fn a_missing_program_keeps_its_released_code() {
898        let missing = |tool| Error::ProgramNotFound { tool }.code();
899        assert_eq!(missing(ProviderId::Claude), "claude_not_found");
900        assert_eq!(missing(ProviderId::Codex), "codex_not_found");
901        let absent = |tool| {
902            Error::ProgramMissing {
903                tool,
904                program: "x".into(),
905            }
906            .code()
907        };
908        assert_eq!(absent(ProviderId::Claude), "claude_program_missing");
909        assert_eq!(absent(ProviderId::Codex), "codex_program_missing");
910    }
911
912    /// A pitboard that finds its files written by a newer one is updated by the route it
913    /// came by. The app's Check for Updates moves only the app and the command line inside
914    /// it, so it is not offered as another way to update this one.
915    #[test]
916    fn a_newer_pitboards_files_say_which_update_moves_which_pitboard() {
917        let path = PathBuf::from("/home/x/.pitboard/state.json");
918        for message in [
919            Error::StateFromNewerVersion {
920                path: path.clone(),
921                found: 9,
922                expected: 5,
923            }
924            .to_string(),
925            Error::StateNamesUnknownTool {
926                path: path.clone(),
927                tool: "gemini".into(),
928            }
929            .to_string(),
930        ] {
931            assert!(
932                message.contains("Update this pitboard the way you installed it."),
933                "{message}"
934            );
935            assert!(
936                message.contains("The app, and the command line inside it, update with"),
937                "{message}"
938            );
939            assert!(!message.contains("or with the app"), "{message}");
940        }
941    }
942
943    /// Advice to enrol names the tool the account is for: a bare name would enrol a Claude
944    /// Code account for a Codex login.
945    #[test]
946    fn enrolment_advice_keeps_the_tool() {
947        let codex = Error::LiveAccountNotEnrolled {
948            tool: ProviderId::Codex,
949            email: "a@b.c".into(),
950        }
951        .to_string();
952        assert!(codex.contains("pitboard enroll codex/<label>"), "{codex}");
953        let claude = Error::LiveAccountNotEnrolled {
954            tool: ProviderId::Claude,
955            email: "a@b.c".into(),
956        }
957        .to_string();
958        assert!(claude.contains("pitboard enroll <label>"), "{claude}");
959        let already = Error::AlreadyEnrolled {
960            tool: ProviderId::Codex,
961            email: "a@b.c".into(),
962            label: "codex/work".into(),
963        }
964        .to_string();
965        assert!(
966            already.contains("pitboard enroll codex/<label> --sign-in"),
967            "{already}"
968        );
969    }
970
971    /// Only a command that changes something finishes an interrupted switch; a plain
972    /// `pitboard` reads and leaves it. So the message names the switch to run again, as it
973    /// would be typed here.
974    #[test]
975    fn an_unverified_switch_names_a_command_that_finishes_it() {
976        let message = Error::SwitchUnverified {
977            tool: ProviderId::Codex,
978            from: "codex/personal".into(),
979            to: "codex/work".into(),
980            detail: "auth.json is not readable".into(),
981        }
982        .to_string();
983        assert!(
984            message.contains("run `pitboard use codex/work` again"),
985            "{message}"
986        );
987        assert!(!message.contains("run `pitboard` again"), "{message}");
988    }
989}