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