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/// How far putting away the file behind a tool's store had gone when something stopped it.
24/// Never as far as deleting the file, and never further than keeping the login it held.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Partway {
27    /// Nothing was changed.
28    Nothing,
29    /// The login Claude Code has stored was renewed to tell whose it is, which spent its
30    /// refresh token, and the renewed one waits in Pitboard's vault for the next change to
31    /// save it there.
32    StoredRenewedKept,
33    /// The login the file held was renewed, which spent its refresh token, and the renewed
34    /// one could not be written back there, nor kept.
35    RenewedUnwritten,
36    /// The login the file held was renewed, which spent its refresh token, and the renewed
37    /// one waits in Pitboard's vault for the next change to write it back there.
38    RenewedKept,
39    /// The login in the file was renewed, and the renewed one written back there.
40    Renewed,
41    /// The login in the file was parked for `label`.
42    Parked { label: String },
43}
44
45impl std::fmt::Display for Partway {
46    /// What Pitboard did, after "Pitboard".
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            Partway::Nothing => write!(f, "changed nothing"),
50            Partway::StoredRenewedKept => write!(
51                f,
52                "deleted nothing. It renewed the login Claude Code has stored and keeps the \
53                 renewed login until the next change saves it there"
54            ),
55            Partway::RenewedUnwritten => write!(
56                f,
57                "deleted nothing. It renewed the login the file held and could not write the \
58                 renewed login back there, so that login no longer works"
59            ),
60            Partway::RenewedKept => write!(
61                f,
62                "deleted nothing. It renewed the login the file held and keeps the renewed login \
63                 until the next change writes it back there"
64            ),
65            Partway::Renewed => write!(
66                f,
67                "deleted nothing, having renewed the login in the file and written it back there"
68            ),
69            Partway::Parked { label } => write!(
70                f,
71                "deleted nothing, having parked the login in the file for `{label}`"
72            ),
73        }
74    }
75}
76
77/// Why a request to Anthropic did not produce an answer Pitboard could use.
78///
79/// The code on an error says what Pitboard was doing; this says what went wrong underneath
80/// it, and whether trying again is worth anything. Without it every failure that was not a
81/// 401 arrived at a front end as one code with a sentence of prose, so neither the command
82/// line nor the app could tell being offline from being rate limited from a login Anthropic
83/// has finished with.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
85#[serde(rename_all = "snake_case")]
86#[non_exhaustive]
87pub enum Cause {
88    /// Anthropic could not be reached at all.
89    Unreachable,
90    /// Anthropic asked for less traffic.
91    RateLimited,
92    /// Anthropic answered, badly, and may answer well later.
93    ServerError,
94    /// Anthropic answered something Pitboard does not understand, which means its shape
95    /// moved. Trying again will produce the same thing.
96    AnswerNotUnderstood,
97    /// This login is finished: revoked, or already used somewhere else.
98    LoginRefused,
99    /// The token has expired. For the signed-in login that is ordinary and Claude Code
100    /// renews it; for a parked one it means the park needs renewing first.
101    TokenExpired,
102}
103
104impl Cause {
105    pub fn of(error: &crate::api::ApiError) -> Cause {
106        use crate::api::ApiError;
107        match error {
108            ApiError::Unauthorized => Cause::TokenExpired,
109            ApiError::RateLimited { .. } => Cause::RateLimited,
110            ApiError::Network(_) => Cause::Unreachable,
111            ApiError::Unexpected { .. } => Cause::ServerError,
112            ApiError::Malformed(_) => Cause::AnswerNotUnderstood,
113            ApiError::InvalidGrant => Cause::LoginRefused,
114        }
115    }
116
117    /// The same question asked of a provider rather than of Anthropic directly.
118    pub fn of_provider(error: &crate::provider::ProviderError) -> Cause {
119        use crate::provider::ProviderError as P;
120        match error {
121            P::Unauthorized => Cause::TokenExpired,
122            P::RateLimited { .. } => Cause::RateLimited,
123            P::Network { .. } => Cause::Unreachable,
124            P::Unexpected { .. } => Cause::ServerError,
125            P::Malformed { .. }
126            | P::ShapeUnexpected { .. }
127            | P::Unsupported { .. }
128            | P::NoLogin { .. } => Cause::AnswerNotUnderstood,
129            P::InvalidGrant { .. } => Cause::LoginRefused,
130        }
131    }
132
133    /// Stable, for a program to branch on; the same as its JSON form.
134    pub fn code(self) -> &'static str {
135        match self {
136            Cause::Unreachable => "unreachable",
137            Cause::RateLimited => "rate_limited",
138            Cause::ServerError => "server_error",
139            Cause::AnswerNotUnderstood => "answer_not_understood",
140            Cause::LoginRefused => "login_refused",
141            Cause::TokenExpired => "token_expired",
142        }
143    }
144
145    /// Whether the same request, later, could answer differently. A front end deciding
146    /// whether to back off or to give up reads this and nothing else.
147    pub fn worth_retrying(self) -> bool {
148        match self {
149            Cause::Unreachable | Cause::RateLimited | Cause::ServerError => true,
150            Cause::AnswerNotUnderstood | Cause::LoginRefused | Cause::TokenExpired => false,
151        }
152    }
153}
154
155#[derive(Debug, thiserror::Error)]
156#[non_exhaustive]
157pub enum Error {
158    #[error(
159        "{path} looks like it is inside {marker}, which syncs to other machines. \
160         Parked logins belong to one machine; set PITBOARD_HOME to a local folder."
161    )]
162    StateOnSyncedDrive { path: PathBuf, marker: String },
163
164    /// A home the environment names is empty or relative, so what it leads to would depend
165    /// on the folder each program runs in. `variable` is the one that named it.
166    #[error("{}", not_absolute(variable, path))]
167    HomeNotAbsolute {
168        variable: &'static str,
169        path: PathBuf,
170    },
171
172    #[error(
173        "the login for `{label}` needs {bytes} bytes and `security` reads {limit} from \
174         stdin, so it can only be written on the argument line, which PITBOARD_NO_ARGV \
175         forbids. {}",
176        smaller(*tool)
177    )]
178    CredentialTooLarge {
179        tool: ProviderId,
180        label: String,
181        bytes: usize,
182        limit: usize,
183    },
184
185    #[error(
186        "`{program}` is not on this machine, and Pitboard signs in with {}'s own sign-in. \
187         Install {}, or point Pitboard at it.",
188        tool.name(),
189        tool.name()
190    )]
191    ProgramMissing { tool: ProviderId, program: String },
192
193    #[error(
194        "CLAUDE_CODE_CUSTOM_OAUTH_URL is set, so Claude Code keeps its login under a \
195         different name than the one Pitboard reads. Unset it to use Pitboard."
196    )]
197    CustomOauthEndpoint,
198
199    #[error("could not read Pitboard's account list at {path}: {source}")]
200    StateUnreadable {
201        path: PathBuf,
202        #[source]
203        source: std::io::Error,
204    },
205
206    #[error(
207        "Pitboard's account list at {path} is corrupt ({source}). \
208         Delete it and enroll your accounts again; parked logins will be lost."
209    )]
210    StateCorrupt {
211        path: PathBuf,
212        #[source]
213        source: serde_json::Error,
214    },
215
216    #[error(
217        "{path} was written by a newer Pitboard (its format is {found}, this one reads \
218         {expected}). Update this Pitboard the way you installed it. The app, and the command \
219         line inside it, update with the app's Check for Updates."
220    )]
221    StateFromNewerVersion {
222        path: PathBuf,
223        found: u32,
224        expected: u32,
225    },
226
227    #[error(
228        "{path} is in a format ({found}) no version of Pitboard has ever written. Delete it \
229         and enroll your accounts again."
230    )]
231    StateVersionUnknown { path: PathBuf, found: u32 },
232
233    #[error(
234        "{path} has an account for `{tool}`, a tool this Pitboard does not know, so it was \
235         written by a newer one. Update this Pitboard the way you installed it. The app, and \
236         the command line inside it, update with the app's Check for Updates."
237    )]
238    StateNamesUnknownTool { path: PathBuf, tool: String },
239
240    #[error(
241        "{path} was written on another computer. Parked logins do not move between \
242         machines, because two machines taking turns presenting one refresh token ends the \
243         login for both. Run `pitboard adopt` to keep your accounts here and drop the \
244         logins they came with; each then needs one `pitboard enroll <label> --sign-in`."
245    )]
246    StateWrongMachine { path: PathBuf },
247
248    #[error(
249        "this platform has no scheduler Pitboard knows how to write. Keeping parked logins \
250         alive here means running `pitboard` yourself from time to time."
251    )]
252    ScheduleUnsupported,
253
254    #[error("the scheduler refused: {detail}")]
255    ScheduleRefused { detail: String },
256
257    /// The schedule renews the default home alone, so it is installed only where
258    /// `PITBOARD_HOME` names that home or nothing. `home` is the directory it names, and
259    /// `default` the default home, `~/.pitboard`.
260    #[error(
261        "the renewal schedule renews only the parked logins in {}, not those in {}, which \
262         PITBOARD_HOME names. Nothing was scheduled. To schedule it for {}, unset \
263         PITBOARD_HOME and install it again.",
264        default.display(),
265        home.display(),
266        default.display()
267    )]
268    ScheduleNotDefaultHome { home: PathBuf, default: PathBuf },
269
270    #[error("the renewal schedule would run {path}, which is not there. Nothing was scheduled.")]
271    ScheduleProgramMissing { path: PathBuf },
272
273    /// macOS runs an app opened where it was downloaded from a copy it makes somewhere
274    /// temporary, which is there while the app runs and gone once it quits.
275    #[error(
276        "the renewal schedule would run {path}, which is in a temporary copy macOS made of \
277         the app and is gone once the app quits. Move Pitboard to your Applications folder, \
278         open it from there, and turn on daily renewal again."
279    )]
280    ScheduleProgramTemporary { path: PathBuf },
281
282    /// An app that names no command line for the schedule, where the only other thing to
283    /// schedule is the app itself, which renews nothing.
284    #[error("this copy of Pitboard has no command line inside it for the renewal schedule to run.")]
285    ScheduleProgramUnnamed,
286
287    #[error("could not write to Pitboard's directory at {path}: {source}")]
288    HomeUnwritable {
289        path: PathBuf,
290        #[source]
291        source: std::io::Error,
292    },
293
294    #[error("could not save Pitboard's account list at {path}: {source}")]
295    StateWriteFailed {
296        path: PathBuf,
297        #[source]
298        source: std::io::Error,
299    },
300
301    #[error(
302        "Claude Code has not run on this machine yet ({path} does not exist). \
303         Run `claude` once, sign in, then try again."
304    )]
305    ClaudeConfigMissing { path: PathBuf },
306
307    #[error("could not read Claude Code's config at {path}: {source}")]
308    ClaudeConfigUnreadable {
309        path: PathBuf,
310        #[source]
311        source: std::io::Error,
312    },
313
314    #[error(
315        "Claude Code's config at {path} is not valid JSON right now ({source}). \
316         It may be mid-write; wait a few seconds and try again."
317    )]
318    ClaudeConfigNotJson {
319        path: PathBuf,
320        #[source]
321        source: serde_json::Error,
322    },
323
324    #[error(
325        "nothing is signed in to {} right now. Run `{}`, sign in, then try again.",
326        tool.name(),
327        tool.login_command()
328    )]
329    LiveCredentialAbsent { tool: ProviderId },
330
331    #[error(
332        "Claude Code's config says {email} is signed in, but Pitboard cannot find that \
333         login in the keychain or in the file it also reads. It will not write a login \
334         where nobody reads it. This usually means Claude Code has started keeping logins \
335         somewhere Pitboard does not know about yet: check for a Pitboard update, and \
336         report it with `pitboard doctor --json` if there is none."
337    )]
338    LiveCredentialElsewhere { email: String },
339
340    #[error(
341        "the signed-in credential is not shaped like a {} login ({detail}). \
342         Run `pitboard doctor` before switching again.",
343        tool.name()
344    )]
345    LiveCredentialShapeUnexpected { tool: ProviderId, detail: String },
346
347    /// The tool is configured to keep its login somewhere Pitboard does not handle.
348    #[error("{reason}.")]
349    LiveStoreUnsupported { tool: ProviderId, reason: String },
350
351    #[error(
352        "no account is enrolled as `{label}`.{enrolled} Run `pitboard enroll {label} \
353         --sign-in` to add it."
354    )]
355    AccountUnknown {
356        label: String,
357        /// The labels that do exist, so a typo costs no second command.
358        enrolled: Enrolled,
359    },
360
361    #[error(
362        "`{label}` has no parked login to switch to: the last one went back into use and \
363         {} has moved on from it. Run `pitboard enroll {label} --sign-in` to sign in to it \
364         again.",
365        tool.name()
366    )]
367    NothingParked { tool: ProviderId, label: String },
368
369    #[error(
370        "the parked login for `{label}` has expired. Run `pitboard enroll {label} --sign-in` \
371         to sign in to it again."
372    )]
373    ParkedLoginExpired { label: String },
374
375    #[error(
376        "{who} is signed in but not enrolled, so it cannot be parked. \
377         Run `pitboard enroll {}` for it first.",
378        Key::new(*tool, "<label>").typed()
379    )]
380    LiveAccountNotEnrolled { tool: ProviderId, who: String },
381
382    #[error(
383        "{email} is already enrolled as `{label}`. To add a different account, run \
384         `pitboard enroll {} --sign-in`.",
385        Key::new(*tool, "<label>").typed()
386    )]
387    AlreadyEnrolled {
388        tool: ProviderId,
389        email: String,
390        label: String,
391    },
392
393    #[error("`{label}` already refers to {who}. Choose a different label.")]
394    LabelTaken { label: String, who: String },
395
396    #[error(
397        "`{typed}` is not a tool Pitboard knows. It knows: {}.",
398        known.join(", ")
399    )]
400    ProviderUnknown { typed: String, known: Vec<String> },
401
402    #[error(
403        "`{label}` is enrolled for more than one tool: {}. Say which one.",
404        matches.iter().map(|m| format!("`{m}`")).collect::<Vec<_>>().join(", ")
405    )]
406    LabelAmbiguous { label: String, matches: Vec<String> },
407
408    #[error("`{label}` is signed in; switch to another account before forgetting it.")]
409    CannotForgetActiveAccount { label: String },
410
411    /// Asked to write an account into its tool's config as the one in use, once another
412    /// account's login is stored, as after a switch made since somebody asked.
413    #[error(
414        "`{label}` is not signed in now, so Pitboard left {}'s config as it is. A switch \
415         writes the account it puts in use there.",
416        tool.name()
417    )]
418    AccountNotInUse { tool: ProviderId, label: String },
419
420    #[error("could not find a free place to park this login. Run `pitboard doctor`.")]
421    ParkSlotExhausted,
422
423    #[error(
424        "the parked login for `{label}` is missing. Run `pitboard enroll {label} --sign-in` \
425         to sign in to it again."
426    )]
427    ParkedCredentialMissing { label: String },
428
429    #[error(
430        "the parked login for `{label}` is not the one Pitboard recorded ({detail}). Run \
431         `pitboard enroll {label} --sign-in` to replace it."
432    )]
433    ParkedCredentialCorrupt { label: String, detail: String },
434
435    #[error("could not back up Claude Code's config ({path}): {source}. Nothing was changed.")]
436    ConfigBackupFailed {
437        path: PathBuf,
438        #[source]
439        source: std::io::Error,
440    },
441
442    /// Claude Code fetches its profile again only once the config's is a day old (the
443    /// register's `config_identity_is_the_last_writers`), so this does not correct itself
444    /// sooner.
445    #[error(
446        "Claude Code's config at {path} could not be updated ({detail}), so Claude Code may \
447         show another account's name than the one in use. `pitboard use` with the account in \
448         use writes it there."
449    )]
450    ConfigWriteFailed { path: PathBuf, detail: String },
451
452    /// The service refused the login's access token as expired. `refused` where Pitboard
453    /// tried to renew the login and its service refuses its refresh token for good, or it
454    /// has none, so only signing in again mends it.
455    #[error("{}", session_expired(*tool, *refused))]
456    SessionExpired { tool: ProviderId, refused: bool },
457
458    #[error(
459        "Pitboard could not confirm with {} which account is signed in ({detail}), and will \
460         not move a login it cannot identify. Check the connection and try again.",
461        tool.service()
462    )]
463    IdentityUnverifiable {
464        tool: ProviderId,
465        cause: Cause,
466        detail: String,
467    },
468
469    #[error("the signed-in account changed while switching. Nothing was moved; try again.")]
470    SignedInAccountChanged,
471
472    #[error(
473        "an earlier switch from `{from}` to `{to}` was interrupted, and Pitboard cannot yet \
474         tell whether it finished ({detail}). Nothing was changed. Run `{}` once so its \
475         session is current, then try again.",
476        tool.program()
477    )]
478    RecoveryUndetermined {
479        tool: ProviderId,
480        from: String,
481        to: String,
482        detail: String,
483    },
484
485    #[error(
486        "could not sign in as `{to}` ({detail}); `{from}` is still signed in, nothing was lost."
487    )]
488    SwitchRolledBack {
489        from: String,
490        to: String,
491        detail: String,
492    },
493
494    #[error(
495        "{} no longer accepts `{label}`'s parked login, so Pitboard did not move anything. \
496         The copy has been dropped; sign in to that account again with \
497         `pitboard enroll {label} --sign-in`.",
498        tool.service()
499    )]
500    ParkedLoginRefused { tool: ProviderId, label: String },
501
502    #[error(
503        "`{label}`'s parked login belongs to {who}, not to the account Pitboard has \
504         under that label. Nothing was moved. Run `pitboard doctor`, then \
505         `pitboard enroll {label} --sign-in` to replace it."
506    )]
507    ParkedLoginBelongsElsewhere { label: String, who: String },
508
509    #[error(
510        "signed in as `{to}`, and the login was gone again before Pitboard finished. {}",
511        after_it_did_not_hold(*tool, from, to)
512    )]
513    SwitchDidNotHold {
514        tool: ProviderId,
515        from: String,
516        to: String,
517    },
518
519    #[error(
520        "could not sign in as `{to}` ({detail}), and could not read the credential store \
521         back to find out whether anything changed. Nothing has been deleted and both \
522         logins are still here. {} and run `pitboard use {to}` again; it finishes or \
523         undoes this before doing anything else.",
524        make_readable(*tool)
525    )]
526    SwitchUnverified {
527        tool: ProviderId,
528        from: String,
529        to: String,
530        detail: String,
531    },
532
533    #[error(
534        "could not sign in as `{to}`, and could not put `{from}` back either ({detail}). \
535         `{from}`'s login is still parked: run `{}` and sign in to any enrolled account, \
536         then `pitboard use {from}`.",
537        tool.login_command()
538    )]
539    SwitchCorrupted {
540        tool: ProviderId,
541        from: String,
542        to: String,
543        detail: String,
544    },
545
546    #[error(
547        "signed in to `{label}` again, and Pitboard could not confirm that its new login \
548         took the place of the one in use ({detail}), so {} may have no login for it now. {}",
549        tool.name(),
550        not_in_use(*tool, label, *parked)
551    )]
552    SignInNotInstalled {
553        tool: ProviderId,
554        label: String,
555        detail: String,
556        /// Whether the new login was parked instead, which keeps the one copy of it.
557        parked: bool,
558        /// What writing it and parking it warned about, which is still true of a change
559        /// that failed afterwards. Taken out by the service and reported beside the error.
560        warnings: Vec<crate::service::Warning>,
561    },
562
563    #[error(
564        "the new login for `{label}` could not be put in use ({detail}), so it was not \
565         kept, and {} keeps the login it has. Run `pitboard enroll {label} --sign-in` to \
566         sign in again.",
567        tool.name()
568    )]
569    SignInNotKept {
570        tool: ProviderId,
571        label: String,
572        detail: String,
573    },
574
575    #[error(
576        "the record of an interrupted switch at {path} is damaged ({source}), so Pitboard \
577         cannot tell what that switch did. Nothing was changed. Check that `pitboard status` \
578         shows the account you expect, then delete the file to continue."
579    )]
580    RecoveryRecordCorrupt {
581        path: PathBuf,
582        #[source]
583        source: serde_json::Error,
584    },
585
586    #[error(
587        "an earlier switch of {} from `{from}` to `{to}` was interrupted while its login was \
588         at {slot}, and this run reads it from somewhere else, so it cannot tell what that \
589         switch did. Nothing was changed. Run Pitboard with {} pointing where it did to \
590         finish it, or `pitboard abandon` to keep every login it names and move on.",
591        tool.name(),
592        tool.home_variable()
593    )]
594    RecoveryElsewhere {
595        tool: ProviderId,
596        from: String,
597        to: String,
598        slot: String,
599    },
600
601    #[error("could not read or write Pitboard's recovery record at {path}: {source}")]
602    RecoveryFailed {
603        path: PathBuf,
604        #[source]
605        source: std::io::Error,
606    },
607
608    #[error(
609        "`{}` was not found on PATH. Install {}, run it once, then try again.",
610        tool.program(),
611        tool.name()
612    )]
613    ProgramNotFound { tool: ProviderId },
614
615    #[error(
616        "could not renew the parked login for `{label}` ({detail}); its last reading is shown \
617         instead. Run `pitboard doctor` if this keeps happening."
618    )]
619    RenewalFailed {
620        label: String,
621        cause: Option<Cause>,
622        detail: String,
623    },
624
625    #[error("the sign-in did not finish, so nothing was enrolled.")]
626    SignInIncomplete,
627
628    /// The file behind the store in use holds something else than what putting it away was
629    /// confirmed for, or is gone.
630    #[error(
631        "{} has changed since putting it away was confirmed. Putting it away again says first \
632         what it holds now.",
633        path.display()
634    )]
635    LeftLoginChanged { path: PathBuf },
636
637    /// The login stored changed before the file behind it could go, where it was going
638    /// because of that login: the file held that very login, or another of the same account.
639    #[error(
640        "the login {} has stored changed while {} was being put away, and whether the file's \
641         login is kept somewhere depended on it. Putting it away again says first what it does \
642         now.",
643        ProviderId::Claude.name(),
644        path.display()
645    )]
646    StoredLoginChanged { path: PathBuf },
647
648    /// The login left in the file is an account's nobody enrolled, so there is no account to
649    /// keep it for, and deleting the file would lose it.
650    #[error(
651        "{} holds {email}'s login{}, an account not enrolled here, so Pitboard has no account to \
652         keep it for. Enrol that account first, with `pitboard enroll <label> --sign-in`, or \
653         with `pitboard enroll <label>` while Claude Code is signed in to it, then run \
654         `pitboard stow` again.",
655        path.display(),
656        crate::words::in_organisation(organization)
657    )]
658    LeftLoginNotEnrolled {
659        path: PathBuf,
660        email: String,
661        organization: String,
662    },
663
664    #[error(
665        "Pitboard could not ask {} whose the login left in {} is ({detail}). Check the \
666         connection and try again.",
667        ProviderId::Claude.service(),
668        path.display()
669    )]
670    LeftLoginUnidentified {
671        path: PathBuf,
672        cause: Cause,
673        detail: String,
674    },
675
676    /// What stopped putting away the file behind a tool's store, said with how far that had
677    /// gone, which is never as far as deleting the file. Its code, cause and exit status are
678    /// `error`'s, so a program branches on what stopped it.
679    #[error("{} Pitboard {partway}.", sentence(error))]
680    StowStopped {
681        partway: Partway,
682        /// What parking the login warned about, where it was parked first. Taken out by the
683        /// service and reported beside the error.
684        warnings: Vec<crate::service::Warning>,
685        error: Box<Error>,
686    },
687
688    /// What stopped Pitboard saving a login of a tool's own it had just renewed, where no copy
689    /// of the renewed login could be kept either, so the refresh token the tool's store holds
690    /// is spent. Its code, cause and exit status are `error`'s.
691    #[error(
692        "{} Pitboard renewed {}'s login and could not save the renewed login, so the refresh \
693         token stored is spent. Run `{}`, sign in, then try again.",
694        sentence(error),
695        tool.name(),
696        tool.login_command()
697    )]
698    RenewalLost { tool: ProviderId, error: Box<Error> },
699
700    /// Signing in to a second account works by pointing the tool's own login at a scratch
701    /// directory. Where that does not isolate it from the live login, running one would
702    /// write over the account somebody is using, so Pitboard will not.
703    #[error(
704        "Pitboard will not sign in to a second account on this machine: {reason} Signing \
705         in would write over the login you are using."
706    )]
707    SignInNotIsolated { reason: String },
708
709    #[error(
710        "another `pitboard enroll --sign-in` is already waiting for its sign-in. Finish or \
711         cancel that one first."
712    )]
713    SignInInProgress,
714
715    /// This process runs as root or under sudo, or elevated on Windows, or the system could
716    /// not say whether it does, so Pitboard changes nothing: the one gate every change passes
717    /// refused it. `why` is how it runs, as the host said it, and `None` where the host could
718    /// not say.
719    #[error("{}", crate::words::elevated(crate::host::OS, *why).refusal)]
720    Elevated { why: Option<&'static str> },
721
722    #[error("{}", crate::words::too_old(*build).refusal)]
723    SystemTooOld { build: Option<u32> },
724
725    /// This is a Windows build of a release made before Pitboard for Windows is released, so
726    /// it does nothing ([`crate::release`]). Kept once Windows is released, where it never
727    /// occurs.
728    #[error("Pitboard for Windows is not released yet. This build changes nothing.")]
729    WindowsNotReleased,
730
731    /// The command line itself was wrong; the message is clap's.
732    #[error("{0}")]
733    Usage(String),
734
735    /// A command that deletes something was run with nobody to ask, without `--yes`.
736    #[error(
737        "`pitboard {command}` asks before it deletes anything, and can ask only in a terminal \
738         without `--json`. Nothing was changed. Run it in a terminal, or add `--yes`."
739    )]
740    ConfirmationNeeded { command: &'static str },
741
742    #[error(transparent)]
743    Store(#[from] crate::store::Error),
744
745    #[error(transparent)]
746    Lock(#[from] crate::lock::LockError),
747}
748
749impl Error {
750    /// A stable identifier a program can branch on. Adding one is safe; renaming one is not.
751    pub fn code(&self) -> &'static str {
752        use Error::*;
753        match self {
754            StateOnSyncedDrive { .. } => "state_on_synced_drive",
755            HomeNotAbsolute { .. } => "home_not_absolute",
756            ProgramMissing { tool, .. } => match tool {
757                ProviderId::Claude => "claude_program_missing",
758                ProviderId::Codex => "codex_program_missing",
759            },
760            CredentialTooLarge { .. } => "credential_too_large",
761            CustomOauthEndpoint => "custom_oauth_endpoint",
762            StateUnreadable { .. } => "state_unreadable",
763            StateCorrupt { .. } => "state_corrupt",
764            StateFromNewerVersion { .. } => "state_from_newer_version",
765            StateVersionUnknown { .. } => "state_version_unknown",
766            StateNamesUnknownTool { .. } => "state_names_unknown_tool",
767            StateWrongMachine { .. } => "state_wrong_machine",
768            StateWriteFailed { .. } => "state_write_failed",
769            ScheduleUnsupported => "schedule_unsupported",
770            ScheduleRefused { .. } => "schedule_refused",
771            ScheduleNotDefaultHome { .. } => "schedule_not_default_home",
772            ScheduleProgramMissing { .. } => "schedule_program_missing",
773            ScheduleProgramTemporary { .. } => "schedule_program_temporary",
774            ScheduleProgramUnnamed => "schedule_program_unnamed",
775            HomeUnwritable { .. } => "home_unwritable",
776            ClaudeConfigMissing { .. } => "claude_config_missing",
777            ClaudeConfigUnreadable { .. } => "claude_config_unreadable",
778            ClaudeConfigNotJson { .. } => "claude_config_not_json",
779            LiveCredentialAbsent { .. } => "live_credential_absent",
780            LiveStoreUnsupported { .. } => "live_store_unsupported",
781            LiveCredentialElsewhere { .. } => "live_credential_elsewhere",
782            LiveCredentialShapeUnexpected { .. } => "live_credential_shape_unexpected",
783            AccountUnknown { .. } => "account_unknown",
784            NothingParked { .. } => "nothing_parked",
785            ParkedLoginExpired { .. } => "parked_login_expired",
786            ParkedLoginRefused { .. } => "parked_login_refused",
787            ParkedLoginBelongsElsewhere { .. } => "parked_login_belongs_elsewhere",
788            LiveAccountNotEnrolled { .. } => "live_account_not_enrolled",
789            AlreadyEnrolled { .. } => "already_enrolled",
790            LabelTaken { .. } => "label_taken",
791            ProviderUnknown { .. } => "provider_unknown",
792            LabelAmbiguous { .. } => "label_ambiguous",
793            CannotForgetActiveAccount { .. } => "cannot_forget_active_account",
794            AccountNotInUse { .. } => "account_not_in_use",
795            ParkSlotExhausted => "park_slot_exhausted",
796            ParkedCredentialMissing { .. } => "parked_credential_missing",
797            ParkedCredentialCorrupt { .. } => "parked_credential_corrupt",
798            ConfigBackupFailed { .. } => "config_backup_failed",
799            ConfigWriteFailed { .. } => "config_write_failed",
800            SwitchRolledBack { .. } => "switch_rolled_back",
801            SwitchDidNotHold { .. } => "switch_did_not_hold",
802            SwitchUnverified { .. } => "switch_unverified",
803            SwitchCorrupted { .. } => "switch_corrupted",
804            SignInNotInstalled { .. } => "sign_in_not_installed",
805            SignInNotKept { .. } => "sign_in_not_kept",
806            RecoveryFailed { .. } => "recovery_failed",
807            RecoveryRecordCorrupt { .. } => "recovery_record_corrupt",
808            SessionExpired { .. } => "session_expired",
809            IdentityUnverifiable { .. } => "identity_unverifiable",
810            SignedInAccountChanged => "signed_in_account_changed",
811            RecoveryUndetermined { .. } => "recovery_undetermined",
812            RecoveryElsewhere { .. } => "recovery_elsewhere",
813            ProgramNotFound { tool } => match tool {
814                ProviderId::Claude => "claude_not_found",
815                ProviderId::Codex => "codex_not_found",
816            },
817            SignInIncomplete => "sign_in_incomplete",
818            LeftLoginChanged { .. } => "left_login_changed",
819            StoredLoginChanged { .. } => "stored_login_changed",
820            LeftLoginNotEnrolled { .. } => "left_login_not_enrolled",
821            LeftLoginUnidentified { .. } => "left_login_unidentified",
822            StowStopped { error, .. } | RenewalLost { error, .. } => error.code(),
823            SignInNotIsolated { .. } => "sign_in_not_isolated",
824            RenewalFailed { .. } => "renewal_failed",
825            SignInInProgress => "sign_in_in_progress",
826            Elevated { .. } => "elevated",
827            SystemTooOld { .. } => "system_too_old",
828            WindowsNotReleased => "windows_not_released",
829            Usage(_) => "usage",
830            ConfirmationNeeded { .. } => "confirmation_needed",
831            Store(e) => e.code(),
832            Lock(e) => e.code(),
833        }
834    }
835
836    /// What went wrong underneath, where the failure came from a request to Anthropic.
837    /// `None` where nothing was asked.
838    pub fn cause(&self) -> Option<Cause> {
839        use Error::*;
840        match self {
841            IdentityUnverifiable { cause, .. } | LeftLoginUnidentified { cause, .. } => {
842                Some(*cause)
843            }
844            RenewalFailed { cause, .. } => *cause,
845            SessionExpired { refused: true, .. } => Some(Cause::LoginRefused),
846            SessionExpired { .. } => Some(Cause::TokenExpired),
847            StowStopped { error, .. } | RenewalLost { error, .. } => error.cause(),
848            _ => None,
849        }
850    }
851
852    /// Warnings a failed change carries in itself, for the caller to report beside it. Only
853    /// a failure that happened after something worth warning about was done carries any.
854    pub(crate) fn take_warnings(&mut self) -> Vec<crate::service::Warning> {
855        match self {
856            Error::SignInNotInstalled { warnings, .. } | Error::StowStopped { warnings, .. } => {
857                std::mem::take(warnings)
858            }
859            _ => Vec::new(),
860        }
861    }
862
863    /// 1 when a request could not be met; 2 when the command line was wrong, or lacks the
864    /// `--yes` a command that deletes needs with nobody to ask; 3 when a login or Claude
865    /// Code's files are in a state Pitboard cannot safely act on: an unexpected format, or a
866    /// login that could not be put back.
867    pub fn exit_code(&self) -> u8 {
868        use Error::*;
869        match self {
870            // A login or Claude Code's files in a state Pitboard will not act on, which is
871            // what exit 3 means: not a failure of the attempt, a refusal to attempt.
872            LiveCredentialShapeUnexpected { .. }
873            | LiveStoreUnsupported { .. }
874            | ClaudeConfigNotJson { .. }
875            | SwitchCorrupted { .. }
876            | SwitchUnverified { .. }
877            | SwitchDidNotHold { .. }
878            | SignInNotInstalled { .. }
879            | LiveCredentialElsewhere { .. }
880            | CredentialTooLarge { .. }
881            | CustomOauthEndpoint
882            | RecoveryRecordCorrupt { .. } => 3,
883            Usage(_) | ConfirmationNeeded { .. } => 2,
884            Store(e) => e.exit_code(),
885            StowStopped { error, .. } | RenewalLost { error, .. } => error.exit_code(),
886            _ => 1,
887        }
888    }
889}
890
891/// `error`'s message as a sentence, ending with a full stop as some do not, for more to be
892/// said after it.
893fn sentence(error: &Error) -> String {
894    let said = error.to_string();
895    if said.ends_with('.') {
896        said
897    } else {
898        format!("{said}.")
899    }
900}
901
902pub type Result<T> = std::result::Result<T, Error>;
903
904/// What Pitboard says of a home that is not a full path: which variable named it, and what
905/// it holds.
906fn not_absolute(variable: &str, path: &std::path::Path) -> String {
907    let holds = if path.as_os_str().is_empty() {
908        "empty".to_string()
909    } else {
910        format!("`{}`, which is not a full path", path.display())
911    };
912    format!(
913        "{variable} is {holds}, so the folder it names would depend on where each program \
914         runs. Set it to a full path, or unset it; Pitboard reads and changes nothing until \
915         then."
916    )
917}
918
919/// [`Error::SessionExpired`]'s words.
920fn session_expired(tool: ProviderId, refused: bool) -> String {
921    if refused {
922        return format!(
923            "{}'s login in use has expired and cannot be renewed, so Pitboard cannot confirm \
924             which account is signed in. Run `{}`, sign in, then try again.",
925            tool.name(),
926            tool.login_command()
927        );
928    }
929    format!(
930        "{}'s session has expired, so Pitboard cannot confirm which account is signed in. \
931         Run `{}` once so it refreshes, then try again.",
932        tool.name(),
933        tool.program()
934    )
935}
936
937/// What makes a tool's live login readable again, where it could not be read.
938fn make_readable(tool: ProviderId) -> &'static str {
939    match tool {
940        ProviderId::Claude => "Unlock the keychain",
941        ProviderId::Codex => "Make Codex's auth.json readable to you again",
942    }
943}
944
945/// What makes a login smaller, where anything does.
946fn smaller(tool: ProviderId) -> &'static str {
947    match tool {
948        ProviderId::Claude => {
949            "Unset it, or sign out of MCP servers you no longer use to make the login smaller."
950        }
951        ProviderId::Codex => "Unset it to let Pitboard write it.",
952    }
953}
954
955/// Which write took a switched-in login away again, as far as each tool is known to make
956/// one, and what that leaves.
957fn after_it_did_not_hold(tool: ProviderId, from: &str, to: &str) -> String {
958    match tool {
959        ProviderId::Claude => format!(
960            "Claude Code removes a login without taking the write lock when `/logout` has \
961             given up waiting, which is the one write Pitboard cannot exclude. Nothing was \
962             lost: both `{from}` and `{to}` are parked. Run `claude` and sign in to any \
963             enrolled account, then `pitboard use {to}`."
964        ),
965        // Not "nothing was lost": the likeliest writer is a codex session refreshing the
966        // outgoing account, which spends the token in that account's park, and `codex
967        // login` would revoke whatever login is left in auth.json.
968        ProviderId::Codex => format!(
969            "Codex takes no lock, so a codex session still running from before the switch, \
970             refreshing or signing out, rewrote auth.json underneath it. `{to}` is still \
971             parked. `{from}`'s park may hold a token that refresh spent. Quit every running \
972             codex, then run `pitboard` to see what is signed in; do not run `codex login` \
973             or `codex logout` until you have, because either revokes the login they find."
974        ),
975    }
976}
977
978/// Where a new login that could not be put in use went, and the way back from there.
979fn not_in_use(tool: ProviderId, label: &str, parked: bool) -> String {
980    if parked {
981        format!(
982            "The new login is parked, so it is not lost. Run `pitboard` to see what is \
983             signed in; if nothing is, run `{}` and sign in to any enrolled account, then \
984             `pitboard use {label}`.",
985            tool.login_command()
986        )
987    } else {
988        format!(
989            "It could not be parked either: run `{}` and sign in to `{label}` again.",
990            tool.login_command()
991        )
992    }
993}
994
995#[cfg(test)]
996mod tests {
997    use super::*;
998
999    #[test]
1000    fn codes_are_unique_so_a_caller_can_branch_on_them() {
1001        let samples = [
1002            Error::LiveCredentialAbsent {
1003                tool: ProviderId::Claude,
1004            },
1005            Error::ParkSlotExhausted,
1006            Error::SignedInAccountChanged,
1007            Error::AccountUnknown {
1008                label: "x".into(),
1009                enrolled: Enrolled::default(),
1010            },
1011            Error::NothingParked {
1012                tool: ProviderId::Claude,
1013                label: "x".into(),
1014            },
1015            Error::ParkedLoginExpired { label: "x".into() },
1016            Error::AccountNotInUse {
1017                tool: ProviderId::Claude,
1018                label: "x".into(),
1019            },
1020            Error::LabelTaken {
1021                label: "x".into(),
1022                who: "e".into(),
1023            },
1024            Error::SwitchRolledBack {
1025                from: "x".into(),
1026                to: "y".into(),
1027                detail: "d".into(),
1028            },
1029            Error::SignInNotKept {
1030                tool: ProviderId::Codex,
1031                label: "x".into(),
1032                detail: "d".into(),
1033            },
1034            Error::Elevated { why: None },
1035            Error::SystemTooOld { build: Some(22631) },
1036            Error::WindowsNotReleased,
1037            Error::LeftLoginChanged { path: "f".into() },
1038            Error::StoredLoginChanged { path: "f".into() },
1039            Error::Usage("u".into()),
1040            Error::ConfirmationNeeded { command: "stow" },
1041        ];
1042        let mut codes: Vec<&str> = samples.iter().map(Error::code).collect();
1043        codes.sort_unstable();
1044        let before = codes.len();
1045        codes.dedup();
1046        assert_eq!(codes.len(), before);
1047    }
1048
1049    /// Whatever stops putting the file away is branched on by its own code, cause and exit
1050    /// status, and says after its own words how far putting it away had gone.
1051    #[test]
1052    fn what_stopped_putting_a_file_away_keeps_its_code_and_says_how_far_it_went() {
1053        let stopped = Error::StowStopped {
1054            partway: Partway::Parked {
1055                label: "work".into(),
1056            },
1057            warnings: Vec::new(),
1058            error: Box::new(Error::Store(crate::store::Error::Locked)),
1059        };
1060
1061        assert_eq!(stopped.code(), "credential_store_locked");
1062        assert_eq!(
1063            stopped.exit_code(),
1064            Error::Store(crate::store::Error::Locked).exit_code()
1065        );
1066        assert!(
1067            stopped.to_string().ends_with(
1068                "desktop session. Pitboard deleted nothing, having parked the login in the file \
1069                 for `work`."
1070            ),
1071            "{stopped}"
1072        );
1073
1074        let unidentified = Error::StowStopped {
1075            partway: Partway::Nothing,
1076            warnings: Vec::new(),
1077            error: Box::new(Error::LeftLoginUnidentified {
1078                path: "f".into(),
1079                cause: Cause::Unreachable,
1080                detail: "offline".into(),
1081            }),
1082        };
1083        assert_eq!(unidentified.code(), "left_login_unidentified");
1084        assert_eq!(unidentified.cause(), Some(Cause::Unreachable));
1085        assert!(
1086            unidentified
1087                .to_string()
1088                .ends_with("try again. Pitboard changed nothing."),
1089            "{unidentified}"
1090        );
1091    }
1092
1093    #[test]
1094    fn a_broken_assumption_exits_differently_from_a_bad_request() {
1095        assert_eq!(
1096            Error::LiveCredentialShapeUnexpected {
1097                tool: ProviderId::Claude,
1098                detail: "x".into()
1099            }
1100            .exit_code(),
1101            3
1102        );
1103        assert_eq!(
1104            Error::AccountUnknown {
1105                label: "x".into(),
1106                enrolled: Enrolled::default()
1107            }
1108            .exit_code(),
1109            1
1110        );
1111    }
1112
1113    #[test]
1114    fn every_message_tells_the_user_something_to_do() {
1115        // A message that only states a fact leaves the user stuck.
1116        let actionable = [
1117            Error::LiveCredentialAbsent {
1118                tool: ProviderId::Claude,
1119            }
1120            .to_string(),
1121            Error::LiveCredentialAbsent {
1122                tool: ProviderId::Codex,
1123            }
1124            .to_string(),
1125            Error::AccountUnknown {
1126                label: "work".into(),
1127                enrolled: Enrolled(vec!["personal".into()]),
1128            }
1129            .to_string(),
1130            Error::NothingParked {
1131                tool: ProviderId::Claude,
1132                label: "work".into(),
1133            }
1134            .to_string(),
1135            Error::ParkedLoginExpired {
1136                label: "work".into(),
1137            }
1138            .to_string(),
1139            Error::ParkedCredentialMissing {
1140                label: "work".into(),
1141            }
1142            .to_string(),
1143            Error::LiveAccountNotEnrolled {
1144                tool: ProviderId::Claude,
1145                who: "a@b.c".into(),
1146            }
1147            .to_string(),
1148            Error::SignInNotKept {
1149                tool: ProviderId::Codex,
1150                label: "codex/work".into(),
1151                detail: "the keychain is locked".into(),
1152            }
1153            .to_string(),
1154            Error::ConfirmationNeeded { command: "forget" }.to_string(),
1155        ];
1156        for message in actionable {
1157            assert!(
1158                message.contains("Run ") || message.contains("Sign in"),
1159                "no action offered: {message}"
1160            );
1161        }
1162    }
1163
1164    /// Codex's messages name Codex and the command that signs in to it; Claude Code's name
1165    /// Claude Code. A message about the wrong tool sends somebody to run the wrong program.
1166    #[test]
1167    fn a_message_names_the_tool_it_is_about() {
1168        let codex = Error::LiveCredentialAbsent {
1169            tool: ProviderId::Codex,
1170        }
1171        .to_string();
1172        assert!(
1173            codex.contains("Codex") && codex.contains("`codex login`"),
1174            "{codex}"
1175        );
1176        assert!(!codex.contains("Claude"), "{codex}");
1177
1178        let unverifiable = Error::IdentityUnverifiable {
1179            tool: ProviderId::Codex,
1180            cause: Cause::Unreachable,
1181            detail: "offline".into(),
1182        }
1183        .to_string();
1184        assert!(unverifiable.contains("OpenAI"), "{unverifiable}");
1185
1186        let claude = Error::IdentityUnverifiable {
1187            tool: ProviderId::Claude,
1188            cause: Cause::Unreachable,
1189            detail: "offline".into(),
1190        }
1191        .to_string();
1192        assert!(claude.contains("confirm with Anthropic"), "{claude}");
1193    }
1194
1195    /// The codes of the two tools' missing programs stay distinct, and Claude Code's keep
1196    /// the names they were released under.
1197    #[test]
1198    fn a_missing_program_keeps_its_released_code() {
1199        let missing = |tool| Error::ProgramNotFound { tool }.code();
1200        assert_eq!(missing(ProviderId::Claude), "claude_not_found");
1201        assert_eq!(missing(ProviderId::Codex), "codex_not_found");
1202        let absent = |tool| {
1203            Error::ProgramMissing {
1204                tool,
1205                program: "x".into(),
1206            }
1207            .code()
1208        };
1209        assert_eq!(absent(ProviderId::Claude), "claude_program_missing");
1210        assert_eq!(absent(ProviderId::Codex), "codex_program_missing");
1211    }
1212
1213    /// A Pitboard that finds its files written by a newer one is updated by the route it
1214    /// came by. The app's Check for Updates moves only the app and the command line inside
1215    /// it, so it is not offered as another way to update this one.
1216    #[test]
1217    fn a_newer_pitboards_files_say_which_update_moves_which_pitboard() {
1218        let path = PathBuf::from("/home/x/.pitboard/state.json");
1219        for message in [
1220            Error::StateFromNewerVersion {
1221                path: path.clone(),
1222                found: 9,
1223                expected: 5,
1224            }
1225            .to_string(),
1226            Error::StateNamesUnknownTool {
1227                path: path.clone(),
1228                tool: "gemini".into(),
1229            }
1230            .to_string(),
1231        ] {
1232            assert!(
1233                message.contains("Update this Pitboard the way you installed it."),
1234                "{message}"
1235            );
1236            assert!(
1237                message.contains("The app, and the command line inside it, update with"),
1238                "{message}"
1239            );
1240            assert!(!message.contains("or with the app"), "{message}");
1241        }
1242    }
1243
1244    /// Advice to enrol names the tool the account is for: a bare name would enrol a Claude
1245    /// Code account for a Codex login.
1246    #[test]
1247    fn enrolment_advice_keeps_the_tool() {
1248        let codex = Error::LiveAccountNotEnrolled {
1249            tool: ProviderId::Codex,
1250            who: "a@b.c".into(),
1251        }
1252        .to_string();
1253        assert!(codex.contains("pitboard enroll codex/<label>"), "{codex}");
1254        let claude = Error::LiveAccountNotEnrolled {
1255            tool: ProviderId::Claude,
1256            who: "a@b.c".into(),
1257        }
1258        .to_string();
1259        assert!(claude.contains("pitboard enroll <label>"), "{claude}");
1260        let already = Error::AlreadyEnrolled {
1261            tool: ProviderId::Codex,
1262            email: "a@b.c".into(),
1263            label: "codex/work".into(),
1264        }
1265        .to_string();
1266        assert!(
1267            already.contains("pitboard enroll codex/<label> --sign-in"),
1268            "{already}"
1269        );
1270    }
1271
1272    /// Only a command that changes something finishes an interrupted switch; a plain
1273    /// `pitboard` reads and leaves it. So the message names the switch to run again, as it
1274    /// would be typed here.
1275    #[test]
1276    fn an_unverified_switch_names_a_command_that_finishes_it() {
1277        let message = Error::SwitchUnverified {
1278            tool: ProviderId::Codex,
1279            from: "codex/personal".into(),
1280            to: "codex/work".into(),
1281            detail: "auth.json is not readable".into(),
1282        }
1283        .to_string();
1284        assert!(
1285            message.contains("run `pitboard use codex/work` again"),
1286            "{message}"
1287        );
1288        assert!(!message.contains("run `pitboard` again"), "{message}");
1289    }
1290}