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