Skip to main content

xdk/
error.rs

1//! The library's error type and the exit codes that classify it.
2//!
3//! Display strings are lowercase fragments with no prefix and no trailing
4//! period, so they read cleanly inside an embedder's error chain; `xr`
5//! applies its own prefixes when it renders one.
6
7use serde::{Deserialize, Serialize};
8
9/// What the caller should do next. Closed set; agents branch on it.
10///
11/// A newer release can add a member, so a caller treats one it does not
12/// recognize as its default branch.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
14#[serde(rename_all = "kebab-case")]
15#[non_exhaustive]
16pub enum NextAction {
17    /// No app carries client credentials; register one.
18    RegisterApp,
19    /// The target app has credentials and no token; sign in.
20    SignIn,
21    /// Another app is the one to use; rerun naming it.
22    SelectApp,
23    /// The store could not be read or parsed; look at the file.
24    InspectStore,
25    /// X refused the app; enroll it in the developer portal.
26    EnrollApp,
27    /// The invocation was not one the tool takes, a word that names no
28    /// command or a usage error; read the help it names.
29    // `xr` reaches this alone: its unknown-command envelope
30    // (`crates/xurl-cli/src/cli/runner.rs`, `render_unknown_command`) and its
31    // usage-error envelope (`crates/xurl-cli/src/cli/output/mod.rs`,
32    // `print_invalid_args`) carry it, and no library error returns it.
33    #[doc(hidden)]
34    ShowHelp,
35    /// Media was still processing when the wait's deadline passed; wait on
36    /// the same media id again.
37    ResumeWait,
38    /// X rate limited the request and said when the window resets; send it
39    /// again once that time has passed.
40    WaitAndRetry,
41}
42
43/// The enrollment recipe for an app X refuses.
44const ENROLLMENT_DOCS: &str = "https://github.com/brettdavies/xurl-rs#x-platform-enrollment";
45/// Where X documents its authentication methods.
46const AUTHENTICATION_DOCS: &str = "https://docs.x.com/resources/fundamentals/authentication";
47/// Where X documents its rate limits.
48const RATE_LIMIT_DOCS: &str = "https://docs.x.com/resources/fundamentals/rate-limits";
49
50/// Whether an API refusal is X declining the app itself rather than the
51/// request: a 403 whose body carries either enrollment marker.
52#[must_use]
53pub fn refuses_enrollment(status: u16, body: &str) -> bool {
54    if status != 403 {
55        return false;
56    }
57    let haystack = body.to_ascii_lowercase();
58    haystack.contains("client-not-enrolled") || haystack.contains("client-forbidden")
59}
60
61/// A lower-level failure an [`Error`] wraps, as
62/// [`std::error::Error::source`] returns it.
63pub type Source = Box<dyn std::error::Error + Send + Sync + 'static>;
64
65/// The library's error type.
66///
67/// The enum is `#[non_exhaustive]`: variants are added as the X API grows,
68/// so a downstream match keeps a wildcard arm. In-crate, [`Self::kind`] and
69/// [`Self::exit_code`] match every variant by name, so a new variant is
70/// classified before it ships.
71///
72/// A variant that wraps a lower-level failure keeps it: `source()` returns
73/// the `reqwest`, `std::io`, `serde_json`, or `serde_yaml` error underneath,
74/// so a caller can walk the chain or downcast to it. `Display` stays the
75/// message alone.
76///
77/// # Example
78///
79/// ```rust,no_run
80/// use xdk::Error;
81/// # fn run() -> Result<(), Error> {
82/// # let result: Result<(), Error> = Err(Error::validation("missing field"));
83/// match result {
84///     Ok(()) => println!("ok"),
85///     Err(Error::Api { status, body, .. }) => eprintln!("api {status}: {body}"),
86///     Err(Error::Validation(msg)) => eprintln!("validation: {msg}"),
87///     Err(Error::InvalidUrl { message, .. }) => eprintln!("bad URL: {message}"),
88///     Err(other) => eprintln!("{} (kind={})", other, other.kind()),
89/// }
90/// # Ok(()) }
91/// ```
92#[derive(Debug, thiserror::Error)]
93#[non_exhaustive]
94pub enum Error {
95    /// HTTP transport / request construction error.
96    #[error("{message}")]
97    Http {
98        /// What failed.
99        message: String,
100        /// The failure underneath, typically a `reqwest::Error`.
101        #[source]
102        source: Option<Source>,
103    },
104
105    /// File / IO error.
106    #[error("{message}")]
107    Io {
108        /// What failed.
109        message: String,
110        /// The failure underneath, typically a `std::io::Error`.
111        #[source]
112        source: Option<Source>,
113    },
114
115    /// Invalid HTTP method supplied.
116    #[error("invalid HTTP method: {0}")]
117    InvalidMethod(String),
118
119    /// API returned an HTTP error response (status >= 400).
120    #[error("{body}")]
121    Api {
122        /// HTTP status code from the API response.
123        status: u16,
124        /// Raw response body (typically JSON).
125        body: String,
126        /// When the rate-limit window resets, as seconds since the Unix
127        /// epoch: the `x-rate-limit-reset` header of this response. `None`
128        /// when the response named no reset, which is every response but a
129        /// 429 and some 429s too. It is never the window an earlier response
130        /// reported, so a caller that waits on it waits on what X said about
131        /// this request.
132        reset_at: Option<u64>,
133    },
134
135    /// Non-HTTP validation or logic error (e.g., missing fields, errors-only 200 responses).
136    #[error("{0}")]
137    Validation(String),
138
139    /// A URL that cannot be requested: a raw URL with a scheme other than
140    /// `http://` or `https://`, or one that does not parse. Both are
141    /// rejected before any network or filesystem activity.
142    #[error("invalid URL: {message}")]
143    InvalidUrl {
144        /// What is wrong with the URL, naming it.
145        message: String,
146        /// The parse failure underneath, a `url::ParseError`, when the URL
147        /// did not parse.
148        #[source]
149        source: Option<Source>,
150    },
151
152    /// Path-parameter value contained a character that would break URL
153    /// semantics (`/`, `?`, `#`, or `%`). Surfaces real IDs that contain
154    /// stray separators rather than silently encoding them.
155    #[error("invalid path parameter {name:?}: value {value:?} contains a reserved character")]
156    InvalidPathParam {
157        /// Name of the offending `{param}` segment in the path template.
158        name: String,
159        /// Caller-supplied value that failed validation.
160        value: String,
161    },
162
163    /// Internal invariant violated — typically a programmer error such as
164    /// a path template referencing a `{name}` segment that the caller never
165    /// supplied in `path_params`.
166    #[error("internal error: {0}")]
167    Internal(String),
168
169    /// JSON serialization / deserialization error.
170    #[error("{message}")]
171    Json {
172        /// What failed.
173        message: String,
174        /// The failure underneath, typically a `serde_json::Error`.
175        #[source]
176        source: Option<Source>,
177    },
178
179    /// Authentication error with sub-type context.
180    #[error("{message}")]
181    Auth {
182        /// What failed.
183        message: String,
184        /// The failure underneath, when a lower-level error caused it.
185        #[source]
186        source: Option<Source>,
187    },
188
189    /// Token store persistence / lookup error.
190    #[error("{message}")]
191    TokenStore {
192        /// What failed.
193        message: String,
194        /// The failure underneath, when a lower-level error caused it.
195        #[source]
196        source: Option<Source>,
197    },
198
199    /// A wait on media processing reached its deadline with the job still
200    /// running. The upload itself is intact: X keeps a media id valid for 24
201    /// hours, so another wait on [`Self::ProcessingTimeout::media_id`] picks
202    /// the job up where this one left it.
203    #[error("media {media_id} was still processing when the {}-second wait ended", waited.as_secs())]
204    ProcessingTimeout {
205        /// The media id whose processing had not finished.
206        media_id: String,
207        /// The deadline the wait ran to.
208        waited: std::time::Duration,
209    },
210
211    /// Auth method mismatch: the caller asked for, or auto-detect resolved,
212    /// a scheme the endpoint's matrix entry does not accept. The payload is
213    /// boxed so the error stays small on every `Result` an embedder returns;
214    /// [`AuthMismatch`] describes the three shapes it takes.
215    #[error("{0}")]
216    AuthMethodMismatch(Box<AuthMismatch>),
217}
218
219crate::assert_send_sync!(Error);
220
221// A boxed `dyn Error` carries no unwind-safety auto traits, so wrapping a
222// cause would silently take them from `Error` and from every type holding
223// one. The cause is only ever read, through `source()`, so a panic cannot
224// leave it half-written; `anyhow::Error` makes the same two impls.
225impl std::panic::UnwindSafe for Error {}
226impl std::panic::RefUnwindSafe for Error {}
227
228/// What an [`Error::AuthMethodMismatch`] describes.
229///
230/// Three shapes share the type:
231/// - **Explicit mismatch**: `requested = Some("app"|"oauth1"|"oauth2")`,
232///   `available_in_app = None`. The caller asked for a scheme the endpoint
233///   does not accept.
234/// - **Empty intersection**: `requested = None`,
235///   `available_in_app = Some([nonempty])`. Auto-detect found no stored
236///   credential on the active app that the endpoint accepts.
237/// - **Wrong app**: `requested = None`, `other_apps_with_creds =
238///   Some([nonempty])`. The active app stores no credentials but other apps
239///   in the store do. `available_in_app` is `Some([])`, or `Some(["app"])`
240///   when `XURL_BEARER_TOKEN` supplies a bearer the endpoint does not accept.
241///
242/// `Display` is the lowercase fragment the library reports; `xr` composes
243/// its own recovery wording from the same fields.
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct AuthMismatch {
246    /// Path template (for example `/2/users/{id}/likes`), verbatim from the
247    /// spec so an agent can match on it.
248    pub endpoint: String,
249    /// The path with `{param}` segments substituted (for example
250    /// `/2/users/12345/likes`); messages prefer it over `endpoint`. `None`
251    /// when no substitution context was available.
252    pub rendered_url: Option<String>,
253    /// HTTP method, already uppercased.
254    pub method: String,
255    /// What the caller asked for: `Some("app"|"oauth1"|"oauth2")` in the
256    /// explicit-mismatch shape, `None` otherwise.
257    pub requested: Option<String>,
258    /// The schemes the endpoint accepts, as wire strings.
259    pub supported: Vec<String>,
260    /// The schemes the active app has stored: `None` in the
261    /// explicit-mismatch shape, `Some([nonempty])` in the empty-intersection
262    /// shape, `Some([])` in the wrong-app shape.
263    pub available_in_app: Option<Vec<String>>,
264    /// The active app's name, when one was resolved.
265    pub app: Option<String>,
266    /// Other apps in the store that hold credentials; populated only in the
267    /// wrong-app shape.
268    pub other_apps_with_creds: Option<Vec<String>>,
269}
270
271crate::assert_send_sync!(AuthMismatch);
272
273impl AuthMismatch {
274    /// Which of the three shapes these fields describe.
275    #[doc(hidden)]
276    #[must_use]
277    pub fn shape(&self) -> MismatchShape<'_> {
278        mismatch_shape(
279            self.requested.as_deref(),
280            self.available_in_app.as_deref(),
281            self.other_apps_with_creds.as_deref(),
282        )
283    }
284}
285
286impl std::fmt::Display for AuthMismatch {
287    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288        let path = self.rendered_url.as_deref().unwrap_or(&self.endpoint);
289        let method = &self.method;
290        let app_name = self.app.as_deref().unwrap_or("the active app");
291        let list = |items: &[String]| {
292            if items.is_empty() {
293                "none".to_string()
294            } else {
295                items.join(", ")
296            }
297        };
298        match self.shape() {
299            MismatchShape::Explicit { requested } if self.supported.is_empty() => {
300                write!(f, "{requested} auth is not accepted at {method} {path}")
301            }
302            MismatchShape::Explicit { requested } => {
303                let accepts = list(&self.supported);
304                write!(
305                    f,
306                    "{requested} auth is not accepted at {method} {path} (accepts {accepts})"
307                )
308            }
309            MismatchShape::WrongApp { others } => {
310                let alts = others.join(", ");
311                write!(
312                    f,
313                    "app '{app_name}' holds no credentials for {method} {path} (other apps with credentials: {alts})"
314                )
315            }
316            MismatchShape::EmptyIntersection { available } => {
317                let has = list(available);
318                let accepts = list(&self.supported);
319                write!(
320                    f,
321                    "no stored auth method on app '{app_name}' is accepted at {method} {path} (app has {has}; endpoint accepts {accepts})"
322                )
323            }
324            MismatchShape::Unknown => write!(f, "auth method is not accepted at {method} {path}"),
325        }
326    }
327}
328
329impl From<AuthMismatch> for Error {
330    fn from(mismatch: AuthMismatch) -> Self {
331        Self::AuthMethodMismatch(Box::new(mismatch))
332    }
333}
334
335/// Which situation an `AuthMethodMismatch`'s fields describe.
336///
337/// The library's Display and the `xr` renderer both classify through
338/// [`mismatch_shape`], so the two cannot sort one error into different
339/// shapes.
340#[doc(hidden)]
341#[derive(Debug)]
342pub enum MismatchShape<'a> {
343    /// The caller asked for a scheme the endpoint does not accept.
344    Explicit { requested: &'a str },
345    /// The active app stores nothing, but other apps hold credentials.
346    WrongApp { others: &'a [String] },
347    /// Nothing the active app stores is accepted at the endpoint.
348    EmptyIntersection { available: &'a [String] },
349    /// No app context was available when the error was built.
350    Unknown,
351}
352
353/// Classifies the three optional fields into one [`MismatchShape`].
354///
355/// Only the wrong-app branch sets `other_apps_with_creds`, so
356/// `available_in_app` may still carry an env-supplied bearer there.
357#[doc(hidden)]
358pub fn mismatch_shape<'a>(
359    requested: Option<&'a str>,
360    available_in_app: Option<&'a [String]>,
361    other_apps_with_creds: Option<&'a [String]>,
362) -> MismatchShape<'a> {
363    match (requested, available_in_app, other_apps_with_creds) {
364        (Some(requested), _, _) => MismatchShape::Explicit { requested },
365        (None, Some(_), Some(others)) if !others.is_empty() => MismatchShape::WrongApp { others },
366        (None, Some(available), _) => MismatchShape::EmptyIntersection { available },
367        (None, None, _) => MismatchShape::Unknown,
368    }
369}
370
371#[allow(dead_code)] // Public library API — used by consumers and integration tests
372impl Error {
373    /// Create an API error with an HTTP status code and response body.
374    pub fn api(status: u16, body: impl Into<String>) -> Self {
375        Self::Api {
376            status,
377            body: body.into(),
378            reset_at: None,
379        }
380    }
381
382    /// Create a validation error for non-HTTP error conditions.
383    pub fn validation(body: impl Into<String>) -> Self {
384        Self::Validation(body.into())
385    }
386
387    /// Create a transport error from a message alone.
388    pub fn http(message: impl Into<String>) -> Self {
389        Self::Http {
390            message: message.into(),
391            source: None,
392        }
393    }
394
395    /// Create a file / IO error from a message alone.
396    pub fn io(message: impl Into<String>) -> Self {
397        Self::Io {
398            message: message.into(),
399            source: None,
400        }
401    }
402
403    /// Create a serialization error from a message alone.
404    pub fn json(message: impl Into<String>) -> Self {
405        Self::Json {
406            message: message.into(),
407            source: None,
408        }
409    }
410
411    /// Create an invalid-URL error from a message naming the URL.
412    pub fn invalid_url(message: impl Into<String>) -> Self {
413        Self::InvalidUrl {
414            message: message.into(),
415            source: None,
416        }
417    }
418
419    /// Create an auth error with a descriptive message.
420    pub fn auth(message: impl Into<String>) -> Self {
421        Self::Auth {
422            message: message.into(),
423            source: None,
424        }
425    }
426
427    /// Create an auth error with a message and underlying cause.
428    pub fn auth_with_cause(message: &str, cause: &dyn std::fmt::Display) -> Self {
429        Self::auth(format!("{message} (cause: {cause})"))
430    }
431
432    /// Create a token store error.
433    pub fn token_store(message: impl Into<String>) -> Self {
434        Self::TokenStore {
435            message: message.into(),
436            source: None,
437        }
438    }
439
440    /// Attaches the lower-level failure this error was built from, so
441    /// [`std::error::Error::source`] returns it. A variant that carries no
442    /// source is returned unchanged.
443    #[must_use]
444    pub fn with_source(mut self, cause: impl Into<Source>) -> Self {
445        if let Self::Http { source, .. }
446        | Self::Io { source, .. }
447        | Self::Json { source, .. }
448        | Self::Auth { source, .. }
449        | Self::TokenStore { source, .. }
450        | Self::InvalidUrl { source, .. } = &mut self
451        {
452            *source = Some(cause.into());
453        }
454        self
455    }
456
457    /// The recovery step this error carries, when the library can name one.
458    ///
459    /// A 403 that says X refused the app is `EnrollApp`; a bare 403 on X's
460    /// Pay-per-use enrollment failure is unactionable without it. A
461    /// processing timeout is `ResumeWait`, and a 429 that named its reset is
462    /// `WaitAndRetry`; a 429 that named none has no time to wait for. Every
463    /// other error is `None` here: the steps that depend on a credential
464    /// store are the binary's to choose.
465    ///
466    /// [`NextAction`] is library API by intent, not a rendering detail: an
467    /// embedder branches on it the way `xr` renders it, so it stays on the
468    /// error rather than in any one consumer. The enum is `#[non_exhaustive]`,
469    /// so a new action is a minor release and a `match` needs a wildcard arm.
470    #[must_use]
471    pub fn next_action(&self) -> Option<NextAction> {
472        match self {
473            Self::Api { status, body, .. } if refuses_enrollment(*status, body) => {
474                Some(NextAction::EnrollApp)
475            }
476            Self::Api {
477                status: 429,
478                reset_at: Some(_),
479                ..
480            } => Some(NextAction::WaitAndRetry),
481            Self::ProcessingTimeout { .. } => Some(NextAction::ResumeWait),
482            Self::Api { .. }
483            | Self::Http { .. }
484            | Self::Io { .. }
485            | Self::InvalidMethod(_)
486            | Self::Validation(_)
487            | Self::InvalidUrl { .. }
488            | Self::InvalidPathParam { .. }
489            | Self::Internal(_)
490            | Self::Json { .. }
491            | Self::Auth { .. }
492            | Self::TokenStore { .. } => None,
493            // The stored-credential recovery steps are the binary's: it knows
494            // which apps hold what and names the invocation.
495            Self::AuthMethodMismatch(_) => None,
496        }
497    }
498
499    /// The page that documents this error's recovery, when one exists.
500    ///
501    /// An enrollment refusal names the recipe that moves the app to the
502    /// right package, a credential failure points at X's authentication
503    /// overview, and a 429 at its rate-limit rules. One arm per variant, so
504    /// a new variant decides its pointer before it ships.
505    #[must_use]
506    pub fn docs_url(&self) -> Option<&'static str> {
507        match self {
508            Self::Api { status, body, .. } if refuses_enrollment(*status, body) => {
509                Some(ENROLLMENT_DOCS)
510            }
511            Self::Api { status: 401, .. } | Self::Auth { .. } | Self::AuthMethodMismatch(_) => {
512                Some(AUTHENTICATION_DOCS)
513            }
514            Self::Api { status: 429, .. } => Some(RATE_LIMIT_DOCS),
515            Self::Api { .. }
516            | Self::Http { .. }
517            | Self::Io { .. }
518            | Self::InvalidMethod(_)
519            | Self::Validation(_)
520            | Self::InvalidUrl { .. }
521            | Self::InvalidPathParam { .. }
522            | Self::Internal(_)
523            | Self::Json { .. }
524            | Self::TokenStore { .. }
525            | Self::ProcessingTimeout { .. } => None,
526        }
527    }
528
529    /// Returns true if this is an API error (HTTP status >= 400).
530    #[must_use]
531    pub fn is_api(&self) -> bool {
532        matches!(self, Self::Api { .. })
533    }
534
535    /// Returns true if this is a validation error.
536    #[must_use]
537    pub fn is_validation(&self) -> bool {
538        matches!(self, Self::Validation(_))
539    }
540
541    /// Returns a typed kebab-case identifier for this error.
542    ///
543    /// The closed set is the envelope `reason` vocabulary that agents
544    /// pattern-match on. Never returns English; never embeds state.
545    ///
546    /// | Variant                | `kind()`         |
547    /// | ---------------------- | ---------------- |
548    /// | `Auth`                 | `auth-required`  |
549    /// | `TokenStore`           | `token-store`    |
550    /// | `Api { 401, .. }`      | `auth-required`  |
551    /// | `Api { 403, .. }`      | `forbidden`      |
552    /// | `Api { 404, .. }`      | `not-found`      |
553    /// | `Api { 429, .. }`      | `rate-limited`   |
554    /// | `Api { 400 \| 422, .. }` | `invalid-request` |
555    /// | `Api { 5xx, .. }`      | `server-error`   |
556    /// | `Api { other, .. }`    | `api-error`      |
557    /// | `Http`                 | `network-error`  |
558    /// | `Io`                   | `io`             |
559    /// | `Json`                 | `serialization`  |
560    /// | `InvalidMethod`        | `invalid-method` |
561    /// | `Validation`           | `validation`     |
562    /// | `InvalidUrl`           | `invalid-url`    |
563    /// | `InvalidPathParam`     | `invalid-path-param` |
564    /// | `Internal`             | `internal`       |
565    /// | `AuthMethodMismatch`   | `auth-method-mismatch` |
566    /// | `ProcessingTimeout`    | `processing-timeout` |
567    #[must_use]
568    pub fn kind(&self) -> &'static str {
569        match self {
570            Self::Auth { .. } => "auth-required",
571            Self::TokenStore { .. } => "token-store",
572            Self::Api { status: 401, .. } => "auth-required",
573            Self::Api { status: 403, .. } => "forbidden",
574            Self::Api { status: 404, .. } => "not-found",
575            Self::Api { status: 429, .. } => "rate-limited",
576            Self::Api {
577                status: 400 | 422, ..
578            } => "invalid-request",
579            Self::Api {
580                status: 500..=599, ..
581            } => "server-error",
582            Self::Api { .. } => "api-error",
583            Self::Http { .. } => "network-error",
584            Self::Io { .. } => "io",
585            Self::Json { .. } => "serialization",
586            Self::InvalidMethod(_) => "invalid-method",
587            Self::AuthMethodMismatch(_) => "auth-method-mismatch",
588            Self::Validation(_) => "validation",
589            Self::InvalidUrl { .. } => "invalid-url",
590            Self::InvalidPathParam { .. } => "invalid-path-param",
591            Self::Internal(_) => "internal",
592            Self::ProcessingTimeout { .. } => "processing-timeout",
593        }
594    }
595
596    /// Returns the structured exit code for this error.
597    ///
598    /// Pattern-matches on `Api { status, .. }` for HTTP errors; a transport
599    /// failure (`Http`) never carries a status, and its message quotes the
600    /// URL, so it is never read for one. One arm per variant, with no
601    /// wildcard: a variant added without an exit-code decision is a compile
602    /// error, not a silent exit 1.
603    #[must_use]
604    pub fn exit_code(&self) -> i32 {
605        match self {
606            Self::Auth { .. } | Self::TokenStore { .. } => EXIT_AUTH_REQUIRED,
607            Self::Api { status: 401, .. } => EXIT_AUTH_REQUIRED,
608            Self::Api { status: 429, .. } => EXIT_RATE_LIMITED,
609            Self::Api { status: 404, .. } => EXIT_NOT_FOUND,
610            Self::Api { .. } => EXIT_GENERAL_ERROR,
611            Self::Http { .. } | Self::Io { .. } => EXIT_NETWORK_ERROR,
612            Self::Json { .. }
613            | Self::InvalidMethod(_)
614            | Self::Validation(_)
615            | Self::InvalidUrl { .. }
616            | Self::InvalidPathParam { .. }
617            | Self::Internal(_)
618            | Self::ProcessingTimeout { .. } => EXIT_GENERAL_ERROR,
619            Self::AuthMethodMismatch(_) => EXIT_AUTH_MISMATCH,
620        }
621    }
622}
623
624impl From<reqwest::Error> for Error {
625    fn from(err: reqwest::Error) -> Self {
626        Self::http(err.to_string()).with_source(err)
627    }
628}
629
630impl From<std::io::Error> for Error {
631    fn from(err: std::io::Error) -> Self {
632        Self::io(err.to_string()).with_source(err)
633    }
634}
635
636impl From<serde_json::Error> for Error {
637    fn from(err: serde_json::Error) -> Self {
638        Self::json(err.to_string()).with_source(err)
639    }
640}
641
642// The token store and a `.twurlrc` import are the library's only YAML.
643impl From<serde_yaml::Error> for Error {
644    fn from(err: serde_yaml::Error) -> Self {
645        Self::token_store(err.to_string()).with_source(err)
646    }
647}
648
649impl From<url::ParseError> for Error {
650    fn from(err: url::ParseError) -> Self {
651        Self::invalid_url(err.to_string()).with_source(err)
652    }
653}
654
655/// Convenience alias used throughout the crate.
656pub type Result<T> = std::result::Result<T, Error>;
657
658// ── Auth failure messages ──────────────────────────────────────────
659//
660// One constant per message so its construction sites and the runner's
661// hint seam agree on the exact string; the runner matches on them to
662// decide whether a recovery hint applies.
663
664/// The message every no-credentials failure carries.
665pub const NO_AUTH_METHOD: &str = "NoAuthMethod: no authentication method available";
666
667/// The message every missing-`OAuth2`-token failure carries.
668pub const NO_OAUTH2_TOKEN: &str = "TokenNotFound: oauth2 token not found";
669
670// ── Exit codes ─────────────────────────────────────────────────────
671
672/// Structured exit codes for machine-readable error handling.
673///
674/// Follows the sysexits-style matrix from the agent-native CLI envelope
675/// pattern (corpus doc #1):
676/// - `0` (`EXIT_SUCCESS`): success.
677/// - `1` (`EXIT_GENERAL_ERROR`): general / user-recoverable error.
678/// - `2` (`EXIT_USAGE_ERROR`): clap usage error (invalid args). Not surfaced
679///   here; emitted directly by the runner on parse failure.
680/// - `3` (`EXIT_RATE_LIMITED`): API rate limit hit — agent should back off.
681/// - `4` (`EXIT_NOT_FOUND`): resource not found.
682/// - `5` (`EXIT_NETWORK_ERROR`): network / connectivity issue.
683/// - `77` (`EXIT_AUTH_REQUIRED`): authentication required. Matches sysexits
684///   `EX_NOPERM`; disambiguates from clap `EX_USAGE` (2).
685/// - `2` (`EXIT_AUTH_MISMATCH`): auth method mismatch — the user supplied
686///   `--auth X` for an endpoint that does not accept `X`. Distinct from
687///   `EXIT_AUTH_REQUIRED` (missing credential) — this signals a *wrong*
688///   credential request that's fixable by changing `--auth`. Shares the
689///   `EX_USAGE` numeric value with clap because both are usage faults.
690#[allow(dead_code)] // Public library API — used by consumers
691pub const EXIT_SUCCESS: i32 = 0;
692/// General / user-recoverable error. Sysexits default for anything not
693/// covered by a more specific code.
694#[allow(dead_code)] // Public library API — used by consumers
695pub const EXIT_GENERAL_ERROR: i32 = 1;
696/// Auth method mismatch. `EX_USAGE` from sysexits — `2`.
697///
698/// Surfaces when `--auth X` is in the user's invocation and the endpoint's
699/// matrix entry does not accept `X`. Also surfaces on the auto-detect
700/// empty-intersection path when no stored credential on the active app
701/// satisfies the endpoint. Distinct from [`EXIT_AUTH_REQUIRED`] (= `77`,
702/// missing credential).
703#[allow(dead_code)] // Public library API — used by consumers
704pub const EXIT_AUTH_MISMATCH: i32 = 2;
705/// Usage error. `EX_USAGE` from sysexits — `2`.
706///
707/// Clap parse failures share this value, as do the errors a caller can fix
708/// by changing the invocation rather than the credentials. Distinct in
709/// meaning from [`EXIT_AUTH_MISMATCH`], which shares the number.
710#[allow(dead_code)] // Public library API — used by consumers
711pub const EXIT_USAGE_ERROR: i32 = 2;
712/// Authentication required. `EX_NOPERM` from sysexits — `77`.
713///
714/// Auth-required errors exit `77` rather than `2`, so the code unambiguously
715/// distinguishes an auth failure from a clap usage error (`EX_USAGE` = `2`).
716#[allow(dead_code)] // Public library API — used by consumers
717pub const EXIT_AUTH_REQUIRED: i32 = 77;
718/// API rate limit hit (HTTP 429). Agents should back off and retry per
719/// the response's rate-limit headers.
720#[allow(dead_code)] // Public library API — used by consumers
721pub const EXIT_RATE_LIMITED: i32 = 3;
722/// Resource not found (HTTP 404).
723#[allow(dead_code)] // Public library API — used by consumers
724pub const EXIT_NOT_FOUND: i32 = 4;
725/// A transport or filesystem failure: [`Error::Http`] and [`Error::Io`]. An
726/// API response whose status has no code of its own exits
727/// [`EXIT_GENERAL_ERROR`].
728#[allow(dead_code)] // Public library API — used by consumers
729pub const EXIT_NETWORK_ERROR: i32 = 5;
730
731/// Maps an [`Error`] to a structured exit code.
732///
733/// Free-function shim delegating to [`Error::exit_code`].
734#[allow(dead_code)] // Public library API — used by consumers
735#[must_use]
736pub fn exit_code_for_error(e: &Error) -> i32 {
737    e.exit_code()
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743
744    #[test]
745    fn a_refused_enrollment_names_the_enroll_step() {
746        let refused = Error::api(403, r#"{"reason":"client-not-enrolled","detail":"x"}"#);
747        assert_eq!(refused.next_action(), Some(NextAction::EnrollApp));
748        let forbidden = Error::api(403, "CLIENT-FORBIDDEN");
749        assert_eq!(forbidden.next_action(), Some(NextAction::EnrollApp));
750    }
751
752    #[test]
753    fn error_fits_under_the_result_large_err_threshold() {
754        let size = std::mem::size_of::<Error>();
755        assert!(
756            size <= 128,
757            "Error is {size} bytes; clippy warns embedders above 128"
758        );
759    }
760
761    #[test]
762    fn other_errors_carry_no_step() {
763        assert_eq!(Error::api(403, "plain forbidden").next_action(), None);
764        assert_eq!(Error::api(401, "client-not-enrolled").next_action(), None);
765        assert_eq!(Error::auth("x").next_action(), None);
766    }
767}