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, Copy, PartialEq, Eq, serde::Serialize)]
31#[serde(rename_all = "snake_case")]
32#[non_exhaustive]
33pub enum Cause {
34 Unreachable,
36 RateLimited,
38 ServerError,
40 AnswerNotUnderstood,
43 LoginRefused,
45 TokenExpired,
48}
49
50impl Cause {
51 pub fn of(error: &crate::api::ApiError) -> Cause {
52 use crate::api::ApiError;
53 match error {
54 ApiError::Unauthorized => Cause::TokenExpired,
55 ApiError::RateLimited { .. } => Cause::RateLimited,
56 ApiError::Network(_) => Cause::Unreachable,
57 ApiError::Unexpected { .. } => Cause::ServerError,
58 ApiError::Malformed(_) => Cause::AnswerNotUnderstood,
59 ApiError::InvalidGrant => Cause::LoginRefused,
60 }
61 }
62
63 pub fn of_provider(error: &crate::provider::ProviderError) -> Cause {
65 use crate::provider::ProviderError as P;
66 match error {
67 P::Unauthorized => Cause::TokenExpired,
68 P::RateLimited { .. } => Cause::RateLimited,
69 P::Network { .. } => Cause::Unreachable,
70 P::Unexpected { .. } => Cause::ServerError,
71 P::Malformed { .. }
72 | P::ShapeUnexpected { .. }
73 | P::Unsupported { .. }
74 | P::NoLogin { .. } => Cause::AnswerNotUnderstood,
75 P::InvalidGrant { .. } => Cause::LoginRefused,
76 }
77 }
78
79 pub fn code(self) -> &'static str {
81 match self {
82 Cause::Unreachable => "unreachable",
83 Cause::RateLimited => "rate_limited",
84 Cause::ServerError => "server_error",
85 Cause::AnswerNotUnderstood => "answer_not_understood",
86 Cause::LoginRefused => "login_refused",
87 Cause::TokenExpired => "token_expired",
88 }
89 }
90
91 pub fn worth_retrying(self) -> bool {
94 match self {
95 Cause::Unreachable | Cause::RateLimited | Cause::ServerError => true,
96 Cause::AnswerNotUnderstood | Cause::LoginRefused | Cause::TokenExpired => false,
97 }
98 }
99}
100
101#[derive(Debug, thiserror::Error)]
102#[non_exhaustive]
103pub enum Error {
104 #[error(
105 "{path} looks like it is inside {marker}, which syncs to other machines. \
106 Parked logins belong to one machine; set PITBOARD_HOME to a local folder."
107 )]
108 StateOnSyncedDrive { path: PathBuf, marker: String },
109
110 #[error(
111 "the login for `{label}` needs {bytes} bytes and `security` reads {limit} from \
112 stdin, so it can only be written on the argument line, which PITBOARD_NO_ARGV \
113 forbids. {}",
114 smaller(*tool)
115 )]
116 CredentialTooLarge {
117 tool: ProviderId,
118 label: String,
119 bytes: usize,
120 limit: usize,
121 },
122
123 #[error(
124 "`{program}` is not on this machine, and pitboard signs in with {}'s own sign-in. \
125 Install {}, or point pitboard at it.",
126 tool.name(),
127 tool.name()
128 )]
129 ProgramMissing { tool: ProviderId, program: String },
130
131 #[error(
132 "CLAUDE_CODE_CUSTOM_OAUTH_URL is set, so Claude Code keeps its login under a \
133 different name than the one pitboard reads. Unset it to use pitboard."
134 )]
135 CustomOauthEndpoint,
136
137 #[error("could not read pitboard's account list at {path}: {source}")]
138 StateUnreadable {
139 path: PathBuf,
140 #[source]
141 source: std::io::Error,
142 },
143
144 #[error(
145 "pitboard's account list at {path} is corrupt ({source}). \
146 Delete it and enroll your accounts again; parked logins will be lost."
147 )]
148 StateCorrupt {
149 path: PathBuf,
150 #[source]
151 source: serde_json::Error,
152 },
153
154 #[error(
155 "{path} was written by a newer pitboard (its format is {found}, this one reads \
156 {expected}). Update this pitboard the way you installed it. The app, and the command \
157 line inside it, update with the app's Check for Updates."
158 )]
159 StateFromNewerVersion {
160 path: PathBuf,
161 found: u32,
162 expected: u32,
163 },
164
165 #[error(
166 "{path} is in a format ({found}) no version of pitboard has ever written. Delete it \
167 and enroll your accounts again."
168 )]
169 StateVersionUnknown { path: PathBuf, found: u32 },
170
171 #[error(
172 "{path} has an account for `{tool}`, a tool this pitboard does not know, so it was \
173 written by a newer one. Update this pitboard the way you installed it. The app, and \
174 the command line inside it, update with the app's Check for Updates."
175 )]
176 StateNamesUnknownTool { path: PathBuf, tool: String },
177
178 #[error(
179 "{path} was written on another computer. Parked logins do not move between \
180 machines, because two machines taking turns presenting one refresh token ends the \
181 login for both. Run `pitboard adopt` to keep your accounts here and drop the \
182 logins they came with; each then needs one `pitboard enroll <label> --sign-in`."
183 )]
184 StateWrongMachine { path: PathBuf },
185
186 #[error(
187 "this platform has no scheduler pitboard knows how to write. Keeping parked logins \
188 alive here means running `pitboard` yourself from time to time."
189 )]
190 ScheduleUnsupported,
191
192 #[error("the scheduler refused: {detail}")]
193 ScheduleRefused { detail: String },
194
195 #[error("the renewal schedule would run {path}, which is not there. Nothing was scheduled.")]
196 ScheduleProgramMissing { path: PathBuf },
197
198 #[error(
201 "the renewal schedule would run {path}, which is in a temporary copy macOS made of \
202 the app and is gone once the app quits. Move pitboard to your Applications folder, \
203 open it from there, and turn on daily renewal again."
204 )]
205 ScheduleProgramTemporary { path: PathBuf },
206
207 #[error("this copy of pitboard has no command line inside it for the renewal schedule to run.")]
210 ScheduleProgramUnnamed,
211
212 #[error("could not write to pitboard's directory at {path}: {source}")]
213 HomeUnwritable {
214 path: PathBuf,
215 #[source]
216 source: std::io::Error,
217 },
218
219 #[error("could not save pitboard's account list at {path}: {source}")]
220 StateWriteFailed {
221 path: PathBuf,
222 #[source]
223 source: std::io::Error,
224 },
225
226 #[error(
227 "Claude Code has not run on this machine yet ({path} does not exist). \
228 Run `claude` once, sign in, then try again."
229 )]
230 ClaudeConfigMissing { path: PathBuf },
231
232 #[error("could not read Claude Code's config at {path}: {source}")]
233 ClaudeConfigUnreadable {
234 path: PathBuf,
235 #[source]
236 source: std::io::Error,
237 },
238
239 #[error(
240 "Claude Code's config at {path} is not valid JSON right now ({source}). \
241 It may be mid-write; wait a few seconds and try again."
242 )]
243 ClaudeConfigNotJson {
244 path: PathBuf,
245 #[source]
246 source: serde_json::Error,
247 },
248
249 #[error(
250 "nothing is signed in to {} right now. Run `{}`, sign in, then try again.",
251 tool.name(),
252 tool.login_command()
253 )]
254 LiveCredentialAbsent { tool: ProviderId },
255
256 #[error(
257 "Claude Code's config says {email} is signed in, but pitboard cannot find that \
258 login in the keychain or in the file it also reads. It will not write a login \
259 where nobody reads it. This usually means Claude Code has started keeping logins \
260 somewhere pitboard does not know about yet: check for a pitboard update, and \
261 report it with `pitboard doctor --json` if there is none."
262 )]
263 LiveCredentialElsewhere { email: String },
264
265 #[error(
266 "the signed-in credential is not shaped like a {} login ({detail}). \
267 Run `pitboard doctor` before switching again.",
268 tool.name()
269 )]
270 LiveCredentialShapeUnexpected { tool: ProviderId, detail: String },
271
272 #[error("{reason}.")]
274 LiveStoreUnsupported { tool: ProviderId, reason: String },
275
276 #[error(
277 "no account is enrolled as `{label}`.{enrolled} Run `pitboard enroll {label} \
278 --sign-in` to add it."
279 )]
280 AccountUnknown {
281 label: String,
282 enrolled: Enrolled,
284 },
285
286 #[error(
287 "`{label}` has no parked login to switch to: the last one went back into use and \
288 {} has moved on from it. Run `pitboard enroll {label} --sign-in` to sign in to it \
289 again.",
290 tool.name()
291 )]
292 NothingParked { tool: ProviderId, label: String },
293
294 #[error(
295 "the parked login for `{label}` has expired. Run `pitboard enroll {label} --sign-in` \
296 to sign in to it again."
297 )]
298 ParkedLoginExpired { label: String },
299
300 #[error(
301 "{email} is signed in but not enrolled, so it cannot be parked. \
302 Run `pitboard enroll {}` for it first.",
303 Key::new(*tool, "<label>").typed()
304 )]
305 LiveAccountNotEnrolled { tool: ProviderId, email: String },
306
307 #[error(
308 "{email} is already enrolled as `{label}`. To add a different account, run \
309 `pitboard enroll {} --sign-in`.",
310 Key::new(*tool, "<label>").typed()
311 )]
312 AlreadyEnrolled {
313 tool: ProviderId,
314 email: String,
315 label: String,
316 },
317
318 #[error("`{label}` already refers to {email}. Choose a different label.")]
319 LabelTaken { label: String, email: String },
320
321 #[error(
322 "`{typed}` is not a tool pitboard knows. It knows: {}.",
323 known.join(", ")
324 )]
325 ProviderUnknown { typed: String, known: Vec<String> },
326
327 #[error(
328 "`{label}` is enrolled for more than one tool: {}. Say which one.",
329 matches.iter().map(|m| format!("`{m}`")).collect::<Vec<_>>().join(", ")
330 )]
331 LabelAmbiguous { label: String, matches: Vec<String> },
332
333 #[error("`{label}` is signed in; switch to another account before forgetting it.")]
334 CannotForgetActiveAccount { label: String },
335
336 #[error("could not find a free place to park this login. Run `pitboard doctor`.")]
337 ParkSlotExhausted,
338
339 #[error(
340 "the parked login for `{label}` is missing. Run `pitboard enroll {label} --sign-in` \
341 to sign in to it again."
342 )]
343 ParkedCredentialMissing { label: String },
344
345 #[error(
346 "the parked login for `{label}` is not the one pitboard recorded ({detail}). Run \
347 `pitboard enroll {label} --sign-in` to replace it."
348 )]
349 ParkedCredentialCorrupt { label: String, detail: String },
350
351 #[error("could not back up Claude Code's config ({path}): {source}. Nothing was changed.")]
352 ConfigBackupFailed {
353 path: PathBuf,
354 #[source]
355 source: std::io::Error,
356 },
357
358 #[error(
360 "the login moved, but Claude Code's config at {path} could not be updated ({detail}). \
361 Claude Code may show the previous account's name until the next switch."
362 )]
363 ConfigWriteFailed { path: PathBuf, detail: String },
364
365 #[error(
366 "{}'s session has expired, so pitboard cannot confirm which account is signed in. \
367 Run `{}` once so it refreshes, then try again.",
368 tool.name(),
369 tool.program()
370 )]
371 SessionExpired { tool: ProviderId },
372
373 #[error(
374 "pitboard could not confirm with {} which account is signed in ({detail}), and will \
375 not move a login it cannot identify. Check the connection and try again.",
376 tool.service()
377 )]
378 IdentityUnverifiable {
379 tool: ProviderId,
380 cause: Cause,
381 detail: String,
382 },
383
384 #[error("the signed-in account changed while switching. Nothing was moved; try again.")]
385 SignedInAccountChanged,
386
387 #[error(
388 "an earlier switch from `{from}` to `{to}` was interrupted, and pitboard cannot yet \
389 tell whether it finished ({detail}). Nothing was changed. Run `{}` once so its \
390 session is current, then try again.",
391 tool.program()
392 )]
393 RecoveryUndetermined {
394 tool: ProviderId,
395 from: String,
396 to: String,
397 detail: String,
398 },
399
400 #[error(
401 "could not sign in as `{to}` ({detail}); `{from}` is still signed in, nothing was lost."
402 )]
403 SwitchRolledBack {
404 from: String,
405 to: String,
406 detail: String,
407 },
408
409 #[error(
410 "{} no longer accepts `{label}`'s parked login, so pitboard did not move anything. \
411 The copy has been dropped; sign in to that account again with \
412 `pitboard enroll {label} --sign-in`.",
413 tool.service()
414 )]
415 ParkedLoginRefused { tool: ProviderId, label: String },
416
417 #[error(
418 "`{label}`'s parked login belongs to {email}, not to the account pitboard has \
419 under that label. Nothing was moved. Run `pitboard doctor`, then \
420 `pitboard enroll {label} --sign-in` to replace it."
421 )]
422 ParkedLoginBelongsElsewhere { label: String, email: String },
423
424 #[error(
425 "signed in as `{to}`, and the login was gone again before pitboard finished. {}",
426 after_it_did_not_hold(*tool, from, to)
427 )]
428 SwitchDidNotHold {
429 tool: ProviderId,
430 from: String,
431 to: String,
432 },
433
434 #[error(
435 "could not sign in as `{to}` ({detail}), and could not read the credential store \
436 back to find out whether anything changed. Nothing has been deleted and both \
437 logins are still here. {} and run `pitboard use {to}` again; it finishes or \
438 undoes this before doing anything else.",
439 make_readable(*tool)
440 )]
441 SwitchUnverified {
442 tool: ProviderId,
443 from: String,
444 to: String,
445 detail: String,
446 },
447
448 #[error(
449 "could not sign in as `{to}`, and could not put `{from}` back either ({detail}). \
450 `{from}`'s login is still parked: run `{}` and sign in to any enrolled account, \
451 then `pitboard use {from}`.",
452 tool.login_command()
453 )]
454 SwitchCorrupted {
455 tool: ProviderId,
456 from: String,
457 to: String,
458 detail: String,
459 },
460
461 #[error(
462 "signed in to `{label}` again, and pitboard could not confirm that its new login \
463 took the place of the one in use ({detail}), so {} may have no login for it now. {}",
464 tool.name(),
465 not_in_use(*tool, label, *parked)
466 )]
467 SignInNotInstalled {
468 tool: ProviderId,
469 label: String,
470 detail: String,
471 parked: bool,
473 warnings: Vec<crate::service::Warning>,
476 },
477
478 #[error(
479 "the new login for `{label}` could not be put in use ({detail}), so it was not \
480 kept, and {} keeps the login it has. Run `pitboard enroll {label} --sign-in` to \
481 sign in again.",
482 tool.name()
483 )]
484 SignInNotKept {
485 tool: ProviderId,
486 label: String,
487 detail: String,
488 },
489
490 #[error(
491 "the record of an interrupted switch at {path} is damaged ({source}), so pitboard \
492 cannot tell what that switch did. Nothing was changed. Check that `pitboard status` \
493 shows the account you expect, then delete the file to continue."
494 )]
495 RecoveryRecordCorrupt {
496 path: PathBuf,
497 #[source]
498 source: serde_json::Error,
499 },
500
501 #[error(
502 "an earlier switch of {} from `{from}` to `{to}` was interrupted while its login was \
503 at {slot}, and this run reads it from somewhere else, so it cannot tell what that \
504 switch did. Nothing was changed. Run pitboard with {} pointing where it did to \
505 finish it, or `pitboard abandon` to keep every login it names and move on.",
506 tool.name(),
507 tool.home_variable()
508 )]
509 RecoveryElsewhere {
510 tool: ProviderId,
511 from: String,
512 to: String,
513 slot: String,
514 },
515
516 #[error("could not read or write pitboard's recovery record at {path}: {source}")]
517 RecoveryFailed {
518 path: PathBuf,
519 #[source]
520 source: std::io::Error,
521 },
522
523 #[error(
524 "`{}` was not found on PATH. Install {}, run it once, then try again.",
525 tool.program(),
526 tool.name()
527 )]
528 ProgramNotFound { tool: ProviderId },
529
530 #[error(
531 "could not renew the parked login for `{label}` ({detail}); its last reading is shown \
532 instead. Run `pitboard doctor` if this keeps happening."
533 )]
534 RenewalFailed {
535 label: String,
536 cause: Option<Cause>,
537 detail: String,
538 },
539
540 #[error("the sign-in did not finish, so nothing was enrolled.")]
541 SignInIncomplete,
542
543 #[error(
547 "pitboard will not sign in to a second account on this machine: {reason} Signing \
548 in would write over the login you are using."
549 )]
550 SignInNotIsolated { reason: String },
551
552 #[error(
553 "another `pitboard enroll --sign-in` is already waiting for its sign-in. Finish or \
554 cancel that one first."
555 )]
556 SignInInProgress,
557
558 #[error("{0}")]
560 Usage(String),
561
562 #[error(transparent)]
563 Store(#[from] crate::store::Error),
564
565 #[error(transparent)]
566 Lock(#[from] crate::lock::LockError),
567}
568
569impl Error {
570 pub fn code(&self) -> &'static str {
572 use Error::*;
573 match self {
574 StateOnSyncedDrive { .. } => "state_on_synced_drive",
575 ProgramMissing { tool, .. } => match tool {
576 ProviderId::Claude => "claude_program_missing",
577 ProviderId::Codex => "codex_program_missing",
578 },
579 CredentialTooLarge { .. } => "credential_too_large",
580 CustomOauthEndpoint => "custom_oauth_endpoint",
581 StateUnreadable { .. } => "state_unreadable",
582 StateCorrupt { .. } => "state_corrupt",
583 StateFromNewerVersion { .. } => "state_from_newer_version",
584 StateVersionUnknown { .. } => "state_version_unknown",
585 StateNamesUnknownTool { .. } => "state_names_unknown_tool",
586 StateWrongMachine { .. } => "state_wrong_machine",
587 StateWriteFailed { .. } => "state_write_failed",
588 ScheduleUnsupported => "schedule_unsupported",
589 ScheduleRefused { .. } => "schedule_refused",
590 ScheduleProgramMissing { .. } => "schedule_program_missing",
591 ScheduleProgramTemporary { .. } => "schedule_program_temporary",
592 ScheduleProgramUnnamed => "schedule_program_unnamed",
593 HomeUnwritable { .. } => "home_unwritable",
594 ClaudeConfigMissing { .. } => "claude_config_missing",
595 ClaudeConfigUnreadable { .. } => "claude_config_unreadable",
596 ClaudeConfigNotJson { .. } => "claude_config_not_json",
597 LiveCredentialAbsent { .. } => "live_credential_absent",
598 LiveStoreUnsupported { .. } => "live_store_unsupported",
599 LiveCredentialElsewhere { .. } => "live_credential_elsewhere",
600 LiveCredentialShapeUnexpected { .. } => "live_credential_shape_unexpected",
601 AccountUnknown { .. } => "account_unknown",
602 NothingParked { .. } => "nothing_parked",
603 ParkedLoginExpired { .. } => "parked_login_expired",
604 ParkedLoginRefused { .. } => "parked_login_refused",
605 ParkedLoginBelongsElsewhere { .. } => "parked_login_belongs_elsewhere",
606 LiveAccountNotEnrolled { .. } => "live_account_not_enrolled",
607 AlreadyEnrolled { .. } => "already_enrolled",
608 LabelTaken { .. } => "label_taken",
609 ProviderUnknown { .. } => "provider_unknown",
610 LabelAmbiguous { .. } => "label_ambiguous",
611 CannotForgetActiveAccount { .. } => "cannot_forget_active_account",
612 ParkSlotExhausted => "park_slot_exhausted",
613 ParkedCredentialMissing { .. } => "parked_credential_missing",
614 ParkedCredentialCorrupt { .. } => "parked_credential_corrupt",
615 ConfigBackupFailed { .. } => "config_backup_failed",
616 ConfigWriteFailed { .. } => "config_write_failed",
617 SwitchRolledBack { .. } => "switch_rolled_back",
618 SwitchDidNotHold { .. } => "switch_did_not_hold",
619 SwitchUnverified { .. } => "switch_unverified",
620 SwitchCorrupted { .. } => "switch_corrupted",
621 SignInNotInstalled { .. } => "sign_in_not_installed",
622 SignInNotKept { .. } => "sign_in_not_kept",
623 RecoveryFailed { .. } => "recovery_failed",
624 RecoveryRecordCorrupt { .. } => "recovery_record_corrupt",
625 SessionExpired { .. } => "session_expired",
626 IdentityUnverifiable { .. } => "identity_unverifiable",
627 SignedInAccountChanged => "signed_in_account_changed",
628 RecoveryUndetermined { .. } => "recovery_undetermined",
629 RecoveryElsewhere { .. } => "recovery_elsewhere",
630 ProgramNotFound { tool } => match tool {
631 ProviderId::Claude => "claude_not_found",
632 ProviderId::Codex => "codex_not_found",
633 },
634 SignInIncomplete => "sign_in_incomplete",
635 SignInNotIsolated { .. } => "sign_in_not_isolated",
636 RenewalFailed { .. } => "renewal_failed",
637 SignInInProgress => "sign_in_in_progress",
638 Usage(_) => "usage",
639 Store(e) => e.code(),
640 Lock(e) => e.code(),
641 }
642 }
643
644 pub fn cause(&self) -> Option<Cause> {
650 use Error::*;
651 match self {
652 IdentityUnverifiable { cause, .. } => Some(*cause),
653 RenewalFailed { cause, .. } => *cause,
654 SessionExpired { .. } => Some(Cause::TokenExpired),
655 _ => None,
656 }
657 }
658
659 pub(crate) fn take_warnings(&mut self) -> Vec<crate::service::Warning> {
662 match self {
663 Error::SignInNotInstalled { warnings, .. } => std::mem::take(warnings),
664 _ => Vec::new(),
665 }
666 }
667
668 pub fn exit_code(&self) -> u8 {
669 use Error::*;
670 match self {
671 LiveCredentialShapeUnexpected { .. }
674 | LiveStoreUnsupported { .. }
675 | ClaudeConfigNotJson { .. }
676 | SwitchCorrupted { .. }
677 | SwitchUnverified { .. }
678 | SwitchDidNotHold { .. }
679 | SignInNotInstalled { .. }
680 | LiveCredentialElsewhere { .. }
681 | CredentialTooLarge { .. }
682 | CustomOauthEndpoint
683 | RecoveryRecordCorrupt { .. } => 3,
684 Usage(_) => 2,
685 Store(e) => e.exit_code(),
686 _ => 1,
687 }
688 }
689}
690
691pub type Result<T> = std::result::Result<T, Error>;
692
693fn make_readable(tool: ProviderId) -> &'static str {
695 match tool {
696 ProviderId::Claude => "Unlock the keychain",
697 ProviderId::Codex => "Make Codex's auth.json readable to you again",
698 }
699}
700
701fn smaller(tool: ProviderId) -> &'static str {
703 match tool {
704 ProviderId::Claude => {
705 "Unset it, or sign out of MCP servers you no longer use to make the login smaller."
706 }
707 ProviderId::Codex => "Unset it to let pitboard write it.",
708 }
709}
710
711fn after_it_did_not_hold(tool: ProviderId, from: &str, to: &str) -> String {
714 match tool {
715 ProviderId::Claude => format!(
716 "Claude Code removes a login without taking the write lock when `/logout` has \
717 given up waiting, which is the one write pitboard cannot exclude. Nothing was \
718 lost: both `{from}` and `{to}` are parked. Run `claude` and sign in to any \
719 enrolled account, then `pitboard use {to}`."
720 ),
721 ProviderId::Codex => format!(
725 "Codex takes no lock, so a codex session still running from before the switch, \
726 refreshing or signing out, rewrote auth.json underneath it. `{to}` is still \
727 parked. `{from}`'s park may hold a token that refresh spent. Quit every running \
728 codex, then run `pitboard` to see what is signed in; do not run `codex login` \
729 or `codex logout` until you have, because either revokes the login they find."
730 ),
731 }
732}
733
734fn not_in_use(tool: ProviderId, label: &str, parked: bool) -> String {
736 if parked {
737 format!(
738 "The new login is parked, so it is not lost. Run `pitboard` to see what is \
739 signed in; if nothing is, run `{}` and sign in to any enrolled account, then \
740 `pitboard use {label}`.",
741 tool.login_command()
742 )
743 } else {
744 format!(
745 "It could not be parked either: run `{}` and sign in to `{label}` again.",
746 tool.login_command()
747 )
748 }
749}
750
751#[cfg(test)]
752mod tests {
753 use super::*;
754
755 #[test]
756 fn codes_are_unique_so_a_caller_can_branch_on_them() {
757 let samples = [
758 Error::LiveCredentialAbsent {
759 tool: ProviderId::Claude,
760 },
761 Error::ParkSlotExhausted,
762 Error::AccountUnknown {
763 label: "x".into(),
764 enrolled: Enrolled::default(),
765 },
766 Error::NothingParked {
767 tool: ProviderId::Claude,
768 label: "x".into(),
769 },
770 Error::ParkedLoginExpired { label: "x".into() },
771 Error::LabelTaken {
772 label: "x".into(),
773 email: "e".into(),
774 },
775 Error::SwitchRolledBack {
776 from: "x".into(),
777 to: "y".into(),
778 detail: "d".into(),
779 },
780 Error::SignInNotKept {
781 tool: ProviderId::Codex,
782 label: "x".into(),
783 detail: "d".into(),
784 },
785 ];
786 let mut codes: Vec<&str> = samples.iter().map(Error::code).collect();
787 codes.sort_unstable();
788 let before = codes.len();
789 codes.dedup();
790 assert_eq!(codes.len(), before);
791 }
792
793 #[test]
794 fn a_broken_assumption_exits_differently_from_a_bad_request() {
795 assert_eq!(
796 Error::LiveCredentialShapeUnexpected {
797 tool: ProviderId::Claude,
798 detail: "x".into()
799 }
800 .exit_code(),
801 3
802 );
803 assert_eq!(
804 Error::AccountUnknown {
805 label: "x".into(),
806 enrolled: Enrolled::default()
807 }
808 .exit_code(),
809 1
810 );
811 }
812
813 #[test]
814 fn every_message_tells_the_user_something_to_do() {
815 let actionable = [
817 Error::LiveCredentialAbsent {
818 tool: ProviderId::Claude,
819 }
820 .to_string(),
821 Error::LiveCredentialAbsent {
822 tool: ProviderId::Codex,
823 }
824 .to_string(),
825 Error::AccountUnknown {
826 label: "work".into(),
827 enrolled: Enrolled(vec!["personal".into()]),
828 }
829 .to_string(),
830 Error::NothingParked {
831 tool: ProviderId::Claude,
832 label: "work".into(),
833 }
834 .to_string(),
835 Error::ParkedLoginExpired {
836 label: "work".into(),
837 }
838 .to_string(),
839 Error::ParkedCredentialMissing {
840 label: "work".into(),
841 }
842 .to_string(),
843 Error::LiveAccountNotEnrolled {
844 tool: ProviderId::Claude,
845 email: "a@b.c".into(),
846 }
847 .to_string(),
848 Error::SignInNotKept {
849 tool: ProviderId::Codex,
850 label: "codex/work".into(),
851 detail: "the keychain is locked".into(),
852 }
853 .to_string(),
854 ];
855 for message in actionable {
856 assert!(
857 message.contains("Run ") || message.contains("Sign in"),
858 "no action offered: {message}"
859 );
860 }
861 }
862
863 #[test]
866 fn a_message_names_the_tool_it_is_about() {
867 let codex = Error::LiveCredentialAbsent {
868 tool: ProviderId::Codex,
869 }
870 .to_string();
871 assert!(
872 codex.contains("Codex") && codex.contains("`codex login`"),
873 "{codex}"
874 );
875 assert!(!codex.contains("Claude"), "{codex}");
876
877 let unverifiable = Error::IdentityUnverifiable {
878 tool: ProviderId::Codex,
879 cause: Cause::Unreachable,
880 detail: "offline".into(),
881 }
882 .to_string();
883 assert!(unverifiable.contains("OpenAI"), "{unverifiable}");
884
885 let claude = Error::IdentityUnverifiable {
886 tool: ProviderId::Claude,
887 cause: Cause::Unreachable,
888 detail: "offline".into(),
889 }
890 .to_string();
891 assert!(claude.contains("confirm with Anthropic"), "{claude}");
892 }
893
894 #[test]
897 fn a_missing_program_keeps_its_released_code() {
898 let missing = |tool| Error::ProgramNotFound { tool }.code();
899 assert_eq!(missing(ProviderId::Claude), "claude_not_found");
900 assert_eq!(missing(ProviderId::Codex), "codex_not_found");
901 let absent = |tool| {
902 Error::ProgramMissing {
903 tool,
904 program: "x".into(),
905 }
906 .code()
907 };
908 assert_eq!(absent(ProviderId::Claude), "claude_program_missing");
909 assert_eq!(absent(ProviderId::Codex), "codex_program_missing");
910 }
911
912 #[test]
916 fn a_newer_pitboards_files_say_which_update_moves_which_pitboard() {
917 let path = PathBuf::from("/home/x/.pitboard/state.json");
918 for message in [
919 Error::StateFromNewerVersion {
920 path: path.clone(),
921 found: 9,
922 expected: 5,
923 }
924 .to_string(),
925 Error::StateNamesUnknownTool {
926 path: path.clone(),
927 tool: "gemini".into(),
928 }
929 .to_string(),
930 ] {
931 assert!(
932 message.contains("Update this pitboard the way you installed it."),
933 "{message}"
934 );
935 assert!(
936 message.contains("The app, and the command line inside it, update with"),
937 "{message}"
938 );
939 assert!(!message.contains("or with the app"), "{message}");
940 }
941 }
942
943 #[test]
946 fn enrolment_advice_keeps_the_tool() {
947 let codex = Error::LiveAccountNotEnrolled {
948 tool: ProviderId::Codex,
949 email: "a@b.c".into(),
950 }
951 .to_string();
952 assert!(codex.contains("pitboard enroll codex/<label>"), "{codex}");
953 let claude = Error::LiveAccountNotEnrolled {
954 tool: ProviderId::Claude,
955 email: "a@b.c".into(),
956 }
957 .to_string();
958 assert!(claude.contains("pitboard enroll <label>"), "{claude}");
959 let already = Error::AlreadyEnrolled {
960 tool: ProviderId::Codex,
961 email: "a@b.c".into(),
962 label: "codex/work".into(),
963 }
964 .to_string();
965 assert!(
966 already.contains("pitboard enroll codex/<label> --sign-in"),
967 "{already}"
968 );
969 }
970
971 #[test]
975 fn an_unverified_switch_names_a_command_that_finishes_it() {
976 let message = Error::SwitchUnverified {
977 tool: ProviderId::Codex,
978 from: "codex/personal".into(),
979 to: "codex/work".into(),
980 detail: "auth.json is not readable".into(),
981 }
982 .to_string();
983 assert!(
984 message.contains("run `pitboard use codex/work` again"),
985 "{message}"
986 );
987 assert!(!message.contains("run `pitboard` again"), "{message}");
988 }
989}