1use crate::host::Os;
5use crate::provider::ProviderId;
6use crate::state::Key;
7use std::path::PathBuf;
8
9#[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#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum Partway {
28 Nothing,
30 StoredRenewedKept,
34 RenewedUnwritten,
37 RenewedKept,
40 Renewed,
42 Parked { label: String },
44}
45
46impl std::fmt::Display for Partway {
47 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
86#[serde(rename_all = "snake_case")]
87#[non_exhaustive]
88pub enum Cause {
89 Unreachable,
91 RateLimited,
93 ServerError,
95 Refused,
97 AnswerNotUnderstood,
100 LoginRefused,
102 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
158pub enum Unidentified {
159 SignedIn,
161 Unnamed,
163 SignIn { label: String },
165 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 #[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 #[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 #[error("{}", temporary_copy(.path))]
324 ScheduleProgramTemporary { path: PathBuf },
325
326 #[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 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 #[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 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 asked: String,
423 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 #[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 #[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 #[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 #[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 parked: bool,
637 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 #[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 #[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 #[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 #[error("{} Pitboard {partway}.", sentence(error))]
752 StowStopped {
753 partway: Partway,
754 warnings: Vec<crate::service::Warning>,
757 error: Box<Error>,
758 },
759
760 #[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 #[error("{error}")]
774 SaidFirst {
775 said: Vec<crate::service::Warning>,
776 error: Box<Error>,
777 },
778
779 #[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 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 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 #[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 #[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 #[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 #[error("Pitboard for Windows is not released yet. This build changes nothing.")]
931 WindowsNotReleased,
932
933 #[error("{0}")]
935 Usage(String),
936
937 #[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 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 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 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 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 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 pub fn exit_code(&self) -> u8 {
1134 use Error::*;
1135 match self {
1136 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
1165fn 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
1221fn 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
1261fn 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
1269fn 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
1287fn 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
1299fn 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
1317fn 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
1331fn 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
1345fn 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
1362fn 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
1372fn 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 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
1399fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1418pub enum Then {
1419 Nothing,
1420 OpenAndQuit,
1421 UpdatePitboard,
1422 CopyAside,
1423 SignInAndEnrol,
1424}
1425
1426impl Then {
1427 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#[derive(Debug, Clone, PartialEq, Eq)]
1450pub enum LoginDoubt {
1451 NamedAnother {
1453 recorded: String,
1454 named: Option<String>,
1455 },
1456 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
1652fn 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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}