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