Skip to main content

pitboard_core/
error.rs

1//! Every way pitboard can fail. Each variant has a message naming the cause and an action,
2//! a stable code for programs to branch on, and an exit code.
3
4use std::path::PathBuf;
5
6/// The labels an account list holds, rendered for a message: " Enrolled: `a`, `b`." or
7/// nothing at all when none are.
8#[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        /// The labels that do exist, so a typo costs no second command.
148        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    /// Claude Code refetches its profile only once a day, so this does not correct itself.
205    #[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    /// The command line itself was wrong; the message is clap's.
294    #[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    /// A stable identifier a program can branch on. Adding one is safe; renaming one is not.
306    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    /// 1 when a request could not be met; 2 when the command line was wrong; 3 when a login
356    /// or Claude Code's files are in a state pitboard cannot safely act on: an unexpected
357    /// format, or a login that could not be put back.
358    pub fn exit_code(&self) -> u8 {
359        use Error::*;
360        match self {
361            // A login or Claude Code's files in a state pitboard will not act on, which is
362            // what exit 3 means: not a failure of the attempt, a refusal to attempt.
363            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        // A message that only states a fact leaves the user stuck.
424        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}