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::host::Os;
5use crate::provider::ProviderId;
6use crate::state::Key;
7use std::path::PathBuf;
8
9/// The labels an account list holds, rendered for a message: " Enrolled: `a`, `b`." or
10/// nothing at all when none are.
11#[derive(Debug, Clone, PartialEq, Eq, Default)]
12pub struct Enrolled(pub Vec<String>);
13
14impl std::fmt::Display for Enrolled {
15    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16        if self.0.is_empty() {
17            return write!(f, " Nothing is enrolled yet.");
18        }
19        let labels: Vec<String> = self.0.iter().map(|l| format!("`{l}`")).collect();
20        write!(f, " Enrolled: {}.", labels.join(", "))
21    }
22}
23
24/// How far putting away the file behind a tool's store had gone when something stopped it.
25/// Never as far as deleting the file, and never further than keeping the login it held.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum Partway {
28    /// Nothing was changed.
29    Nothing,
30    /// The login Claude Code has stored was renewed to tell whose it is, which spent its
31    /// refresh token, and the renewed one waits in Pitboard's vault for the next change to
32    /// save it there.
33    StoredRenewedKept,
34    /// The login the file held was renewed, which spent its refresh token, and the renewed
35    /// one could not be written back there, nor kept.
36    RenewedUnwritten,
37    /// The login the file held was renewed, which spent its refresh token, and the renewed
38    /// one waits in Pitboard's vault for the next change to write it back there.
39    RenewedKept,
40    /// The login in the file was renewed, and the renewed one written back there.
41    Renewed,
42    /// The login in the file was parked for `label`.
43    Parked { label: String },
44}
45
46impl std::fmt::Display for Partway {
47    /// What Pitboard did, after "Pitboard".
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            Partway::Nothing => write!(f, "changed nothing"),
51            Partway::StoredRenewedKept => write!(
52                f,
53                "deleted nothing. It renewed the login Claude Code has stored and keeps the \
54                 renewed login until the next change saves it there"
55            ),
56            Partway::RenewedUnwritten => write!(
57                f,
58                "deleted nothing. It renewed the login the file held and could not write the \
59                 renewed login back there, so that login no longer works"
60            ),
61            Partway::RenewedKept => write!(
62                f,
63                "deleted nothing. It renewed the login the file held and keeps the renewed login \
64                 until the next change writes it back there"
65            ),
66            Partway::Renewed => write!(
67                f,
68                "deleted nothing, having renewed the login in the file and written it back there"
69            ),
70            Partway::Parked { label } => write!(
71                f,
72                "deleted nothing, having parked the login in the file for `{label}`"
73            ),
74        }
75    }
76}
77
78/// Why a request to Anthropic did not produce an answer Pitboard could use.
79///
80/// The code on an error says what Pitboard was doing; this says what went wrong underneath
81/// it, and whether trying again is worth anything. Without it every failure that was not a
82/// 401 arrived at a front end as one code with a sentence of prose, so neither the command
83/// line nor the app could tell being offline from being rate limited from a login Anthropic
84/// has finished with.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
86#[serde(rename_all = "snake_case")]
87#[non_exhaustive]
88pub enum Cause {
89    /// Anthropic could not be reached at all.
90    Unreachable,
91    /// Anthropic asked for less traffic.
92    RateLimited,
93    /// Anthropic answered, badly, and may answer well later.
94    ServerError,
95    /// Anthropic refused the request, and asked again would refuse it the same way.
96    Refused,
97    /// Anthropic answered something Pitboard does not understand, which means its shape
98    /// moved. Trying again will produce the same thing.
99    AnswerNotUnderstood,
100    /// This login is finished: revoked, or already used somewhere else.
101    LoginRefused,
102    /// The token has expired. For the signed-in login that is ordinary and Claude Code
103    /// renews it; for a parked one it means the park needs renewing first.
104    TokenExpired,
105}
106
107impl Cause {
108    pub(crate) fn of_provider(error: &crate::provider::ProviderError) -> Cause {
109        use crate::provider::ProviderError as P;
110        match error {
111            P::Unauthorized => Cause::TokenExpired,
112            P::RateLimited { .. } => Cause::RateLimited,
113            P::Network { .. } => Cause::Unreachable,
114            P::Unavailable { .. } => Cause::ServerError,
115            // The request is not one the service takes any more, which no sign-in mends.
116            P::Refused { refusal, .. } if matches!(refusal.status, 404 | 405) => {
117                Cause::AnswerNotUnderstood
118            }
119            P::Refused { .. } | P::OnHold { .. } => Cause::Refused,
120            P::Unnamed { error } => Cause::of_provider(error),
121            P::Malformed { .. }
122            | P::ShapeUnexpected { .. }
123            | P::Unsupported { .. }
124            | P::NoLogin { .. }
125            | P::CodexHomeMissing { .. } => Cause::AnswerNotUnderstood,
126            P::InvalidGrant { .. } => Cause::LoginRefused,
127        }
128    }
129
130    /// Stable, for a program to branch on; the same as its JSON form.
131    pub fn code(self) -> &'static str {
132        match self {
133            Cause::Unreachable => "unreachable",
134            Cause::RateLimited => "rate_limited",
135            Cause::ServerError => "server_error",
136            Cause::Refused => "refused",
137            Cause::AnswerNotUnderstood => "answer_not_understood",
138            Cause::LoginRefused => "login_refused",
139            Cause::TokenExpired => "token_expired",
140        }
141    }
142
143    /// Whether the same request, later, could answer differently. A front end deciding
144    /// whether to back off or to give up reads this and nothing else.
145    pub fn worth_retrying(self) -> bool {
146        match self {
147            Cause::Unreachable | Cause::RateLimited | Cause::ServerError => true,
148            Cause::Refused
149            | Cause::AnswerNotUnderstood
150            | Cause::LoginRefused
151            | Cause::TokenExpired => false,
152        }
153    }
154}
155
156/// The login whose account Pitboard could not confirm.
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub enum Unidentified {
159    /// The one its tool has signed in.
160    SignedIn,
161    /// A login whose account its service named, and whose email it would not give.
162    Unnamed,
163    /// The one a sign-in for the account typed as `label` made.
164    SignIn { label: String },
165    /// The one parked for the account typed as `label`.
166    Parked { label: String },
167}
168
169#[derive(Debug, thiserror::Error)]
170#[non_exhaustive]
171pub enum Error {
172    #[error(
173        "{path} looks like it is inside {marker}, which syncs to other machines. \
174         Parked logins belong to one machine; set PITBOARD_HOME to a local folder."
175    )]
176    StateOnSyncedDrive { path: PathBuf, marker: String },
177
178    /// A home the environment names is empty or relative, so what it leads to would depend
179    /// on the folder each program runs in. `variable` is the one that named it.
180    #[error("{}", not_absolute(variable, path))]
181    HomeNotAbsolute {
182        variable: &'static str,
183        path: PathBuf,
184    },
185
186    #[error("{}.", codex_home_missing(path))]
187    CodexHomeMissing { path: PathBuf },
188
189    #[error("{}", too_large(crate::host::OS, *tool, label, *bytes, *limit))]
190    CredentialTooLarge {
191        tool: ProviderId,
192        label: String,
193        bytes: usize,
194        limit: usize,
195    },
196
197    #[error(
198        "`{program}` is not on this machine, and Pitboard signs in with {}'s own sign-in. \
199         Install {}, or point Pitboard at it.",
200        tool.name(),
201        tool.name()
202    )]
203    ProgramMissing { tool: ProviderId, program: String },
204
205    #[error(
206        "CLAUDE_CODE_CUSTOM_OAUTH_URL is set, so Claude Code keeps its login under a \
207         different name than the one Pitboard reads. Unset it to use Pitboard."
208    )]
209    CustomOauthEndpoint,
210
211    #[error("could not read Pitboard's account list at {path}: {source}")]
212    StateUnreadable {
213        path: PathBuf,
214        #[source]
215        source: std::io::Error,
216    },
217
218    #[error(
219        "Pitboard's account list at {path} is corrupt ({source}). \
220         Delete it and enroll your accounts again; parked logins will be lost."
221    )]
222    StateCorrupt {
223        path: PathBuf,
224        #[source]
225        source: serde_json::Error,
226    },
227
228    #[error(
229        "{path} was written by a newer Pitboard (its format is {found}, this one reads \
230         {expected}). Update this Pitboard the way you installed it. The app, and the command \
231         line inside it, update with the app's Check for Updates."
232    )]
233    StateFromNewerVersion {
234        path: PathBuf,
235        found: u32,
236        expected: u32,
237    },
238
239    #[error(
240        "{path} is in a format ({found}) no version of Pitboard has ever written. Delete it \
241         and enroll your accounts again."
242    )]
243    StateVersionUnknown { path: PathBuf, found: u32 },
244
245    #[error("could not read which accounts Pitboard warms up, at {path}: {source}")]
246    WarmSettingUnreadable {
247        path: PathBuf,
248        #[source]
249        source: std::io::Error,
250    },
251
252    #[error(
253        "which accounts Pitboard warms up, at {path}, cannot be read ({detail}). Delete it, \
254         then turn warm-up on again for each account you want it for."
255    )]
256    WarmSettingCorrupt { path: PathBuf, detail: String },
257
258    #[error("{}", warm_up_unsupported(*tool, label))]
259    WarmUpUnsupported { tool: ProviderId, label: String },
260
261    #[error("`{label}` was not warmed up: {}.", crate::words::warm_failed(.why))]
262    WarmUpFailed {
263        label: String,
264        why: crate::duty::warm::Failure,
265    },
266
267    #[error("`{label}` {why}, so it is not warmed up now.")]
268    WarmUpNotDue {
269        label: String,
270        why: String,
271        code: &'static str,
272    },
273
274    #[error("Pitboard warms up no account now: {}.", crate::words::not_warming(.halt))]
275    WarmUpHalted { halt: crate::duty::warm::Halt },
276
277    #[error("another Pitboard is warming up an account. Try again in a minute.")]
278    WarmUpInProgress,
279
280    #[error(
281        "{path} has an account for `{tool}`, a tool this Pitboard does not know, so it was \
282         written by a newer one. Update this Pitboard the way you installed it. The app, and \
283         the command line inside it, update with the app's Check for Updates."
284    )]
285    StateNamesUnknownTool { path: PathBuf, tool: String },
286
287    #[error(
288        "{path} was written on another computer. Parked logins do not move between \
289         machines, because two machines taking turns presenting one refresh token ends the \
290         login for both. Run `pitboard adopt` to keep your accounts here and drop the \
291         logins they came with; each then needs one `pitboard enroll <label> --sign-in`."
292    )]
293    StateWrongMachine { path: PathBuf },
294
295    #[error(
296        "this platform has no scheduler Pitboard knows how to write. Keeping parked logins \
297         alive here means running `pitboard` yourself from time to time."
298    )]
299    ScheduleUnsupported,
300
301    #[error("the scheduler refused: {detail}")]
302    ScheduleRefused { detail: String },
303
304    /// The schedule renews the default home alone, so it is installed only where
305    /// `PITBOARD_HOME` names that home or nothing. `home` is the directory it names, and
306    /// `default` the default home, `~/.pitboard`.
307    #[error(
308        "the renewal schedule renews only the parked logins in {}, not those in {}, which \
309         PITBOARD_HOME names. Nothing was scheduled. To schedule it for {}, unset \
310         PITBOARD_HOME and install it again.",
311        default.display(),
312        home.display(),
313        default.display()
314    )]
315    ScheduleNotDefaultHome { home: PathBuf, default: PathBuf },
316
317    #[error("the renewal schedule would run {path}, which is not there. Nothing was scheduled.")]
318    ScheduleProgramMissing { path: PathBuf },
319
320    /// macOS runs an app opened where it was downloaded from a copy it makes somewhere
321    /// temporary, which is there while the app runs and gone once it quits. Windows' Explorer
322    /// runs a program opened inside a zip from a copy of it.
323    #[error("{}", temporary_copy(.path))]
324    ScheduleProgramTemporary { path: PathBuf },
325
326    /// An app that names no command line for the schedule, where the only other thing to
327    /// schedule is the app itself, which renews nothing.
328    #[error("this copy of Pitboard has no command line inside it for the renewal schedule to run.")]
329    ScheduleProgramUnnamed,
330
331    #[error("could not write to Pitboard's directory at {path}: {source}")]
332    HomeUnwritable {
333        path: PathBuf,
334        #[source]
335        source: std::io::Error,
336    },
337
338    #[error("could not save Pitboard's account list at {path}: {source}")]
339    StateWriteFailed {
340        path: PathBuf,
341        #[source]
342        source: std::io::Error,
343    },
344
345    #[error(
346        "Claude Code has not run on this machine yet ({path} does not exist). \
347         Run `claude` once, sign in, then try again."
348    )]
349    ClaudeConfigMissing { path: PathBuf },
350
351    #[error("could not read Claude Code's config at {path}: {source}")]
352    ClaudeConfigUnreadable {
353        path: PathBuf,
354        #[source]
355        source: std::io::Error,
356    },
357
358    #[error(
359        "Claude Code's config at {path} is not valid JSON right now ({source}). \
360         It may be mid-write; wait a few seconds and try again."
361    )]
362    ClaudeConfigNotJson {
363        path: PathBuf,
364        #[source]
365        source: serde_json::Error,
366    },
367
368    #[error("{}", crate::words::nothing_signed_in(*tool, label.as_deref()))]
369    LiveCredentialAbsent {
370        tool: ProviderId,
371        // the label typed, where a sentence names the command to run for it
372        label: Option<String>,
373    },
374
375    #[error("{}", login_elsewhere(crate::host::OS, email))]
376    LiveCredentialElsewhere { email: String },
377
378    #[error(
379        "the signed-in credential is not shaped like a {} login ({detail}). \
380         Run `pitboard doctor` before switching again.",
381        tool.name()
382    )]
383    LiveCredentialShapeUnexpected { tool: ProviderId, detail: String },
384
385    /// The tool is configured to keep its login somewhere Pitboard does not handle.
386    #[error("{reason}.")]
387    LiveStoreUnsupported { tool: ProviderId, reason: String },
388
389    #[error(
390        "no account is enrolled as `{label}`.{enrolled} Run `pitboard enroll {label} \
391         --sign-in` to add it."
392    )]
393    AccountUnknown {
394        label: String,
395        /// The labels that do exist, so a typo costs no second command.
396        enrolled: Enrolled,
397    },
398
399    #[error(
400        "`{label}` has no parked login to switch to: the last one went back into use and \
401         {} has moved on from it. Run `pitboard enroll {label} --sign-in` to sign in to it \
402         again.",
403        tool.name()
404    )]
405    NothingParked { tool: ProviderId, label: String },
406
407    #[error(
408        "the parked login for `{label}` has expired. Run `pitboard enroll {label} --sign-in` \
409         to sign in to it again."
410    )]
411    ParkedLoginExpired { label: String },
412
413    #[error("{}", not_enrolled(*tool, who))]
414    LiveAccountNotEnrolled { tool: ProviderId, who: String },
415
416    #[error("{}", already_enrolled(*tool, email, label, asked, *waited))]
417    AlreadyEnrolled {
418        tool: ProviderId,
419        email: String,
420        label: String,
421        // the label typed
422        asked: String,
423        // a sign-in inside the app was waited for under `asked`
424        waited: bool,
425    },
426
427    #[error("`{label}` already refers to {who}. Choose a different label.")]
428    LabelTaken { label: String, who: String },
429
430    #[error(
431        "`{typed}` is not a tool Pitboard knows. It knows: {}.",
432        known.join(", ")
433    )]
434    ProviderUnknown { typed: String, known: Vec<String> },
435
436    #[error(
437        "`{label}` is enrolled for more than one tool: {}. Say which one.",
438        matches.iter().map(|m| format!("`{m}`")).collect::<Vec<_>>().join(", ")
439    )]
440    LabelAmbiguous { label: String, matches: Vec<String> },
441
442    #[error("`{label}` is signed in; switch to another account before forgetting it.")]
443    CannotForgetActiveAccount { label: String },
444
445    /// Asked to write an account into its tool's config as the one in use, once another
446    /// account's login is stored, as after a switch made since somebody asked.
447    #[error(
448        "`{label}` is not signed in now, so Pitboard left {}'s config as it is. A switch \
449         writes the account it puts in use there.",
450        tool.name()
451    )]
452    AccountNotInUse { tool: ProviderId, label: String },
453
454    #[error("could not find a free place to park this login. Run `pitboard doctor`.")]
455    ParkSlotExhausted,
456
457    #[error(
458        "the parked login for `{label}` is missing. Run `pitboard enroll {label} --sign-in` \
459         to sign in to it again."
460    )]
461    ParkedCredentialMissing { label: String },
462
463    #[error(
464        "the parked login for `{label}` is not the one Pitboard recorded ({detail}). Run \
465         `pitboard enroll {label} --sign-in` to replace it."
466    )]
467    ParkedCredentialCorrupt { label: String, detail: String },
468
469    #[error("could not back up Claude Code's config ({path}): {source}. Nothing was changed.")]
470    ConfigBackupFailed {
471        path: PathBuf,
472        #[source]
473        source: std::io::Error,
474    },
475
476    /// Claude Code fetches its profile again only once the config's is a day old (the
477    /// register's `config_identity_is_the_last_writers`), so this does not correct itself
478    /// sooner.
479    #[error(
480        "Claude Code's config at {path} could not be updated ({detail}), so Claude Code may \
481         show another account's name than the one in use. `pitboard use` with the account in \
482         use writes it there."
483    )]
484    ConfigWriteFailed { path: PathBuf, detail: String },
485
486    /// The service refused the login's access token as expired. `refused` where Pitboard
487    /// tried to renew the login and its service refuses its refresh token for good, or it
488    /// has none, so only signing in again mends it.
489    #[error("{}", session_expired(*tool, *refused))]
490    SessionExpired { tool: ProviderId, refused: bool },
491
492    #[error("{}", identity_unverifiable(*tool, login, *cause, detail))]
493    IdentityUnverifiable {
494        tool: ProviderId,
495        login: Unidentified,
496        cause: Cause,
497        detail: String,
498    },
499
500    #[error("the signed-in account changed while switching. Nothing was moved; try again.")]
501    SignedInAccountChanged,
502
503    /// The service refuses the refresh token of the login in use for good, so that login no
504    /// longer works and cannot be parked, and a switch to `label` asks first to put that
505    /// account's login in its place.
506    #[error("{}", live_login_refused(*tool, label, detail))]
507    LiveLoginRefused {
508        tool: ProviderId,
509        label: String,
510        detail: String,
511    },
512
513    #[error(
514        "could not put `{label}`'s login in place of {}'s refused login ({detail}). `{label}`'s \
515         login is still parked, and nothing was lost.",
516        tool.name()
517    )]
518    ReplaceFailed {
519        tool: ProviderId,
520        label: String,
521        detail: String,
522    },
523
524    #[error(
525        "could not put `{label}`'s login where {} reads it ({detail}). `{label}`'s login is \
526         still parked, and nothing was lost.",
527        tool.name()
528    )]
529    InstallFailed {
530        tool: ProviderId,
531        label: String,
532        detail: String,
533    },
534
535    #[error("{}", account_on_hold(*tool, login, link.as_deref()))]
536    AccountOnHold {
537        tool: ProviderId,
538        login: Unidentified,
539        link: Option<String>,
540    },
541
542    #[error("{}", recovery_undetermined(*tool, from, to, detail))]
543    RecoveryUndetermined {
544        tool: ProviderId,
545        from: String,
546        to: String,
547        detail: String,
548    },
549
550    #[error(
551        "an earlier switch from `{from}` to `{to}` stopped partway through writing {}'s login, \
552         which nothing reads until a whole login is written over it, and Pitboard could not \
553         write `{to}`'s ({detail}). Nothing was deleted. Fix that, then try again: Pitboard \
554         finishes the switch first.",
555        tool.name()
556    )]
557    RecoveryUnfinished {
558        tool: ProviderId,
559        from: String,
560        to: String,
561        detail: String,
562    },
563
564    #[error(
565        "could not sign in as `{to}` ({detail}); `{from}` is still signed in, nothing was lost."
566    )]
567    SwitchRolledBack {
568        from: String,
569        to: String,
570        detail: String,
571    },
572
573    #[error(
574        "{} no longer accepts `{label}`'s parked login, so Pitboard did not move anything. \
575         The copy has been dropped; sign in to that account again with \
576         `pitboard enroll {label} --sign-in`.",
577        tool.service()
578    )]
579    ParkedLoginRefused { tool: ProviderId, label: String },
580
581    #[error(
582        "`{label}`'s parked login belongs to {who}, not to the account Pitboard has \
583         under that label. Nothing was moved. Run `pitboard doctor`, then \
584         `pitboard enroll {label} --sign-in` to replace it."
585    )]
586    ParkedLoginBelongsElsewhere { label: String, who: String },
587
588    #[error(
589        "signed in as `{to}`, and the login was gone again before Pitboard finished. {}",
590        after_it_did_not_hold(*tool, from, to)
591    )]
592    SwitchDidNotHold {
593        tool: ProviderId,
594        from: String,
595        to: String,
596    },
597
598    #[error(
599        "could not sign in as `{to}` ({detail}), and could not read the credential store \
600         back to find out whether anything changed. Nothing has been deleted and both \
601         logins are still here. {} and run `pitboard use {to}` again; it finishes or \
602         undoes this before doing anything else.",
603        make_readable(crate::host::OS, *tool)
604    )]
605    SwitchUnverified {
606        tool: ProviderId,
607        from: String,
608        to: String,
609        detail: String,
610    },
611
612    #[error(
613        "could not sign in as `{to}`, and could not put `{from}` back either ({detail}). \
614         `{from}`'s login is still parked: {} and sign in to any enrolled account, then \
615         `pitboard use {from}`.",
616        crate::words::sign_in_yourself(*tool, false)
617    )]
618    SwitchCorrupted {
619        tool: ProviderId,
620        from: String,
621        to: String,
622        detail: String,
623    },
624
625    #[error(
626        "signed in to `{label}` again, and Pitboard could not confirm that its new login \
627         took the place of the one in use ({detail}), so {} may have no login for it now. {}",
628        tool.name(),
629        not_in_use(*tool, label, *parked)
630    )]
631    SignInNotInstalled {
632        tool: ProviderId,
633        label: String,
634        detail: String,
635        /// Whether the new login was parked instead, which keeps the one copy of it.
636        parked: bool,
637        /// What writing it and parking it warned about, which is still true of a change
638        /// that failed afterwards. Taken out by the service and reported beside the error.
639        warnings: Vec<crate::service::Warning>,
640    },
641
642    #[error(
643        "the new login for `{label}` could not be put in use ({detail}), so it was not \
644         kept, and {} keeps the login it has. Run `pitboard enroll {label} --sign-in` to \
645         sign in again.",
646        tool.name()
647    )]
648    SignInNotKept {
649        tool: ProviderId,
650        label: String,
651        detail: String,
652    },
653
654    #[error(
655        "the record of an interrupted switch at {path} is damaged ({source}), so Pitboard \
656         cannot tell what that switch did. Nothing was changed. Check that `pitboard status` \
657         shows the account you expect, then delete the file to continue."
658    )]
659    RecoveryRecordCorrupt {
660        path: PathBuf,
661        #[source]
662        source: serde_json::Error,
663    },
664
665    #[error("{}", recovery_elsewhere(*tool, from, to, slot))]
666    RecoveryElsewhere {
667        tool: ProviderId,
668        from: String,
669        to: String,
670        slot: String,
671    },
672
673    #[error("could not read or write Pitboard's recovery record at {path}: {source}")]
674    RecoveryFailed {
675        path: PathBuf,
676        #[source]
677        source: std::io::Error,
678    },
679
680    #[error(
681        "`{}` was not found on PATH. Install {}, run it once, then try again.",
682        tool.program(),
683        tool.name()
684    )]
685    ProgramNotFound { tool: ProviderId },
686
687    #[error(
688        "could not renew the parked login for `{label}` ({detail}); its last reading is shown \
689         instead. Run `pitboard doctor` if this keeps happening."
690    )]
691    RenewalFailed {
692        label: String,
693        cause: Option<Cause>,
694        detail: String,
695    },
696
697    #[error("the sign-in did not finish, so nothing was enrolled.")]
698    SignInIncomplete,
699
700    /// The file behind the store in use holds something else than what putting it away was
701    /// confirmed for, or is gone.
702    #[error(
703        "{} has changed since putting it away was confirmed. Putting it away again says first \
704         what it holds now.",
705        path.display()
706    )]
707    LeftLoginChanged { path: PathBuf },
708
709    /// The login stored changed before the file behind it could go, where it was going
710    /// because of that login: the file held that very login, or another of the same account.
711    #[error(
712        "the login {} has stored changed while {} was being put away, and whether the file's \
713         login is kept somewhere depended on it. Putting it away again says first what it does \
714         now.",
715        ProviderId::Claude.name(),
716        path.display()
717    )]
718    StoredLoginChanged { path: PathBuf },
719
720    /// The login left in the file is an account's nobody enrolled, so there is no account to
721    /// keep it for, and deleting the file would lose it.
722    #[error(
723        "{} holds {email}'s login{}, an account not enrolled here, so Pitboard has no account to \
724         keep it for. Enrol that account first, with `pitboard enroll <label> --sign-in`, or \
725         with `pitboard enroll <label>` while Claude Code is signed in to it, then run \
726         `pitboard stow` again.",
727        path.display(),
728        crate::words::in_organisation(organization)
729    )]
730    LeftLoginNotEnrolled {
731        path: PathBuf,
732        email: String,
733        organization: String,
734    },
735
736    #[error(
737        "Pitboard could not ask {} whose the login left in {} is ({detail}). {}",
738        ProviderId::Claude.service(),
739        path.display(),
740        try_again(*cause).unwrap_or("Run `pitboard doctor`.")
741    )]
742    LeftLoginUnidentified {
743        path: PathBuf,
744        cause: Cause,
745        detail: String,
746    },
747
748    /// What stopped putting away the file behind a tool's store, said with how far that had
749    /// gone, which is never as far as deleting the file. Its code, cause and exit status are
750    /// `error`'s, so a program branches on what stopped it.
751    #[error("{} Pitboard {partway}.", sentence(error))]
752    StowStopped {
753        partway: Partway,
754        /// What parking the login warned about, where it was parked first. Taken out by the
755        /// service and reported beside the error.
756        warnings: Vec<crate::service::Warning>,
757        error: Box<Error>,
758    },
759
760    /// What stopped Pitboard saving a login of a tool's own it had just renewed, where no copy
761    /// of the renewed login could be kept either, so the refresh token the tool's store holds
762    /// is spent. Its code, cause and exit status are `error`'s.
763    #[error(
764        "{} Pitboard renewed {}'s login and could not save the renewed login, so the refresh \
765         token stored is spent. {}, sign in, then try again.",
766        sentence(error),
767        tool.name(),
768        crate::words::sign_in_yourself(*tool, true)
769    )]
770    RenewalLost { tool: ProviderId, error: Box<Error> },
771
772    // A refusal after the change had done what `said` says. The service takes it apart.
773    #[error("{error}")]
774    SaidFirst {
775        said: Vec<crate::service::Warning>,
776        error: Box<Error>,
777    },
778
779    /// Signing in to a second account works by pointing the tool's own login at a scratch
780    /// directory. Where that does not isolate it from the live login, running one would
781    /// write over the account somebody is using, so Pitboard will not.
782    #[error(
783        "Pitboard will not sign in to a second account on this machine: {reason} Signing \
784         in would write over the login you are using."
785    )]
786    SignInNotIsolated { reason: String },
787
788    #[error(
789        "another `pitboard enroll --sign-in` is already waiting for its sign-in. Finish or \
790         cancel that one first."
791    )]
792    SignInInProgress,
793
794    #[error("{}", pitboard_open(home))]
795    PitboardOpen { home: PathBuf },
796
797    #[error("{}", app_open(app, still))]
798    AppOpen {
799        app: &'static str,
800        // what of the app still runs once its own program has quit, as `ShipIt (pid 812)`
801        still: Vec<String>,
802    },
803
804    #[error(
805        "Pitboard could not tell whether {app} is open ({why}), so nothing has changed. Run \
806         this again."
807    )]
808    AppStateUnknown { app: &'static str, why: String },
809
810    #[error(
811        "{}'s data folder {what}, so Pitboard moves no {} login. {}Nothing has changed.",
812        tool.program(),
813        tool.name(),
814        then.said()
815    )]
816    LoginFolderUnknown {
817        tool: ProviderId,
818        what: String,
819        then: Then,
820    },
821
822    #[error(
823        "Pitboard could not read {program}'s cookie store ({detail}). Open {program}, quit it \
824         with Command-Q, then run this again. Nothing has changed.",
825        program = tool.program()
826    )]
827    LoginFolderUnreadable { tool: ProviderId, detail: String },
828
829    #[error(
830        "{program} has not finished signing in. Open {program}, wait until it shows your \
831         chats, quit it with Command-Q, then run this again. Nothing has changed.",
832        program = tool.program()
833    )]
834    SignInUnfinished { tool: ProviderId },
835
836    #[error("{}", in_doubt(*tool, why))]
837    LoginInDoubt { tool: ProviderId, why: LoginDoubt },
838
839    #[error(
840        "Another Pitboard is changing {app}'s login now, so nothing has changed. Run this \
841         again once it has finished."
842    )]
843    LoginFolderBusy { app: &'static str },
844
845    #[error(
846        "Pitboard parks a Claude Desktop login by renaming it into {}, which is on another \
847         volume than {}, and it never copies one. Keep Pitboard's folder on the volume your \
848         home is on. Nothing has changed.",
849        parks.display(),
850        root.display()
851    )]
852    DifferentVolume { parks: PathBuf, root: PathBuf },
853
854    #[error("{}", parks_not_private(path, *why))]
855    ParksNotPrivate {
856        path: PathBuf,
857        why: crate::host::Private,
858    },
859
860    #[error(
861        "Pitboard could not read this Mac's identity, which it writes beside every parked \
862         Claude Desktop login so it never puts back one parked on another Mac. Nothing has \
863         changed."
864    )]
865    MachineUnknown,
866
867    #[error("{}", opened_midway(app, label.as_deref()))]
868    AppOpenedMidway {
869        app: &'static str,
870        // the account whose login was moving; None from the start of a sign-in
871        label: Option<String>,
872    },
873
874    #[error(
875        "Pitboard could not park `{label}`'s login ({detail}) and undid what it had done. \
876         `{label}` is in use, and nothing was lost. Run this again."
877    )]
878    MoveUndone { label: String, detail: String },
879
880    #[error(
881        "Pitboard stopped while moving `{label}`'s login ({detail}). Keep Claude closed and \
882         run this again: Pitboard settles the move first."
883    )]
884    MoveStopped { label: String, detail: String },
885
886    // A settle or a give-up that failed partway: coded `recovery_undetermined`, given up alike.
887    #[error(
888        "Pitboard stopped while settling an interrupted move of `{label}`'s login ({detail}). \
889         Nothing is lost. Keep Claude closed and run this again: Pitboard settles the move \
890         first. If it stops again, `pitboard abandon` keeps every folder and moves on."
891    )]
892    RecoveryStopped { label: String, detail: String },
893
894    #[error("{}", left_signed_out(from.as_deref(), to, detail))]
895    SwitchLeftSignedOut {
896        from: Option<String>,
897        to: String,
898        detail: String,
899    },
900
901    #[error("{}", move_mixed(label, places))]
902    MoveMixed { label: String, places: Vec<String> },
903
904    // the account parked last, if any, and each folder set aside that holds a login
905    #[error("{}", would_strand(label.as_deref(), set_aside))]
906    UninstallWouldStrand {
907        label: Option<String>,
908        set_aside: Vec<PathBuf>,
909    },
910
911    #[error(
912        "Another Pitboard command finished, put back or removed the sign-in this one waited \
913         for, so it enrolled nothing. Run `pitboard` to see who is signed in."
914    )]
915    SignInSettledElsewhere { label: String },
916
917    /// This process runs as root or under sudo, or elevated on Windows, or the system could
918    /// not say whether it does, so Pitboard changes nothing: the one gate every change passes
919    /// refused it. `why` is how it runs, as the host said it, and `None` where the host could
920    /// not say.
921    #[error("{}", crate::words::elevated(crate::host::OS, *why).refusal)]
922    Elevated { why: Option<&'static str> },
923
924    #[error("{}", crate::words::too_old(*build).refusal)]
925    SystemTooOld { build: Option<u32> },
926
927    /// This is a Windows build of a release made before Pitboard for Windows is released, so
928    /// it does nothing ([`crate::release`]). Kept once Windows is released, where it never
929    /// occurs.
930    #[error("Pitboard for Windows is not released yet. This build changes nothing.")]
931    WindowsNotReleased,
932
933    /// The command line itself was wrong; the message is clap's.
934    #[error("{0}")]
935    Usage(String),
936
937    /// A command that deletes something was run with nobody to ask, without `--yes`.
938    #[error(
939        "`pitboard {command}` asks before it deletes anything, and can ask only in a terminal \
940         without `--json`. Nothing was changed. Run it in a terminal, or add `--yes`."
941    )]
942    ConfirmationNeeded { command: &'static str },
943
944    #[error(transparent)]
945    Store(#[from] crate::store::Error),
946
947    #[error(transparent)]
948    Lock(#[from] crate::lock::LockError),
949}
950
951impl Error {
952    /// A stable identifier a program can branch on. Adding one is safe; renaming one is not.
953    pub fn code(&self) -> &'static str {
954        use Error::*;
955        match self {
956            StateOnSyncedDrive { .. } => "state_on_synced_drive",
957            HomeNotAbsolute { .. } => "home_not_absolute",
958            CodexHomeMissing { .. } => "codex_home_missing",
959            ProgramMissing { tool, .. } => match tool {
960                ProviderId::Claude => "claude_program_missing",
961                ProviderId::Codex => "codex_program_missing",
962                ProviderId::Desktop => "desktop_program_missing",
963            },
964            CredentialTooLarge { .. } => "credential_too_large",
965            CustomOauthEndpoint => "custom_oauth_endpoint",
966            StateUnreadable { .. } => "state_unreadable",
967            StateCorrupt { .. } => "state_corrupt",
968            StateFromNewerVersion { .. } => "state_from_newer_version",
969            StateVersionUnknown { .. } => "state_version_unknown",
970            WarmSettingUnreadable { .. } => "warm_setting_unreadable",
971            WarmSettingCorrupt { .. } => "warm_setting_corrupt",
972            WarmUpUnsupported { .. } => "warm_up_unsupported",
973            WarmUpFailed { why, .. } => why.code(),
974            WarmUpNotDue { code, .. } => code,
975            WarmUpHalted { halt } => halt.code(),
976            WarmUpInProgress => "warm_up_in_progress",
977            StateNamesUnknownTool { .. } => "state_names_unknown_tool",
978            StateWrongMachine { .. } => "state_wrong_machine",
979            StateWriteFailed { .. } => "state_write_failed",
980            ScheduleUnsupported => "schedule_unsupported",
981            ScheduleRefused { .. } => "schedule_refused",
982            ScheduleNotDefaultHome { .. } => "schedule_not_default_home",
983            ScheduleProgramMissing { .. } => "schedule_program_missing",
984            ScheduleProgramTemporary { .. } => "schedule_program_temporary",
985            ScheduleProgramUnnamed => "schedule_program_unnamed",
986            HomeUnwritable { .. } => "home_unwritable",
987            ClaudeConfigMissing { .. } => "claude_config_missing",
988            ClaudeConfigUnreadable { .. } => "claude_config_unreadable",
989            ClaudeConfigNotJson { .. } => "claude_config_not_json",
990            LiveCredentialAbsent { .. } => "live_credential_absent",
991            LiveStoreUnsupported { .. } => "live_store_unsupported",
992            LiveCredentialElsewhere { .. } => "live_credential_elsewhere",
993            LiveCredentialShapeUnexpected { .. } => "live_credential_shape_unexpected",
994            AccountUnknown { .. } => "account_unknown",
995            NothingParked { .. } => "nothing_parked",
996            ParkedLoginExpired { .. } => "parked_login_expired",
997            ParkedLoginRefused { .. } => "parked_login_refused",
998            ParkedLoginBelongsElsewhere { .. } => "parked_login_belongs_elsewhere",
999            LiveAccountNotEnrolled { .. } => "live_account_not_enrolled",
1000            AlreadyEnrolled { .. } => "already_enrolled",
1001            LabelTaken { .. } => "label_taken",
1002            ProviderUnknown { .. } => "provider_unknown",
1003            LabelAmbiguous { .. } => "label_ambiguous",
1004            CannotForgetActiveAccount { .. } => "cannot_forget_active_account",
1005            AccountNotInUse { .. } => "account_not_in_use",
1006            ParkSlotExhausted => "park_slot_exhausted",
1007            ParkedCredentialMissing { .. } => "parked_credential_missing",
1008            ParkedCredentialCorrupt { .. } => "parked_credential_corrupt",
1009            ConfigBackupFailed { .. } => "config_backup_failed",
1010            ConfigWriteFailed { .. } => "config_write_failed",
1011            SwitchRolledBack { .. } => "switch_rolled_back",
1012            SwitchDidNotHold { .. } => "switch_did_not_hold",
1013            SwitchUnverified { .. } => "switch_unverified",
1014            SwitchCorrupted { .. } => "switch_corrupted",
1015            SignInNotInstalled { .. } => "sign_in_not_installed",
1016            SignInNotKept { .. } => "sign_in_not_kept",
1017            RecoveryFailed { .. } => "recovery_failed",
1018            RecoveryRecordCorrupt { .. } => "recovery_record_corrupt",
1019            SessionExpired { .. } => "session_expired",
1020            IdentityUnverifiable { .. } => "identity_unverifiable",
1021            SignedInAccountChanged => "signed_in_account_changed",
1022            LiveLoginRefused { .. } => "live_login_refused",
1023            ReplaceFailed { .. } => "replace_failed",
1024            InstallFailed { .. } => "install_failed",
1025            AccountOnHold { .. } => "account_on_hold",
1026            RecoveryUndetermined { .. } | RecoveryUnfinished { .. } | RecoveryStopped { .. } => {
1027                "recovery_undetermined"
1028            }
1029            RecoveryElsewhere { .. } => "recovery_elsewhere",
1030            ProgramNotFound { tool } => match tool {
1031                ProviderId::Claude => "claude_not_found",
1032                ProviderId::Codex => "codex_not_found",
1033                ProviderId::Desktop => "desktop_not_found",
1034            },
1035            SignInIncomplete => "sign_in_incomplete",
1036            LeftLoginChanged { .. } => "left_login_changed",
1037            StoredLoginChanged { .. } => "stored_login_changed",
1038            LeftLoginNotEnrolled { .. } => "left_login_not_enrolled",
1039            LeftLoginUnidentified { .. } => "left_login_unidentified",
1040            StowStopped { error, .. } | RenewalLost { error, .. } | SaidFirst { error, .. } => {
1041                error.code()
1042            }
1043            SignInNotIsolated { .. } => "sign_in_not_isolated",
1044            RenewalFailed { .. } => "renewal_failed",
1045            SignInInProgress => "sign_in_in_progress",
1046            AppOpen { .. } => "app_open",
1047            PitboardOpen { .. } => "pitboard_open",
1048            AppStateUnknown { .. } => "app_state_unknown",
1049            LoginFolderUnknown { .. } => "login_folder_unknown",
1050            LoginFolderUnreadable { .. } => "login_folder_unreadable",
1051            SignInUnfinished { .. } => "sign_in_unfinished",
1052            LoginInDoubt { .. } => "login_in_doubt",
1053            LoginFolderBusy { .. } => "login_folder_busy",
1054            DifferentVolume { .. } => "different_volume",
1055            ParksNotPrivate { .. } => "parks_not_private",
1056            MachineUnknown => "machine_unknown",
1057            AppOpenedMidway { .. } => "app_opened_midway",
1058            MoveUndone { .. } => "move_undone",
1059            MoveStopped { .. } => "move_stopped",
1060            SwitchLeftSignedOut { .. } => "switch_left_signed_out",
1061            MoveMixed { .. } => "move_mixed",
1062            UninstallWouldStrand { .. } => "uninstall_would_strand",
1063            SignInSettledElsewhere { .. } => "sign_in_settled_elsewhere",
1064            Elevated { .. } => "elevated",
1065            SystemTooOld { .. } => "system_too_old",
1066            WindowsNotReleased => "windows_not_released",
1067            Usage(_) => "usage",
1068            ConfirmationNeeded { .. } => "confirmation_needed",
1069            Store(e) => e.code(),
1070            Lock(e) => e.code(),
1071        }
1072    }
1073
1074    /// What went wrong underneath, where the failure came from a request to Anthropic.
1075    /// `None` where nothing was asked.
1076    pub fn cause(&self) -> Option<Cause> {
1077        use Error::*;
1078        match self {
1079            IdentityUnverifiable { cause, .. } | LeftLoginUnidentified { cause, .. } => {
1080                Some(*cause)
1081            }
1082            RenewalFailed { cause, .. } => *cause,
1083            SessionExpired { refused: true, .. } | LiveLoginRefused { .. } => {
1084                Some(Cause::LoginRefused)
1085            }
1086            AccountOnHold { .. } => Some(Cause::Refused),
1087            SessionExpired { .. } => Some(Cause::TokenExpired),
1088            StowStopped { error, .. } | RenewalLost { error, .. } | SaidFirst { error, .. } => {
1089                error.cause()
1090            }
1091            _ => None,
1092        }
1093    }
1094
1095    /// Warnings a failed change carries in itself, for the caller to report beside it. Only
1096    /// a failure that happened after something worth warning about was done carries any.
1097    pub(crate) fn take_warnings(&mut self) -> Vec<crate::service::Warning> {
1098        let mut said = self.take_said_first();
1099        if let Error::SignInNotInstalled { warnings, .. } | Error::StowStopped { warnings, .. } =
1100            self
1101        {
1102            said.append(warnings);
1103        }
1104        said
1105    }
1106
1107    // What the change had done before it refused, outermost first. What is left is the refusal
1108    // itself.
1109    pub(crate) fn take_said_first(&mut self) -> Vec<crate::service::Warning> {
1110        let mut said = Vec::new();
1111        while let Error::SaidFirst { said: first, error } = self {
1112            said.append(first);
1113            *self = std::mem::replace(error.as_mut(), Error::SignInIncomplete);
1114        }
1115        said
1116    }
1117
1118    // This refusal, after a change that had done what `said` says.
1119    pub(crate) fn after(self, said: Vec<crate::service::Warning>) -> Error {
1120        if said.is_empty() {
1121            return self;
1122        }
1123        Error::SaidFirst {
1124            said,
1125            error: Box::new(self),
1126        }
1127    }
1128
1129    /// 1 when a request could not be met; 2 when the command line was wrong, or lacks the
1130    /// `--yes` a command that deletes needs with nobody to ask; 3 when a login or Claude
1131    /// Code's files are in a state Pitboard cannot safely act on: an unexpected format, or a
1132    /// login that could not be put back.
1133    pub fn exit_code(&self) -> u8 {
1134        use Error::*;
1135        match self {
1136            // A login or Claude Code's files in a state Pitboard will not act on, which is
1137            // what exit 3 means: not a failure of the attempt, a refusal to attempt.
1138            LiveCredentialShapeUnexpected { .. }
1139            | LiveStoreUnsupported { .. }
1140            | ClaudeConfigNotJson { .. }
1141            | SwitchCorrupted { .. }
1142            | SwitchUnverified { .. }
1143            | SwitchDidNotHold { .. }
1144            | SignInNotInstalled { .. }
1145            | LiveCredentialElsewhere { .. }
1146            | CredentialTooLarge { .. }
1147            | CustomOauthEndpoint
1148            | RecoveryRecordCorrupt { .. }
1149            | LoginFolderUnknown { .. }
1150            | LoginInDoubt { .. }
1151            | DifferentVolume { .. }
1152            | ParksNotPrivate { .. }
1153            | MachineUnknown
1154            | MoveMixed { .. } => 3,
1155            Usage(_) | ConfirmationNeeded { .. } => 2,
1156            Store(e) => e.exit_code(),
1157            StowStopped { error, .. } | RenewalLost { error, .. } | SaidFirst { error, .. } => {
1158                error.exit_code()
1159            }
1160            _ => 1,
1161        }
1162    }
1163}
1164
1165/// `error`'s message as a sentence, ending with a full stop as some do not, for more to be
1166/// said after it.
1167fn sentence(error: &Error) -> String {
1168    let said = error.to_string();
1169    if said.ends_with('.') {
1170        said
1171    } else {
1172        format!("{said}.")
1173    }
1174}
1175
1176pub type Result<T> = std::result::Result<T, Error>;
1177
1178pub(crate) fn codex_home_missing(path: &std::path::Path) -> String {
1179    format!("{}. {CODEX_HOME_REMEDY}", codex_home_no_folder(path))
1180}
1181
1182pub(crate) fn codex_home_no_folder(path: &std::path::Path) -> String {
1183    format!(
1184        "CODEX_HOME is {}, which is no folder, so Codex does not start",
1185        path.display()
1186    )
1187}
1188
1189pub(crate) const CODEX_HOME_REMEDY: &str = "Make that folder, or unset CODEX_HOME";
1190
1191fn temporary_copy(path: &std::path::Path) -> String {
1192    if crate::schedule::opened_from_a_zip(path) {
1193        return format!(
1194            "the renewal schedule would run {}, which is in a temporary copy Windows made of a \
1195             zip it opened. Install Pitboard, or extract the zip, and turn on daily renewal \
1196             from there.",
1197            path.display()
1198        );
1199    }
1200    format!(
1201        "the renewal schedule would run {}, which is in a temporary copy macOS made of the app \
1202         and is gone once the app quits. Move Pitboard to your Applications folder, open it \
1203         from there, and turn on daily renewal again.",
1204        path.display()
1205    )
1206}
1207
1208fn not_absolute(variable: &str, path: &std::path::Path) -> String {
1209    let holds = if path.as_os_str().is_empty() {
1210        "empty".to_string()
1211    } else {
1212        format!("`{}`, which is not a full path", path.display())
1213    };
1214    format!(
1215        "{variable} is {holds}, so the folder it names would depend on where each program \
1216         runs. Set it to a full path, or unset it; Pitboard reads and changes nothing until \
1217         then."
1218    )
1219}
1220
1221/// [`Error::IdentityUnverifiable`]'s words: what to do follows from why.
1222fn identity_unverifiable(
1223    tool: ProviderId,
1224    login: &Unidentified,
1225    cause: Cause,
1226    detail: &str,
1227) -> String {
1228    let service = tool.service();
1229    let next = match (try_again(cause), cause, login) {
1230        (Some(later), ..) => later.to_owned(),
1231        (None, Cause::AnswerNotUnderstood, _) => "Check for a Pitboard update.".to_owned(),
1232        (None, _, Unidentified::SignedIn | Unidentified::Unnamed) => format!(
1233            "{}, sign in, then try again.",
1234            crate::words::sign_in_yourself(tool, true)
1235        ),
1236        (None, _, Unidentified::Parked { label } | Unidentified::SignIn { label }) => {
1237            format!("Sign in to it again with `pitboard enroll {label} --sign-in`.")
1238        }
1239    };
1240    match login {
1241        Unidentified::SignedIn => format!(
1242            "Pitboard could not confirm with {service} which account is signed in ({detail}), \
1243             and will not move a login it cannot identify. {next}"
1244        ),
1245        Unidentified::Parked { label } => format!(
1246            "Pitboard could not confirm with {service} whose login is parked for `{label}` \
1247             ({detail}), and will not put in use a login it cannot identify. {next}"
1248        ),
1249        Unidentified::SignIn { label } => format!(
1250            "Pitboard could not confirm with {service} whose login the sign-in for `{label}` \
1251             made ({detail}), and keeps no login it cannot identify. {next}"
1252        ),
1253        Unidentified::Unnamed => format!(
1254            "{service} named the account of {}'s login and would not give its email \
1255             ({detail}), and Pitboard keeps every account under its email. {next}",
1256            tool.name()
1257        ),
1258    }
1259}
1260
1261/// [`Error::LiveLoginRefused`]'s words.
1262fn live_login_refused(tool: ProviderId, label: &str, detail: &str) -> String {
1263    format!(
1264        "{} Run `pitboard use {label} --yes` to put `{label}`'s login there.",
1265        crate::words::replacing(tool, detail)
1266    )
1267}
1268
1269/// [`Error::AccountOnHold`]'s words.
1270fn account_on_hold(tool: ProviderId, login: &Unidentified, link: Option<&str>) -> String {
1271    let service = tool.service();
1272    let see = link.map(|link| format!(" ({link})")).unwrap_or_default();
1273    let whose = match login {
1274        Unidentified::SignedIn | Unidentified::Unnamed => {
1275            format!("the account of {}'s login in use", tool.name())
1276        }
1277        Unidentified::Parked { label } | Unidentified::SignIn { label } => {
1278            format!("`{label}`'s account")
1279        }
1280    };
1281    format!(
1282        "{service} has put {whose} on hold{see}. Pitboard keeps its login. Follow {service}'s \
1283         instructions for the account, then try again."
1284    )
1285}
1286
1287/// When asking again is the way on, what to say about it.
1288fn try_again(cause: Cause) -> Option<&'static str> {
1289    match cause {
1290        Cause::Unreachable => Some("Check the connection and try again."),
1291        Cause::RateLimited => Some("Try again in a few minutes."),
1292        Cause::ServerError => Some("Try again later."),
1293        Cause::Refused | Cause::AnswerNotUnderstood | Cause::LoginRefused | Cause::TokenExpired => {
1294            None
1295        }
1296    }
1297}
1298
1299/// [`Error::SessionExpired`]'s words.
1300fn session_expired(tool: ProviderId, refused: bool) -> String {
1301    if refused {
1302        return format!(
1303            "{}'s login in use has expired and cannot be renewed, so Pitboard cannot confirm \
1304             which account is signed in. {}, sign in, then try again.",
1305            tool.name(),
1306            crate::words::sign_in_yourself(tool, true)
1307        );
1308    }
1309    format!(
1310        "{}'s session has expired, so Pitboard cannot confirm which account is signed in. \
1311         {} once so it refreshes, then try again.",
1312        tool.name(),
1313        crate::words::run_it(tool, true)
1314    )
1315}
1316
1317// What makes a tool's live login readable again, where it could not be read.
1318fn make_readable(os: Os, tool: ProviderId) -> &'static str {
1319    match tool {
1320        ProviderId::Claude => match os {
1321            Os::MacOs | Os::Linux => "Unlock the keychain",
1322            Os::Windows => {
1323                "Check that you can read and change Claude Code's folder and its .credentials.json"
1324            }
1325        },
1326        ProviderId::Codex => "Make Codex's auth.json readable to you again",
1327        ProviderId::Desktop => "Quit Claude",
1328    }
1329}
1330
1331// Where Claude Code keeps a login on `os`, none of which holds `email`'s.
1332fn login_elsewhere(os: Os, email: &str) -> String {
1333    let places = match os {
1334        Os::MacOs | Os::Linux => "in the keychain or in the file it also reads",
1335        Os::Windows => "in .credentials.json or in Credential Manager",
1336    };
1337    format!(
1338        "Claude Code's config says {email} is signed in, but Pitboard cannot find that login \
1339         {places}. It will not write a login where nobody reads it. This usually means Claude \
1340         Code has started keeping logins somewhere Pitboard does not know about yet: check for a \
1341         Pitboard update, and report it with `pitboard doctor --json` if there is none."
1342    )
1343}
1344
1345// A keychain's ceiling is what `security` reads from stdin; Credential Manager's, 256 pieces.
1346fn too_large(os: Os, tool: ProviderId, label: &str, bytes: usize, limit: usize) -> String {
1347    match os {
1348        Os::MacOs | Os::Linux => format!(
1349            "the login for `{label}` needs {bytes} bytes and `security` reads {limit} from \
1350             stdin, so it can only be written on the argument line, which PITBOARD_NO_ARGV \
1351             forbids. {}",
1352            smaller(tool)
1353        ),
1354        Os::Windows => format!(
1355            "the login for `{label}` needs {}. Sign out of MCP servers you no longer use to make \
1356             the login smaller.",
1357            crate::words::past_credential_manager(bytes, limit)
1358        ),
1359    }
1360}
1361
1362// What makes a login smaller, where anything does.
1363fn smaller(tool: ProviderId) -> &'static str {
1364    match tool {
1365        ProviderId::Claude => {
1366            "Unset it, or sign out of MCP servers you no longer use to make the login smaller."
1367        }
1368        ProviderId::Codex | ProviderId::Desktop => "Unset it to let Pitboard write it.",
1369    }
1370}
1371
1372/// Which write took a switched-in login away again, as far as each tool is known to make
1373/// one, and what that leaves.
1374fn after_it_did_not_hold(tool: ProviderId, from: &str, to: &str) -> String {
1375    match tool {
1376        ProviderId::Claude => format!(
1377            "Claude Code removes a login without taking the write lock when `/logout` has \
1378             given up waiting, which is the one write Pitboard cannot exclude. Nothing was \
1379             lost: both `{from}` and `{to}` are parked. Run `claude` and sign in to any \
1380             enrolled account, then `pitboard use {to}`."
1381        ),
1382        // Not "nothing was lost": the likeliest writer is a codex session refreshing the
1383        // outgoing account, which spends the token in that account's park, and `codex
1384        // login` would revoke whatever login is left in auth.json.
1385        ProviderId::Codex => format!(
1386            "Codex takes no lock, so a codex session still running from before the switch, \
1387             refreshing or signing out, rewrote auth.json underneath it. `{to}` is still \
1388             parked. `{from}`'s park may hold a token that refresh spent. Quit every running \
1389             codex, then run `pitboard` to see what is signed in; do not run `codex login` \
1390             or `codex logout` until you have, because either revokes the login they find."
1391        ),
1392        ProviderId::Desktop => "Claude was opened before Pitboard finished and wrote its own \
1393                                login back. Quit Claude, then run `pitboard` to see which \
1394                                account it holds."
1395            .into(),
1396    }
1397}
1398
1399/// Where a new login that could not be put in use went, and the way back from there.
1400fn not_in_use(tool: ProviderId, label: &str, parked: bool) -> String {
1401    if parked {
1402        format!(
1403            "The new login is parked, so it is not lost. Run `pitboard` to see what is \
1404             signed in; if nothing is, {} and sign in to any enrolled account, then \
1405             `pitboard use {label}`.",
1406            crate::words::sign_in_yourself(tool, false)
1407        )
1408    } else {
1409        format!(
1410            "It could not be parked either: {} and sign in to `{label}` again.",
1411            crate::words::sign_in_yourself(tool, false)
1412        )
1413    }
1414}
1415
1416// What a step of a Claude Desktop change says to do once it has refused.
1417#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1418pub enum Then {
1419    Nothing,
1420    OpenAndQuit,
1421    UpdatePitboard,
1422    CopyAside,
1423    SignInAndEnrol,
1424}
1425
1426impl Then {
1427    // With a space after it, where it says anything.
1428    pub(crate) fn said(self) -> &'static str {
1429        match self {
1430            Then::Nothing => "",
1431            Then::OpenAndQuit => "Open Claude, quit it with Command-Q, then run this again. ",
1432            Then::UpdatePitboard => {
1433                "Update Pitboard, or report it at https://github.com/datlechin/pitboard/issues if \
1434                 no release reads it yet. "
1435            }
1436            Then::CopyAside => {
1437                "Claude resets a settings file it cannot read when it opens, so copy it aside, \
1438                 open Claude, quit it with Command-Q, then run this again. "
1439            }
1440            Then::SignInAndEnrol => {
1441                "Open Claude. If it asks you to sign in, sign in. Quit it with Command-Q, then \
1442                 run `pitboard enroll desktop/<label>` for the account it shows. "
1443            }
1444        }
1445    }
1446}
1447
1448// Why the login Claude holds cannot be taken as any account's without the person's word.
1449#[derive(Debug, Clone, PartialEq, Eq)]
1450pub enum LoginDoubt {
1451    // `named`: the account Claude names, as typed; None for one Pitboard has not enrolled
1452    NamedAnother {
1453        recorded: String,
1454        named: Option<String>,
1455    },
1456    // `label` was signed in to outside Pitboard, beside the login it parked
1457    SignedInOutside {
1458        label: String,
1459    },
1460}
1461
1462fn pitboard_open(home: &std::path::Path) -> String {
1463    let quit = match crate::host::OS {
1464        Os::MacOs | Os::Linux => "Quit it from its item in the menu bar",
1465        Os::Windows => "Exit it from its icon's menu in the notification area",
1466    };
1467    format!(
1468        "the Pitboard app is open, and it would make {} again once it was gone. {quit}, then \
1469         run this again. Nothing has changed.",
1470        home.display()
1471    )
1472}
1473
1474fn app_open(app: &str, still: &[String]) -> String {
1475    if still.is_empty() {
1476        return format!(
1477            "{app} is open, and Pitboard changes its login only while it is closed. Quit {app} \
1478             with Command-Q, then run this again. Nothing has changed."
1479        );
1480    }
1481    format!(
1482        "{app} has quit, and {} from it is still running, and Pitboard changes {app}'s login \
1483         only while all of it is closed. {} Nothing has changed.",
1484        crate::words::listed(still.to_vec()),
1485        crate::words::wait_for_still(crate::host::OS, "run this again")
1486    )
1487}
1488
1489fn in_doubt(tool: ProviderId, why: &LoginDoubt) -> String {
1490    let enroll = Key::new(tool, "<label>").typed();
1491    match why {
1492        LoginDoubt::NamedAnother { recorded, named } => {
1493            let named = named.as_ref().map_or_else(
1494                || "an account Pitboard has not enrolled".to_string(),
1495                |label| format!("`{label}`"),
1496            );
1497            format!(
1498                "Pitboard recorded this {program} login as `{recorded}`'s, and {program} names \
1499                 {named} for it. Open {program}, check which account it shows, quit it with \
1500                 Command-Q, then run `pitboard enroll {enroll}` under that account's name. \
1501                 Nothing has changed.",
1502                program = tool.program()
1503            )
1504        }
1505        LoginDoubt::SignedInOutside { label } => format!(
1506            "{program} was signed in to `{label}` outside Pitboard, so Pitboard cannot tell this \
1507             login from the one it parked. Open {program}, check which account it shows, and \
1508             quit it with Command-Q. If it shows `{label}`, run `pitboard enroll {label}`, then \
1509             this again. If it shows another account, run `pitboard enroll {enroll}` under that \
1510             account's name. Nothing has changed.",
1511            program = tool.program()
1512        ),
1513    }
1514}
1515
1516fn not_enrolled(tool: ProviderId, who: &str) -> String {
1517    let enroll = Key::new(tool, "<label>").typed();
1518    match tool {
1519        ProviderId::Claude | ProviderId::Codex => format!(
1520            "{who} is signed in but not enrolled, so it cannot be parked. Run `pitboard enroll \
1521             {enroll}` for it first."
1522        ),
1523        ProviderId::Desktop => format!(
1524            "Claude is signed in to an account Pitboard has not enrolled, so it cannot be \
1525             parked. Run `pitboard enroll {enroll}` for it first."
1526        ),
1527    }
1528}
1529
1530fn already_enrolled(
1531    tool: ProviderId,
1532    email: &str,
1533    label: &str,
1534    asked: &str,
1535    waited: bool,
1536) -> String {
1537    match tool {
1538        ProviderId::Claude | ProviderId::Codex => format!(
1539            "{email} is already enrolled as `{label}`. To add a different account, run \
1540             `pitboard enroll {} --sign-in`.",
1541            Key::new(tool, "<label>").typed()
1542        ),
1543        ProviderId::Desktop if waited => format!(
1544            "Claude is signed in to `{label}`. Run `pitboard enroll {label}` to record that \
1545             login, then add another with `pitboard enroll {asked} --sign-in`."
1546        ),
1547        ProviderId::Desktop => format!(
1548            "Claude is signed in to `{label}`, which is enrolled already. To add `{asked}`, run \
1549             `pitboard enroll {asked} --sign-in`."
1550        ),
1551    }
1552}
1553
1554fn parks_not_private(path: &std::path::Path, why: crate::host::Private) -> String {
1555    use crate::host::Private;
1556    let path = path.display();
1557    let own = "Point `PITBOARD_HOME` at a folder of your own on this volume, then run this again.";
1558    let said = match why {
1559        Private::Shared => {
1560            format!("{path} can be reached by others. Run `chmod 700 {path}`, then run this again.")
1561        }
1562        Private::Link => {
1563            format!("{path} is a link, so Pitboard parks no Claude Desktop login there. {own}")
1564        }
1565        Private::NotOwn => {
1566            format!("{path} is not yours, so Pitboard parks no Claude Desktop login there. {own}")
1567        }
1568        Private::NotADirectory | Private::Missing | Private::Yes => format!(
1569            "{path} is not a folder, so Pitboard parks no Claude Desktop login there. {own}"
1570        ),
1571    };
1572    format!("{said} Nothing has changed.")
1573}
1574
1575fn opened_midway(app: &str, label: Option<&str>) -> String {
1576    match label {
1577        Some(label) => format!(
1578            "{app} was opened while Pitboard moved `{label}`'s login, so Pitboard stopped. \
1579             Nothing is lost. Quit {app} with Command-Q, then run this again: Pitboard settles \
1580             the move first."
1581        ),
1582        None => format!(
1583            "{app} was opened while Pitboard set aside the account name {app} kept, so \
1584             Pitboard stopped. Nothing is lost. Quit {app} with Command-Q, then run this again."
1585        ),
1586    }
1587}
1588
1589fn would_strand(label: Option<&str>, set_aside: &[PathBuf]) -> String {
1590    let every = "so uninstalling would delete every Claude Desktop login Pitboard holds.";
1591    match label {
1592        Some(label) => format!(
1593            "No Claude Desktop account is in use, and `{label}` is parked, {every} Run `pitboard \
1594             use {label}`, then uninstall, or `pitboard forget` each Claude Desktop account first."
1595        ),
1596        None => {
1597            let held = match set_aside {
1598                [_] => "holds a Claude Desktop login",
1599                _ => "hold Claude Desktop logins",
1600            };
1601            let folders = set_aside
1602                .iter()
1603                .map(|path| path.display().to_string())
1604                .collect();
1605            format!(
1606                "No Claude Desktop account is in use, and {} {held} Pitboard set aside, {every} \
1607                 Run `pitboard enroll desktop/<label> --sign-in` to sign in to an account you \
1608                 keep, then uninstall.",
1609                crate::words::listed(folders)
1610            )
1611        }
1612    }
1613}
1614
1615fn move_mixed(label: &str, places: &[String]) -> String {
1616    format!(
1617        "Giving up would leave parts of `{label}` beside another login in Claude's folder: {}. \
1618         Nothing has changed. {}",
1619        places.join(", "),
1620        crate::words::mixed_way_out(label)
1621    )
1622}
1623
1624fn left_signed_out(from: Option<&str>, to: &str, detail: &str) -> String {
1625    match from {
1626        Some(from) => format!(
1627            "`{from}` is parked, and `{to}` could not be put in use ({detail}). Claude is \
1628             signed out and both logins are parked. Run `pitboard use {to}` again, or `pitboard \
1629             use {from}` to go back."
1630        ),
1631        None => format!(
1632            "`{to}` could not be put in use ({detail}). Claude is signed out and `{to}` is \
1633             still parked. Run `pitboard use {to}` again."
1634        ),
1635    }
1636}
1637
1638fn warm_up_unsupported(tool: ProviderId, label: &str) -> String {
1639    match tool {
1640        ProviderId::Claude | ProviderId::Codex => format!(
1641            "`{label}` is a {name} account, and Pitboard warms up Claude Code accounts only: when \
1642             a {name} period starts has not been measured.",
1643            name = tool.name()
1644        ),
1645        ProviderId::Desktop => format!(
1646            "`{label}` is a Claude Desktop account, and Pitboard sends no request for one, so it \
1647             warms up none."
1648        ),
1649    }
1650}
1651
1652/// "from `work` to `home`", or "to `home`" for a replacement, which has no account going out.
1653fn between(from: &str, to: &str) -> String {
1654    if from.is_empty() {
1655        format!("to `{to}`")
1656    } else {
1657        format!("from `{from}` to `{to}`")
1658    }
1659}
1660
1661fn recovery_undetermined(tool: ProviderId, from: &str, to: &str, detail: &str) -> String {
1662    match tool {
1663        ProviderId::Claude | ProviderId::Codex => format!(
1664            "an earlier switch {} was interrupted, and Pitboard cannot yet \
1665             tell whether it finished ({detail}). Nothing was changed. {} once so its session \
1666             is current, then try again.",
1667            between(from, to),
1668            crate::words::run_it(tool, true)
1669        ),
1670        ProviderId::Desktop => format!(
1671            "An earlier move of `{to}`'s login was interrupted, and Pitboard cannot tell where it \
1672             stopped ({detail}). Nothing has changed. {}",
1673            crate::words::undetermined_way_out()
1674        ),
1675    }
1676}
1677
1678fn recovery_elsewhere(tool: ProviderId, from: &str, to: &str, slot: &str) -> String {
1679    match tool {
1680        ProviderId::Claude | ProviderId::Codex => format!(
1681            "an earlier switch of {} {} was interrupted while its login was at {slot}, and \
1682             this run reads it from somewhere else, so it cannot tell what that switch did. \
1683             Nothing was changed. {} to finish it, or `pitboard abandon` to keep every login \
1684             it names and move on.",
1685            tool.name(),
1686            between(from, to),
1687            crate::words::pointing_where_it_did(tool)
1688        ),
1689        ProviderId::Desktop => format!(
1690            "An earlier move of `{to}`'s login was interrupted while Claude kept its data in \
1691             {slot}, and this run finds it somewhere else. Nothing has changed. {}",
1692            crate::words::elsewhere_way_out(tool)
1693        ),
1694    }
1695}
1696
1697#[cfg(test)]
1698mod tests {
1699    use super::*;
1700
1701    // Claude Code keeps no keychain on Windows: its file, or Credential Manager.
1702    #[test]
1703    fn claude_codes_lost_and_unreadable_logins_are_worded_for_each_system() {
1704        let keychain = "the login for `l` needs 9 bytes and `security` reads 4 from stdin, so it \
1705                        can only be written on the argument line, which PITBOARD_NO_ARGV \
1706                        forbids. Unset it, or sign out of MCP servers you no longer use to make \
1707                        the login smaller.";
1708        for (os, too_large_said) in [
1709            (Os::MacOs, keychain),
1710            (Os::Linux, keychain),
1711            (
1712                Os::Windows,
1713                "the login for `l` needs 9 bytes, past the 4 Claude Code keeps in Credential \
1714                 Manager. Sign out of MCP servers you no longer use to make the login smaller.",
1715            ),
1716        ] {
1717            assert_eq!(too_large(os, ProviderId::Claude, "l", 9, 4), too_large_said);
1718        }
1719        for (os, places, readable) in [
1720            (
1721                Os::MacOs,
1722                "in the keychain or in the file it also reads",
1723                "Unlock the keychain",
1724            ),
1725            (
1726                Os::Linux,
1727                "in the keychain or in the file it also reads",
1728                "Unlock the keychain",
1729            ),
1730            (
1731                Os::Windows,
1732                "in .credentials.json or in Credential Manager",
1733                "Check that you can read and change Claude Code's folder and its .credentials.json",
1734            ),
1735        ] {
1736            assert!(
1737                login_elsewhere(os, "a@example.com").starts_with(&format!(
1738                    "Claude Code's config says a@example.com is signed in, but Pitboard cannot \
1739                     find that login {places}. It will not write a login where nobody reads it."
1740                )),
1741                "{os:?}"
1742            );
1743            assert_eq!(make_readable(os, ProviderId::Claude), readable);
1744        }
1745    }
1746
1747    // Word for word what each said before a tool could be an app.
1748    #[test]
1749    fn every_claude_code_and_codex_sentence_reads_as_it_did() {
1750        let said = |tool: ProviderId| {
1751            let moved = |make: fn(ProviderId, String, String, String) -> Error| {
1752                make(tool, "a".into(), "b".into(), "d".into()).to_string()
1753            };
1754            let not_installed = |parked| Error::SignInNotInstalled {
1755                tool,
1756                label: "l".into(),
1757                detail: "d".into(),
1758                parked,
1759                warnings: Vec::new(),
1760            };
1761            vec![
1762                Error::LiveCredentialAbsent { tool, label: None }.to_string(),
1763                moved(|tool, from, to, detail| Error::RecoveryUndetermined {
1764                    tool,
1765                    from,
1766                    to,
1767                    detail,
1768                }),
1769                moved(|tool, from, to, detail| Error::SwitchCorrupted {
1770                    tool,
1771                    from,
1772                    to,
1773                    detail,
1774                }),
1775                moved(|tool, from, to, slot| Error::RecoveryElsewhere {
1776                    tool,
1777                    from,
1778                    to,
1779                    slot,
1780                }),
1781                Error::RenewalLost {
1782                    tool,
1783                    error: Box::new(Error::SignedInAccountChanged),
1784                }
1785                .to_string(),
1786                Error::SessionExpired {
1787                    tool,
1788                    refused: true,
1789                }
1790                .to_string(),
1791                Error::SessionExpired {
1792                    tool,
1793                    refused: false,
1794                }
1795                .to_string(),
1796                not_installed(true).to_string(),
1797                not_installed(false).to_string(),
1798                crate::service::Warning::SignInParkedNotInUse {
1799                    tool,
1800                    label: "l".into(),
1801                    why: "w".into(),
1802                }
1803                .to_string(),
1804                Error::LiveAccountNotEnrolled {
1805                    tool,
1806                    who: "a@b.c".into(),
1807                }
1808                .to_string(),
1809                Error::AlreadyEnrolled {
1810                    tool,
1811                    email: "a@b.c".into(),
1812                    label: "l".into(),
1813                    asked: "other".into(),
1814                    waited: false,
1815                }
1816                .to_string(),
1817            ]
1818        };
1819        let before = |name: &str, sign_in: &str, run: &str, home: &str, enroll: &str| {
1820            vec![
1821                format!(
1822                    "nothing is signed in to {name} right now. Run `{sign_in}`, sign in, then try again."
1823                ),
1824                format!(
1825                    "an earlier switch from `a` to `b` was interrupted, and Pitboard cannot yet \
1826                     tell whether it finished (d). Nothing was changed. Run `{run}` once so its \
1827                     session is current, then try again."
1828                ),
1829                format!(
1830                    "could not sign in as `b`, and could not put `a` back either (d). `a`'s login \
1831                     is still parked: run `{sign_in}` and sign in to any enrolled account, then \
1832                     `pitboard use a`."
1833                ),
1834                format!(
1835                    "an earlier switch of {name} from `a` to `b` was interrupted while its login \
1836                     was at d, and this run reads it from somewhere else, so it cannot tell what \
1837                     that switch did. Nothing was changed. Run Pitboard with {home} pointing \
1838                     where it did to finish it, or `pitboard abandon` to keep every login it \
1839                     names and move on."
1840                ),
1841                format!(
1842                    "the signed-in account changed while switching. Nothing was moved; try again. \
1843                     Pitboard renewed {name}'s login and could not save the renewed login, so \
1844                     the refresh token stored is spent. Run `{sign_in}`, sign in, then try again."
1845                ),
1846                format!(
1847                    "{name}'s login in use has expired and cannot be renewed, so Pitboard cannot \
1848                     confirm which account is signed in. Run `{sign_in}`, sign in, then try again."
1849                ),
1850                format!(
1851                    "{name}'s session has expired, so Pitboard cannot confirm which account is \
1852                     signed in. Run `{run}` once so it refreshes, then try again."
1853                ),
1854                format!(
1855                    "signed in to `l` again, and Pitboard could not confirm that its new login \
1856                     took the place of the one in use (d), so {name} may have no login for it \
1857                     now. The new login is parked, so it is not lost. Run `pitboard` to see what \
1858                     is signed in; if nothing is, run `{sign_in}` and sign in to any enrolled \
1859                     account, then `pitboard use l`."
1860                ),
1861                format!(
1862                    "signed in to `l` again, and Pitboard could not confirm that its new login \
1863                     took the place of the one in use (d), so {name} may have no login for it \
1864                     now. It could not be parked either: run `{sign_in}` and sign in to `l` again."
1865                ),
1866                format!(
1867                    "{name} goes on with the login it has: Pitboard could not tell whose it is \
1868                     (w), so it parked the new login for `l` rather than write over that one. If \
1869                     that login no longer works, run `{sign_in}` and sign in to `l` there."
1870                ),
1871                format!(
1872                    "a@b.c is signed in but not enrolled, so it cannot be parked. Run `pitboard \
1873                     enroll {enroll}` for it first."
1874                ),
1875                format!(
1876                    "a@b.c is already enrolled as `l`. To add a different account, run `pitboard \
1877                     enroll {enroll} --sign-in`."
1878                ),
1879            ]
1880        };
1881        let pairs = |tool, expected: Vec<String>| {
1882            for (now, then) in said(tool).into_iter().zip(expected) {
1883                assert_eq!(now, then, "{tool}");
1884            }
1885        };
1886        pairs(
1887            ProviderId::Claude,
1888            before(
1889                "Claude Code",
1890                "claude",
1891                "claude",
1892                "CLAUDE_CONFIG_DIR",
1893                "<label>",
1894            ),
1895        );
1896        pairs(
1897            ProviderId::Codex,
1898            before(
1899                "Codex",
1900                "codex login",
1901                "codex",
1902                "CODEX_HOME",
1903                "codex/<label>",
1904            ),
1905        );
1906        // Claude Desktop has words of its own for each, and none names a program to run.
1907        for sentence in said(ProviderId::Desktop) {
1908            assert!(!sentence.contains("Run `Claude`"), "{sentence}");
1909            assert!(!sentence.contains("run `Claude`"), "{sentence}");
1910        }
1911        let absent = |label: Option<&str>| {
1912            Error::LiveCredentialAbsent {
1913                tool: ProviderId::Desktop,
1914                label: label.map(str::to_string),
1915            }
1916            .to_string()
1917        };
1918        assert_eq!(
1919            absent(Some("desktop/travel")),
1920            "Claude is not signed in. Run `pitboard enroll desktop/travel --sign-in`, then sign \
1921             in inside Claude and quit it: Pitboard waits for that, and sets aside any account \
1922             name Claude kept first."
1923        );
1924        assert!(absent(None).contains("`pitboard enroll desktop/<label> --sign-in`"));
1925    }
1926
1927    #[test]
1928    fn a_moves_refusals_say_their_code_their_exit_and_what_to_do() {
1929        let said = |error: Error| (error.code(), error.exit_code(), error.to_string());
1930        let label = || "desktop/work".to_string();
1931        assert_eq!(
1932            said(Error::MachineUnknown),
1933            (
1934                "machine_unknown",
1935                3,
1936                "Pitboard could not read this Mac's identity, which it writes beside every \
1937                 parked Claude Desktop login so it never puts back one parked on another Mac. \
1938                 Nothing has changed."
1939                    .into()
1940            )
1941        );
1942        assert_eq!(
1943            said(Error::AppOpenedMidway {
1944                app: "Claude",
1945                label: Some(label())
1946            }),
1947            (
1948                "app_opened_midway",
1949                1,
1950                "Claude was opened while Pitboard moved `desktop/work`'s login, so Pitboard \
1951                 stopped. Nothing is lost. Quit Claude with Command-Q, then run this again: \
1952                 Pitboard settles the move first."
1953                    .into()
1954            )
1955        );
1956        assert!(
1957            said(Error::AppOpenedMidway {
1958                app: "Claude",
1959                label: None
1960            })
1961            .2
1962            .contains("set aside the account name Claude kept")
1963        );
1964        assert_eq!(
1965            said(Error::SwitchLeftSignedOut {
1966                from: Some(label()),
1967                to: "desktop/home".into(),
1968                detail: "d".into()
1969            }),
1970            (
1971                "switch_left_signed_out",
1972                1,
1973                "`desktop/work` is parked, and `desktop/home` could not be put in use (d). \
1974                 Claude is signed out and both logins are parked. Run `pitboard use \
1975                 desktop/home` again, or `pitboard use desktop/work` to go back."
1976                    .into()
1977            )
1978        );
1979        assert!(
1980            said(Error::SwitchLeftSignedOut {
1981                from: None,
1982                to: "desktop/home".into(),
1983                detail: "d".into()
1984            })
1985            .2
1986            .ends_with("`desktop/home` is still parked. Run `pitboard use desktop/home` again.")
1987        );
1988        assert_eq!(
1989            said(Error::RecoveryStopped {
1990                label: label(),
1991                detail: "d".into()
1992            }),
1993            (
1994                "recovery_undetermined",
1995                1,
1996                "Pitboard stopped while settling an interrupted move of `desktop/work`'s login \
1997                 (d). Nothing is lost. Keep Claude closed and run this again: Pitboard settles \
1998                 the move first. If it stops again, `pitboard abandon` keeps every folder and \
1999                 moves on."
2000                    .into()
2001            )
2002        );
2003        let parks = PathBuf::from("/p/parks");
2004        for (why, words) in [
2005            (crate::host::Private::Shared, "Run `chmod 700 /p/parks`"),
2006            (crate::host::Private::Link, "/p/parks is a link"),
2007            (crate::host::Private::NotOwn, "/p/parks is not yours"),
2008        ] {
2009            let (code, exit, sentence) = said(Error::ParksNotPrivate {
2010                path: parks.clone(),
2011                why,
2012            });
2013            assert_eq!((code, exit), ("parks_not_private", 3));
2014            assert!(sentence.contains(words), "{sentence}");
2015            assert!(sentence.ends_with("Nothing has changed."), "{sentence}");
2016        }
2017        for (error, code, exit) in [
2018            (
2019                Error::DifferentVolume {
2020                    parks: parks.clone(),
2021                    root: PathBuf::from("/data"),
2022                },
2023                "different_volume",
2024                3,
2025            ),
2026            (
2027                Error::MoveUndone {
2028                    label: label(),
2029                    detail: "d".into(),
2030                },
2031                "move_undone",
2032                1,
2033            ),
2034            (
2035                Error::MoveStopped {
2036                    label: label(),
2037                    detail: "d".into(),
2038                },
2039                "move_stopped",
2040                1,
2041            ),
2042            (
2043                Error::MoveMixed {
2044                    label: label(),
2045                    places: vec!["`a` in Claude's folder".into()],
2046                },
2047                "move_mixed",
2048                3,
2049            ),
2050            (
2051                Error::UninstallWouldStrand {
2052                    label: Some(label()),
2053                    set_aside: Vec::new(),
2054                },
2055                "uninstall_would_strand",
2056                1,
2057            ),
2058            (
2059                Error::UninstallWouldStrand {
2060                    label: None,
2061                    set_aside: vec![PathBuf::from("/p/parks/pitboard-aside-1")],
2062                },
2063                "uninstall_would_strand",
2064                1,
2065            ),
2066            (
2067                Error::SignInSettledElsewhere { label: label() },
2068                "sign_in_settled_elsewhere",
2069                1,
2070            ),
2071        ] {
2072            assert_eq!((error.code(), error.exit_code()), (code, exit), "{error}");
2073            assert!(!error.to_string().contains('\u{2014}'), "{error}");
2074        }
2075        let waited = Error::AlreadyEnrolled {
2076            tool: ProviderId::Desktop,
2077            email: String::new(),
2078            label: "desktop/home".into(),
2079            asked: "desktop/travel".into(),
2080            waited: true,
2081        };
2082        assert_eq!(
2083            waited.to_string(),
2084            "Claude is signed in to `desktop/home`. Run `pitboard enroll desktop/home` to \
2085             record that login, then add another with `pitboard enroll desktop/travel \
2086             --sign-in`."
2087        );
2088    }
2089
2090    #[test]
2091    fn codes_are_unique_so_a_caller_can_branch_on_them() {
2092        let samples = [
2093            Error::LiveCredentialAbsent {
2094                tool: ProviderId::Claude,
2095                label: None,
2096            },
2097            Error::ParkSlotExhausted,
2098            Error::SignedInAccountChanged,
2099            Error::AccountUnknown {
2100                label: "x".into(),
2101                enrolled: Enrolled::default(),
2102            },
2103            Error::NothingParked {
2104                tool: ProviderId::Claude,
2105                label: "x".into(),
2106            },
2107            Error::ParkedLoginExpired { label: "x".into() },
2108            Error::AccountNotInUse {
2109                tool: ProviderId::Claude,
2110                label: "x".into(),
2111            },
2112            Error::LabelTaken {
2113                label: "x".into(),
2114                who: "e".into(),
2115            },
2116            Error::SwitchRolledBack {
2117                from: "x".into(),
2118                to: "y".into(),
2119                detail: "d".into(),
2120            },
2121            Error::SignInNotKept {
2122                tool: ProviderId::Codex,
2123                label: "x".into(),
2124                detail: "d".into(),
2125            },
2126            Error::Elevated { why: None },
2127            Error::SystemTooOld { build: Some(22631) },
2128            Error::WindowsNotReleased,
2129            Error::LeftLoginChanged { path: "f".into() },
2130            Error::StoredLoginChanged { path: "f".into() },
2131            Error::Usage("u".into()),
2132            Error::ConfirmationNeeded { command: "stow" },
2133        ];
2134        let mut codes: Vec<&str> = samples.iter().map(Error::code).collect();
2135        codes.sort_unstable();
2136        let before = codes.len();
2137        codes.dedup();
2138        assert_eq!(codes.len(), before);
2139    }
2140
2141    /// Whatever stops putting the file away is branched on by its own code, cause and exit
2142    /// status, and says after its own words how far putting it away had gone.
2143    #[test]
2144    fn what_stopped_putting_a_file_away_keeps_its_code_and_says_how_far_it_went() {
2145        let stopped = Error::StowStopped {
2146            partway: Partway::Parked {
2147                label: "work".into(),
2148            },
2149            warnings: Vec::new(),
2150            error: Box::new(Error::Store(crate::store::Error::Locked)),
2151        };
2152
2153        assert_eq!(stopped.code(), "credential_store_locked");
2154        assert_eq!(
2155            stopped.exit_code(),
2156            Error::Store(crate::store::Error::Locked).exit_code()
2157        );
2158        assert!(
2159            stopped.to_string().ends_with(
2160                "desktop session. Pitboard deleted nothing, having parked the login in the file \
2161                 for `work`."
2162            ),
2163            "{stopped}"
2164        );
2165
2166        let unidentified = Error::StowStopped {
2167            partway: Partway::Nothing,
2168            warnings: Vec::new(),
2169            error: Box::new(Error::LeftLoginUnidentified {
2170                path: "f".into(),
2171                cause: Cause::Unreachable,
2172                detail: "offline".into(),
2173            }),
2174        };
2175        assert_eq!(unidentified.code(), "left_login_unidentified");
2176        assert_eq!(unidentified.cause(), Some(Cause::Unreachable));
2177        assert!(
2178            unidentified
2179                .to_string()
2180                .ends_with("try again. Pitboard changed nothing."),
2181            "{unidentified}"
2182        );
2183    }
2184
2185    #[test]
2186    fn a_refusal_after_what_was_said_is_given_back_as_itself() {
2187        let refusal = || Error::AccountNotInUse {
2188            tool: ProviderId::Desktop,
2189            label: "desktop/work".into(),
2190        };
2191        let waiting = crate::service::Warning::MoveWaiting {
2192            tool: ProviderId::Desktop,
2193            label: "desktop/work".into(),
2194        };
2195        let mut carried = refusal().after(vec![waiting]);
2196        assert_eq!(carried.code(), "account_not_in_use");
2197        assert_eq!(carried.exit_code(), refusal().exit_code());
2198        assert_eq!(carried.to_string(), refusal().to_string());
2199        let said = carried.take_warnings();
2200        assert_eq!(
2201            said.iter()
2202                .map(crate::service::Warning::code)
2203                .collect::<Vec<_>>(),
2204            ["move_waiting"]
2205        );
2206        assert!(
2207            matches!(carried, Error::AccountNotInUse { .. }),
2208            "{carried:?}"
2209        );
2210        assert!(carried.take_warnings().is_empty());
2211        let alone = refusal().after(Vec::new());
2212        assert!(matches!(alone, Error::AccountNotInUse { .. }), "{alone:?}");
2213    }
2214
2215    #[test]
2216    fn a_broken_assumption_exits_differently_from_a_bad_request() {
2217        assert_eq!(
2218            Error::LiveCredentialShapeUnexpected {
2219                tool: ProviderId::Claude,
2220                detail: "x".into()
2221            }
2222            .exit_code(),
2223            3
2224        );
2225        assert_eq!(
2226            Error::AccountUnknown {
2227                label: "x".into(),
2228                enrolled: Enrolled::default()
2229            }
2230            .exit_code(),
2231            1
2232        );
2233    }
2234
2235    #[test]
2236    fn every_message_tells_the_user_something_to_do() {
2237        // A message that only states a fact leaves the user stuck.
2238        let actionable = [
2239            Error::LiveCredentialAbsent {
2240                tool: ProviderId::Claude,
2241                label: None,
2242            }
2243            .to_string(),
2244            Error::LiveCredentialAbsent {
2245                tool: ProviderId::Codex,
2246                label: None,
2247            }
2248            .to_string(),
2249            Error::AccountUnknown {
2250                label: "work".into(),
2251                enrolled: Enrolled(vec!["personal".into()]),
2252            }
2253            .to_string(),
2254            Error::NothingParked {
2255                tool: ProviderId::Claude,
2256                label: "work".into(),
2257            }
2258            .to_string(),
2259            Error::ParkedLoginExpired {
2260                label: "work".into(),
2261            }
2262            .to_string(),
2263            Error::ParkedCredentialMissing {
2264                label: "work".into(),
2265            }
2266            .to_string(),
2267            Error::LiveAccountNotEnrolled {
2268                tool: ProviderId::Claude,
2269                who: "a@b.c".into(),
2270            }
2271            .to_string(),
2272            Error::SignInNotKept {
2273                tool: ProviderId::Codex,
2274                label: "codex/work".into(),
2275                detail: "the keychain is locked".into(),
2276            }
2277            .to_string(),
2278            Error::ConfirmationNeeded { command: "forget" }.to_string(),
2279        ];
2280        for message in actionable {
2281            assert!(
2282                message.contains("Run ") || message.contains("Sign in"),
2283                "no action offered: {message}"
2284            );
2285        }
2286    }
2287
2288    /// Codex's messages name Codex and the command that signs in to it; Claude Code's name
2289    /// Claude Code. A message about the wrong tool sends somebody to run the wrong program.
2290    #[test]
2291    fn a_message_names_the_tool_it_is_about() {
2292        let codex = Error::LiveCredentialAbsent {
2293            tool: ProviderId::Codex,
2294            label: None,
2295        }
2296        .to_string();
2297        assert!(
2298            codex.contains("Codex") && codex.contains("`codex login`"),
2299            "{codex}"
2300        );
2301        assert!(!codex.contains("Claude"), "{codex}");
2302
2303        let unverifiable = Error::IdentityUnverifiable {
2304            tool: ProviderId::Codex,
2305            login: Unidentified::SignedIn,
2306            cause: Cause::Unreachable,
2307            detail: "offline".into(),
2308        }
2309        .to_string();
2310        assert!(unverifiable.contains("OpenAI"), "{unverifiable}");
2311
2312        let claude = Error::IdentityUnverifiable {
2313            tool: ProviderId::Claude,
2314            login: Unidentified::SignedIn,
2315            cause: Cause::Unreachable,
2316            detail: "offline".into(),
2317        }
2318        .to_string();
2319        assert!(claude.contains("confirm with Anthropic"), "{claude}");
2320    }
2321
2322    // A 400 used to say "check the connection", which no connection mends.
2323    #[test]
2324    fn a_refusal_says_to_sign_in_again_and_an_outage_to_try_again() {
2325        let said = |cause| {
2326            Error::IdentityUnverifiable {
2327                tool: ProviderId::Claude,
2328                login: Unidentified::SignedIn,
2329                cause,
2330                detail: "Anthropic answered 400: No organization associated with this token".into(),
2331            }
2332            .to_string()
2333        };
2334        assert_eq!(
2335            said(Cause::Refused),
2336            "Pitboard could not confirm with Anthropic which account is signed in (Anthropic \
2337             answered 400: No organization associated with this token), and will not move a \
2338             login it cannot identify. Run `claude`, sign in, then try again."
2339        );
2340        assert!(said(Cause::Unreachable).ends_with("Check the connection and try again."));
2341        assert!(said(Cause::ServerError).ends_with("Try again later."));
2342        assert!(Cause::ServerError.worth_retrying());
2343        assert!(!Cause::Refused.worth_retrying());
2344    }
2345
2346    // The reporter's dialog said "which account is signed in" when it may have been the
2347    // parked login of the account being switched to that failed.
2348    #[test]
2349    fn the_parked_login_that_failed_is_named() {
2350        let said = |cause| {
2351            Error::IdentityUnverifiable {
2352                tool: ProviderId::Claude,
2353                login: Unidentified::Parked {
2354                    label: "dtg".into(),
2355                },
2356                cause,
2357                detail: "Anthropic answered 400".into(),
2358            }
2359            .to_string()
2360        };
2361        assert_eq!(
2362            said(Cause::Refused),
2363            "Pitboard could not confirm with Anthropic whose login is parked for `dtg` \
2364             (Anthropic answered 400), and will not put in use a login it cannot identify. Sign \
2365             in to it again with `pitboard enroll dtg --sign-in`."
2366        );
2367        assert!(said(Cause::Unreachable).ends_with("Check the connection and try again."));
2368        assert!(!said(Cause::Refused).contains("signed in ("));
2369    }
2370
2371    // A refused sign-in used to be told to run `claude` and sign in to the tool itself.
2372    #[test]
2373    fn a_sign_in_that_cannot_be_identified_is_signed_in_again_through_pitboard() {
2374        let said = Error::IdentityUnverifiable {
2375            tool: ProviderId::Claude,
2376            login: Unidentified::SignIn {
2377                label: "dtg".into(),
2378            },
2379            cause: Cause::Refused,
2380            detail: "Anthropic answered 400".into(),
2381        }
2382        .to_string();
2383        assert_eq!(
2384            said,
2385            "Pitboard could not confirm with Anthropic whose login the sign-in for `dtg` made \
2386             (Anthropic answered 400), and keeps no login it cannot identify. Sign in to it \
2387             again with `pitboard enroll dtg --sign-in`."
2388        );
2389    }
2390
2391    /// The codes of the two tools' missing programs stay distinct, and Claude Code's keep
2392    /// the names they were released under.
2393    #[test]
2394    fn a_missing_program_keeps_its_released_code() {
2395        let missing = |tool| Error::ProgramNotFound { tool }.code();
2396        assert_eq!(missing(ProviderId::Claude), "claude_not_found");
2397        assert_eq!(missing(ProviderId::Codex), "codex_not_found");
2398        let absent = |tool| {
2399            Error::ProgramMissing {
2400                tool,
2401                program: "x".into(),
2402            }
2403            .code()
2404        };
2405        assert_eq!(absent(ProviderId::Claude), "claude_program_missing");
2406        assert_eq!(absent(ProviderId::Codex), "codex_program_missing");
2407    }
2408
2409    /// A Pitboard that finds its files written by a newer one is updated by the route it
2410    /// came by. The app's Check for Updates moves only the app and the command line inside
2411    /// it, so it is not offered as another way to update this one.
2412    #[test]
2413    fn a_newer_pitboards_files_say_which_update_moves_which_pitboard() {
2414        let path = PathBuf::from("/home/x/.pitboard/state.json");
2415        for message in [
2416            Error::StateFromNewerVersion {
2417                path: path.clone(),
2418                found: 9,
2419                expected: 5,
2420            }
2421            .to_string(),
2422            Error::StateNamesUnknownTool {
2423                path,
2424                tool: "gemini".into(),
2425            }
2426            .to_string(),
2427        ] {
2428            assert!(
2429                message.contains("Update this Pitboard the way you installed it."),
2430                "{message}"
2431            );
2432            assert!(
2433                message.contains("The app, and the command line inside it, update with"),
2434                "{message}"
2435            );
2436            assert!(!message.contains("or with the app"), "{message}");
2437        }
2438    }
2439
2440    /// Advice to enrol names the tool the account is for: a bare name would enrol a Claude
2441    /// Code account for a Codex login.
2442    #[test]
2443    fn enrolment_advice_keeps_the_tool() {
2444        let codex = Error::LiveAccountNotEnrolled {
2445            tool: ProviderId::Codex,
2446            who: "a@b.c".into(),
2447        }
2448        .to_string();
2449        assert!(codex.contains("pitboard enroll codex/<label>"), "{codex}");
2450        let claude = Error::LiveAccountNotEnrolled {
2451            tool: ProviderId::Claude,
2452            who: "a@b.c".into(),
2453        }
2454        .to_string();
2455        assert!(claude.contains("pitboard enroll <label>"), "{claude}");
2456        let already = Error::AlreadyEnrolled {
2457            tool: ProviderId::Codex,
2458            email: "a@b.c".into(),
2459            label: "codex/work".into(),
2460            asked: "codex/other".into(),
2461            waited: false,
2462        }
2463        .to_string();
2464        assert!(
2465            already.contains("pitboard enroll codex/<label> --sign-in"),
2466            "{already}"
2467        );
2468    }
2469
2470    /// Only a command that changes something finishes an interrupted switch; a plain
2471    /// `pitboard` reads and leaves it. So the message names the switch to run again, as it
2472    /// would be typed here.
2473    #[test]
2474    fn an_unverified_switch_names_a_command_that_finishes_it() {
2475        let message = Error::SwitchUnverified {
2476            tool: ProviderId::Codex,
2477            from: "codex/personal".into(),
2478            to: "codex/work".into(),
2479            detail: "auth.json is not readable".into(),
2480        }
2481        .to_string();
2482        assert!(
2483            message.contains("run `pitboard use codex/work` again"),
2484            "{message}"
2485        );
2486        assert!(!message.contains("run `pitboard` again"), "{message}");
2487    }
2488}