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#[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    /// Claude Code refetches its profile only once a day, so this does not correct itself.
159    #[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    /// The command line itself was wrong; the message is clap's.
248    #[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    /// A stable identifier a program can branch on. Adding one is safe; renaming one is not.
260    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    /// 1 when a request could not be met; 2 when the command line was wrong; 3 when a login
307    /// or Claude Code's files are in a state pitboard cannot safely act on: an unexpected
308    /// format, or a login that could not be put back.
309    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        // A message that only states a fact leaves the user stuck.
361        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}