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