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