1use std::path::PathBuf;
5
6#[derive(Debug, Clone, PartialEq, Eq, Default)]
9pub struct Enrolled(pub Vec<String>);
10
11impl std::fmt::Display for Enrolled {
12 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13 if self.0.is_empty() {
14 return write!(f, " Nothing is enrolled yet.");
15 }
16 let labels: Vec<String> = self.0.iter().map(|l| format!("`{l}`")).collect();
17 write!(f, " Enrolled: {}.", labels.join(", "))
18 }
19}
20
21#[derive(Debug, thiserror::Error)]
22pub enum Error {
23 #[error(
24 "{path} looks like it is inside {marker}, which syncs to other machines. \
25 Parked logins belong to one machine; set PITBOARD_HOME to a local folder."
26 )]
27 StateOnSyncedDrive { path: PathBuf, marker: String },
28
29 #[error(
30 "the login for `{label}` needs {bytes} bytes and `security` reads {limit} from \
31 stdin. Claude Code writes a login this size by putting it on the argument line, \
32 where any process running as you can read it while the call lasts, and it does \
33 that for this same login on every refresh. pitboard does it only if you say so: \
34 set PITBOARD_ARGV_FALLBACK=1. Otherwise, signing out of MCP servers you no longer \
35 use makes the login smaller."
36 )]
37 CredentialTooLarge {
38 label: String,
39 bytes: usize,
40 limit: usize,
41 },
42
43 #[error(
44 "`{program}` is not on this machine, and pitboard signs in with Claude Code's own \
45 sign-in. Install Claude Code, or point pitboard at it."
46 )]
47 ClaudeProgramMissing { program: String },
48
49 #[error(
50 "CLAUDE_CODE_CUSTOM_OAUTH_URL is set, so Claude Code keeps its login under a \
51 different name than the one pitboard reads. Unset it to use pitboard."
52 )]
53 CustomOauthEndpoint,
54
55 #[error("could not read pitboard's account list at {path}: {source}")]
56 StateUnreadable {
57 path: PathBuf,
58 #[source]
59 source: std::io::Error,
60 },
61
62 #[error(
63 "pitboard's account list at {path} is corrupt ({source}). \
64 Delete it and enroll your accounts again; parked logins will be lost."
65 )]
66 StateCorrupt {
67 path: PathBuf,
68 #[source]
69 source: serde_json::Error,
70 },
71
72 #[error(
73 "{path} was written by a newer pitboard (its format is {found}, this one reads \
74 {expected}). The command line and the app update separately, so upgrade whichever \
75 is behind: `brew upgrade pitboard`, or the app's own Check for Updates."
76 )]
77 StateFromNewerVersion {
78 path: PathBuf,
79 found: u32,
80 expected: u32,
81 },
82
83 #[error(
84 "{path} is in a format ({found}) no version of pitboard has ever written. Delete it \
85 and enroll your accounts again."
86 )]
87 StateVersionUnknown { path: PathBuf, found: u32 },
88
89 #[error(
90 "{path} was written on another computer. Parked logins do not move between \
91 machines; enroll your accounts again on this one."
92 )]
93 StateWrongMachine { path: PathBuf },
94
95 #[error("could not write to pitboard's directory at {path}: {source}")]
96 HomeUnwritable {
97 path: PathBuf,
98 #[source]
99 source: std::io::Error,
100 },
101
102 #[error("could not save pitboard's account list at {path}: {source}")]
103 StateWriteFailed {
104 path: PathBuf,
105 #[source]
106 source: std::io::Error,
107 },
108
109 #[error(
110 "Claude Code has not run on this machine yet ({path} does not exist). \
111 Run `claude` once, sign in, then try again."
112 )]
113 ClaudeConfigMissing { path: PathBuf },
114
115 #[error("could not read Claude Code's config at {path}: {source}")]
116 ClaudeConfigUnreadable {
117 path: PathBuf,
118 #[source]
119 source: std::io::Error,
120 },
121
122 #[error(
123 "Claude Code's config at {path} is not valid JSON right now ({source}). \
124 It may be mid-write; wait a few seconds and try again."
125 )]
126 ClaudeConfigNotJson {
127 path: PathBuf,
128 #[source]
129 source: serde_json::Error,
130 },
131
132 #[error("nothing is signed in right now. Run `claude`, sign in, then try again.")]
133 LiveCredentialAbsent,
134
135 #[error(
136 "the signed-in credential is not shaped like a Claude Code login ({detail}). \
137 Run `pitboard doctor` before switching again."
138 )]
139 LiveCredentialShapeUnexpected { detail: String },
140
141 #[error(
142 "no account is enrolled as `{label}`.{enrolled} Run `pitboard enroll {label} \
143 --sign-in` to add it."
144 )]
145 AccountUnknown {
146 label: String,
147 enrolled: Enrolled,
149 },
150
151 #[error(
152 "`{label}` has no parked login to switch to: the last one went back into use and \
153 Claude Code has moved on from it. Run `pitboard enroll {label} --sign-in` to sign \
154 in to it again."
155 )]
156 NothingParked { label: String },
157
158 #[error(
159 "the parked login for `{label}` has expired. Run `pitboard enroll {label} --sign-in` \
160 to sign in to it again."
161 )]
162 ParkedLoginExpired { label: String },
163
164 #[error(
165 "{email} is signed in but not enrolled, so it cannot be parked. \
166 Run `pitboard enroll <label>` for it first."
167 )]
168 LiveAccountNotEnrolled { email: String },
169
170 #[error(
171 "{email} is already enrolled as `{label}`. To add a different account, run \
172 `pitboard enroll <label> --sign-in`."
173 )]
174 AlreadyEnrolled { email: String, label: String },
175
176 #[error("`{label}` already refers to {email}. Choose a different label.")]
177 LabelTaken { label: String, email: String },
178
179 #[error("`{label}` is signed in; switch to another account before forgetting it.")]
180 CannotForgetActiveAccount { label: String },
181
182 #[error("could not find a free place to park this login. Run `pitboard doctor`.")]
183 ParkSlotExhausted,
184
185 #[error(
186 "the parked login for `{label}` is missing. Run `pitboard enroll {label} --sign-in` \
187 to sign in to it again."
188 )]
189 ParkedCredentialMissing { label: String },
190
191 #[error(
192 "the parked login for `{label}` is not the one pitboard recorded ({detail}). Run \
193 `pitboard enroll {label} --sign-in` to replace it."
194 )]
195 ParkedCredentialCorrupt { label: String, detail: String },
196
197 #[error("could not back up Claude Code's config ({path}): {source}. Nothing was changed.")]
198 ConfigBackupFailed {
199 path: PathBuf,
200 #[source]
201 source: std::io::Error,
202 },
203
204 #[error(
206 "the login moved, but Claude Code's config at {path} could not be updated ({detail}). \
207 Claude Code may show the previous account's name until the next switch."
208 )]
209 ConfigWriteFailed { path: PathBuf, detail: String },
210
211 #[error(
212 "Claude Code's session has expired, so pitboard cannot confirm which account is \
213 signed in. Run `claude` once so it refreshes, then try again."
214 )]
215 SessionExpired,
216
217 #[error(
218 "pitboard could not confirm with Anthropic which account is signed in ({detail}), \
219 and will not move a login it cannot identify. Check the connection and try again."
220 )]
221 IdentityUnverifiable { detail: String },
222
223 #[error("the signed-in account changed while switching. Nothing was moved; try again.")]
224 SignedInAccountChanged,
225
226 #[error(
227 "an earlier switch from `{from}` to `{to}` was interrupted, and pitboard cannot yet \
228 tell whether it finished ({detail}). Nothing was changed. Run `claude` once so its \
229 session is current, then try again."
230 )]
231 RecoveryUndetermined {
232 from: String,
233 to: String,
234 detail: String,
235 },
236
237 #[error(
238 "could not sign in as `{to}` ({detail}); `{from}` is still signed in, nothing was lost."
239 )]
240 SwitchRolledBack {
241 from: String,
242 to: String,
243 detail: String,
244 },
245
246 #[error(
247 "could not sign in as `{to}`, and could not put `{from}` back either ({detail}). \
248 `{from}`'s login is still parked: run `claude` and sign in to any enrolled account, \
249 then `pitboard use {from}`."
250 )]
251 SwitchCorrupted {
252 from: String,
253 to: String,
254 detail: String,
255 },
256
257 #[error(
258 "the record of an interrupted switch at {path} is damaged ({source}), so pitboard \
259 cannot tell what that switch did. Nothing was changed. Check that `pitboard status` \
260 shows the account you expect, then delete the file to continue."
261 )]
262 RecoveryRecordCorrupt {
263 path: PathBuf,
264 #[source]
265 source: serde_json::Error,
266 },
267
268 #[error("could not read or write pitboard's recovery record at {path}: {source}")]
269 RecoveryFailed {
270 path: PathBuf,
271 #[source]
272 source: std::io::Error,
273 },
274
275 #[error("`claude` was not found on PATH. Install Claude Code, run it once, then try again.")]
276 ClaudeNotFound,
277
278 #[error(
279 "could not renew the parked login for `{label}` ({detail}); its last reading is shown \
280 instead. Run `pitboard doctor` if this keeps happening."
281 )]
282 RenewalFailed { label: String, detail: String },
283
284 #[error("the sign-in did not finish, so nothing was enrolled.")]
285 SignInIncomplete,
286
287 #[error(
288 "another `pitboard enroll --sign-in` is already waiting for its sign-in. Finish or \
289 cancel that one first."
290 )]
291 SignInInProgress,
292
293 #[error("{0}")]
295 Usage(String),
296
297 #[error(transparent)]
298 Store(#[from] crate::store::Error),
299
300 #[error(transparent)]
301 Lock(#[from] crate::lock::LockError),
302}
303
304impl Error {
305 pub fn code(&self) -> &'static str {
307 use Error::*;
308 match self {
309 StateOnSyncedDrive { .. } => "state_on_synced_drive",
310 ClaudeProgramMissing { .. } => "claude_program_missing",
311 CredentialTooLarge { .. } => "credential_too_large",
312 CustomOauthEndpoint => "custom_oauth_endpoint",
313 StateUnreadable { .. } => "state_unreadable",
314 StateCorrupt { .. } => "state_corrupt",
315 StateFromNewerVersion { .. } => "state_from_newer_version",
316 StateVersionUnknown { .. } => "state_version_unknown",
317 StateWrongMachine { .. } => "state_wrong_machine",
318 StateWriteFailed { .. } => "state_write_failed",
319 HomeUnwritable { .. } => "home_unwritable",
320 ClaudeConfigMissing { .. } => "claude_config_missing",
321 ClaudeConfigUnreadable { .. } => "claude_config_unreadable",
322 ClaudeConfigNotJson { .. } => "claude_config_not_json",
323 LiveCredentialAbsent => "live_credential_absent",
324 LiveCredentialShapeUnexpected { .. } => "live_credential_shape_unexpected",
325 AccountUnknown { .. } => "account_unknown",
326 NothingParked { .. } => "nothing_parked",
327 ParkedLoginExpired { .. } => "parked_login_expired",
328 LiveAccountNotEnrolled { .. } => "live_account_not_enrolled",
329 AlreadyEnrolled { .. } => "already_enrolled",
330 LabelTaken { .. } => "label_taken",
331 CannotForgetActiveAccount { .. } => "cannot_forget_active_account",
332 ParkSlotExhausted => "park_slot_exhausted",
333 ParkedCredentialMissing { .. } => "parked_credential_missing",
334 ParkedCredentialCorrupt { .. } => "parked_credential_corrupt",
335 ConfigBackupFailed { .. } => "config_backup_failed",
336 ConfigWriteFailed { .. } => "config_write_failed",
337 SwitchRolledBack { .. } => "switch_rolled_back",
338 SwitchCorrupted { .. } => "switch_corrupted",
339 RecoveryFailed { .. } => "recovery_failed",
340 RecoveryRecordCorrupt { .. } => "recovery_record_corrupt",
341 SessionExpired => "session_expired",
342 IdentityUnverifiable { .. } => "identity_unverifiable",
343 SignedInAccountChanged => "signed_in_account_changed",
344 RecoveryUndetermined { .. } => "recovery_undetermined",
345 ClaudeNotFound => "claude_not_found",
346 SignInIncomplete => "sign_in_incomplete",
347 RenewalFailed { .. } => "renewal_failed",
348 SignInInProgress => "sign_in_in_progress",
349 Usage(_) => "usage",
350 Store(e) => e.code(),
351 Lock(e) => e.code(),
352 }
353 }
354
355 pub fn exit_code(&self) -> u8 {
359 use Error::*;
360 match self {
361 LiveCredentialShapeUnexpected { .. }
364 | ClaudeConfigNotJson { .. }
365 | SwitchCorrupted { .. }
366 | CredentialTooLarge { .. }
367 | CustomOauthEndpoint
368 | RecoveryRecordCorrupt { .. } => 3,
369 Usage(_) => 2,
370 Store(e) => e.exit_code(),
371 _ => 1,
372 }
373 }
374}
375
376pub type Result<T> = std::result::Result<T, Error>;
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn codes_are_unique_so_a_caller_can_branch_on_them() {
384 let samples = [
385 Error::LiveCredentialAbsent,
386 Error::ParkSlotExhausted,
387 Error::AccountUnknown {
388 label: "x".into(),
389 enrolled: Enrolled::default(),
390 },
391 Error::NothingParked { label: "x".into() },
392 Error::ParkedLoginExpired { label: "x".into() },
393 Error::LabelTaken {
394 label: "x".into(),
395 email: "e".into(),
396 },
397 ];
398 let mut codes: Vec<&str> = samples.iter().map(Error::code).collect();
399 codes.sort_unstable();
400 let before = codes.len();
401 codes.dedup();
402 assert_eq!(codes.len(), before);
403 }
404
405 #[test]
406 fn a_broken_assumption_exits_differently_from_a_bad_request() {
407 assert_eq!(
408 Error::LiveCredentialShapeUnexpected { detail: "x".into() }.exit_code(),
409 3
410 );
411 assert_eq!(
412 Error::AccountUnknown {
413 label: "x".into(),
414 enrolled: Enrolled::default()
415 }
416 .exit_code(),
417 1
418 );
419 }
420
421 #[test]
422 fn every_message_tells_the_user_something_to_do() {
423 let actionable = [
425 Error::LiveCredentialAbsent.to_string(),
426 Error::AccountUnknown {
427 label: "work".into(),
428 enrolled: Enrolled(vec!["personal".into()]),
429 }
430 .to_string(),
431 Error::NothingParked {
432 label: "work".into(),
433 }
434 .to_string(),
435 Error::ParkedLoginExpired {
436 label: "work".into(),
437 }
438 .to_string(),
439 Error::ParkedCredentialMissing {
440 label: "work".into(),
441 }
442 .to_string(),
443 Error::LiveAccountNotEnrolled {
444 email: "a@b.c".into(),
445 }
446 .to_string(),
447 ];
448 for message in actionable {
449 assert!(
450 message.contains("Run ") || message.contains("Sign in"),
451 "no action offered: {message}"
452 );
453 }
454 }
455}