Skip to main content

xurl/
error.rs

1//! Typed error system matching xurl's error categories.
2//!
3//! The Go source uses string-typed errors with a `Type` field. We replicate
4//! that with thiserror variants so Rust callers get pattern matching while
5//! the Display output stays identical to xurl.
6
7use thiserror::Error;
8
9/// Top-level error type for xurl-rs.
10///
11/// `result_large_err` would fire because the largest variant
12/// (`AuthMethodMismatch`) carries multiple `String`/`Vec<String>` fields.
13/// Boxing the variant would change the public construction surface; allow
14/// the lint on the enum so consumers can keep building the variant inline.
15///
16/// # Example
17///
18/// ```rust,no_run
19/// use xurl::error::XurlError;
20/// # fn run() -> Result<(), XurlError> {
21/// # let result: Result<(), XurlError> = Err(XurlError::validation("missing field"));
22/// match result {
23///     Ok(()) => println!("ok"),
24///     Err(XurlError::Api { status, body }) => eprintln!("api {status}: {body}"),
25///     Err(XurlError::Validation(msg)) => eprintln!("validation: {msg}"),
26///     Err(XurlError::InvalidUrl(url)) => eprintln!("bad URL: {url}"),
27///     Err(other) => eprintln!("{} (kind={})", other, other.kind()),
28/// }
29/// # Ok(()) }
30/// ```
31#[allow(clippy::result_large_err)]
32#[derive(Debug, Error)]
33pub enum XurlError {
34    /// HTTP transport / request construction error.
35    #[error("HTTP Error: {0}")]
36    Http(String),
37
38    /// File / IO error.
39    #[error("IO Error: {0}")]
40    Io(String),
41
42    /// Invalid HTTP method supplied.
43    #[error("Invalid Method: Invalid HTTP method: {0}")]
44    InvalidMethod(String),
45
46    /// API returned an HTTP error response (status >= 400).
47    #[error("{body}")]
48    Api {
49        /// HTTP status code from the API response.
50        status: u16,
51        /// Raw response body (typically JSON).
52        body: String,
53    },
54
55    /// Non-HTTP validation or logic error (e.g., missing fields, errors-only 200 responses).
56    #[error("{0}")]
57    Validation(String),
58
59    /// Raw URL supplied with an unsupported scheme. Only `http://` and
60    /// `https://` are accepted; file/ftp/etc are rejected before any
61    /// network or filesystem activity.
62    #[error("Invalid URL: {0}")]
63    InvalidUrl(String),
64
65    /// Path-parameter value contained a character that would break URL
66    /// semantics (`/`, `?`, `#`, or `%`). Surfaces real IDs that contain
67    /// stray separators rather than silently encoding them.
68    #[error("Invalid path parameter {name:?}: value {value:?} contains a reserved character")]
69    InvalidPathParam {
70        /// Name of the offending `{param}` segment in the path template.
71        name: String,
72        /// Caller-supplied value that failed validation.
73        value: String,
74    },
75
76    /// Internal invariant violated — typically a programmer error such as
77    /// a path template referencing a `{name}` segment that the caller never
78    /// supplied in `path_params`.
79    #[error("Internal error: {0}")]
80    Internal(String),
81
82    /// JSON serialization / deserialization error.
83    #[error("JSON Error: {0}")]
84    Json(String),
85
86    /// Authentication error with sub-type context.
87    #[error("Auth Error: {0}")]
88    Auth(String),
89
90    /// Token store persistence / lookup error.
91    #[error("Token Store Error: {0}")]
92    TokenStore(String),
93
94    /// Sentinel: a structured envelope was already emitted by the call site.
95    ///
96    /// The runner short-circuits its trailing `print_error` for this variant
97    /// and propagates the carried exit code unchanged. Used by
98    /// `print_confirmation_required` so the canonical envelope
99    /// `{"status":"error","reason":"confirmation-required",…}` is the only
100    /// thing the agent sees on stderr (no duplicated `{"error":...,"kind":...}`
101    /// from the generic `print_error` path).
102    #[error("envelope-already-emitted")]
103    EnvelopeAlreadyEmitted {
104        /// Exit code the runner should surface for this error.
105        exit_code: i32,
106    },
107
108    /// Auth method mismatch: the user supplied (or the auto-detect resolved)
109    /// an auth method the endpoint's matrix entry doesn't accept.
110    ///
111    /// Three shapes share the variant:
112    /// - **Explicit-mismatch**: `requested = Some("app"|"oauth1"|"oauth2")`,
113    ///   `available_in_app = None`. The user passed `--auth X` and `X` isn't
114    ///   in the endpoint's supported set.
115    /// - **Empty-intersection**: `requested = None`,
116    ///   `available_in_app = Some([nonempty])`. Auto-detect resolved a
117    ///   non-empty `available_in_app` against `supported` to an empty
118    ///   intersection: no stored credential on the active app satisfies the
119    ///   endpoint.
120    /// - **Wrong-app**: `requested = None`, `available_in_app = Some([])`,
121    ///   `other_apps_with_creds = Some([nonempty])`. The active app holds no
122    ///   credentials but other apps in the store do — the user likely
123    ///   forgot `--app NAME`.
124    ///
125    /// `app` carries the active app name (when known) so the recovery hint
126    /// can substitute it. `rendered_url` carries the substituted path
127    /// (`/2/users/12345/likes`) for user-facing messages while `endpoint`
128    /// stays as the spec template (`/2/users/{id}/likes`) for agents to
129    /// pattern-match against.
130    ///
131    /// The `Display` impl renders the same body that fills the envelope's
132    /// `message` field.
133    #[error("{}", auth_method_mismatch_message(.endpoint, .rendered_url.as_deref(), .method, .requested.as_deref(), .supported, .available_in_app.as_deref(), .app.as_deref(), .other_apps_with_creds.as_deref()))]
134    AuthMethodMismatch {
135        /// Path template (e.g. `/2/users/{id}/likes`) — keyed verbatim
136        /// against the spec for agent pattern matching.
137        endpoint: String,
138        /// Path with `{param}` segments substituted from `path_params`
139        /// (e.g. `/2/users/12345/likes`). User-facing messages prefer this
140        /// over `endpoint` so the recovery hint doesn't contain literal
141        /// brace placeholders. `None` when no substitution context was
142        /// available (e.g. construction outside a real call).
143        rendered_url: Option<String>,
144        /// HTTP method, already uppercased.
145        method: String,
146        /// What the user asked for. `Some("app"|"oauth1"|"oauth2")` in the
147        /// explicit-mismatch shape; `None` in the empty-intersection and
148        /// wrong-app shapes.
149        requested: Option<String>,
150        /// Auth methods the endpoint accepts, as user-facing strings.
151        supported: Vec<String>,
152        /// Auth methods the active app actually has stored. `None` in the
153        /// explicit-mismatch shape; `Some([nonempty])` in the empty-
154        /// intersection shape; `Some([])` in the wrong-app shape.
155        available_in_app: Option<Vec<String>>,
156        /// Active app name (e.g. `"default"` or `"bird-prod"`). `None` when
157        /// constructed outside a context that resolved an active app.
158        app: Option<String>,
159        /// Names of other apps in the token store that DO hold credentials.
160        /// Populated only in the wrong-app shape so agents can suggest the
161        /// right `--app NAME` to try.
162        other_apps_with_creds: Option<Vec<String>>,
163    },
164}
165
166/// Builds the user-facing message that fills both the `Display` output and
167/// the JSON envelope's `message` field for `AuthMethodMismatch`.
168///
169/// Three shapes per the variant's docstring; each ends with an actionable
170/// recovery instruction. Prefers `rendered_url` over `endpoint` for
171/// user-facing strings so `{id}` placeholders don't leak into messages.
172#[allow(clippy::too_many_arguments)]
173fn auth_method_mismatch_message(
174    endpoint: &str,
175    rendered_url: Option<&str>,
176    method: &str,
177    requested: Option<&str>,
178    supported: &[String],
179    available_in_app: Option<&[String]>,
180    app: Option<&str>,
181    other_apps_with_creds: Option<&[String]>,
182) -> String {
183    let display_path = rendered_url.unwrap_or(endpoint);
184    let app_name = app.unwrap_or("the active app");
185    let suggest_first = |fallback: &str| {
186        supported
187            .first()
188            .map(|s| format!(" Add credentials with: xr auth {s} --app {fallback}."))
189            .unwrap_or_default()
190    };
191
192    match (requested, available_in_app, other_apps_with_creds) {
193        // Explicit mismatch: the user passed --auth X explicitly.
194        (Some(req), _, _) => {
195            let pretty_req = pretty_scheme(req);
196            let alt = supported
197                .iter()
198                .map(|s| format!("--auth {s}"))
199                .collect::<Vec<_>>()
200                .join(" or ");
201            if alt.is_empty() {
202                format!("{pretty_req} auth is not accepted at {method} {display_path}.")
203            } else {
204                format!("{pretty_req} auth is not accepted at {method} {display_path}. Use {alt}.")
205            }
206        }
207        // Wrong-app: active app holds nothing but other apps do.
208        (None, Some(avail), Some(others)) if avail.is_empty() && !others.is_empty() => {
209            let alts = others.join(", ");
210            let accepts = if supported.is_empty() {
211                "none".to_string()
212            } else {
213                supported.join(", ")
214            };
215            format!(
216                "App '{app_name}' has no stored credentials, but other apps do ({alts}). Endpoint {method} {display_path} accepts: {accepts}. Try --app NAME with one of the apps above."
217            )
218        }
219        // Empty intersection on a non-empty active app.
220        (None, Some(avail), _) => {
221            let has = if avail.is_empty() {
222                "none".to_string()
223            } else {
224                avail.join(", ")
225            };
226            let accepts = if supported.is_empty() {
227                "none".to_string()
228            } else {
229                supported.join(", ")
230            };
231            let suggest = suggest_first(app_name);
232            format!(
233                "No stored auth method on app '{app_name}' is accepted at {method} {display_path}. App has: {has}. Endpoint accepts: {accepts}.{suggest}"
234            )
235        }
236        (None, None, _) => {
237            format!("Auth method is not accepted at {method} {display_path}.")
238        }
239    }
240}
241
242/// Maps a wire-format auth string to its pretty-printed scheme name.
243///
244/// Delegates to [`crate::api::auth_matrix::WireScheme::pretty`] so the
245/// display vocabulary lives in one place. Unknown strings fall back to
246/// the input verbatim so a future scheme added to the matrix without an
247/// updated pretty mapping still surfaces something readable.
248fn pretty_scheme(name: &str) -> String {
249    crate::api::auth_matrix::WireScheme::from_wire(name)
250        .map(|ws| ws.pretty().to_string())
251        .unwrap_or_else(|| name.to_string())
252}
253
254#[allow(dead_code)] // Public library API — used by consumers and integration tests
255impl XurlError {
256    /// Create an API error with an HTTP status code and response body.
257    pub fn api(status: u16, body: impl Into<String>) -> Self {
258        Self::Api {
259            status,
260            body: body.into(),
261        }
262    }
263
264    /// Create a validation error for non-HTTP error conditions.
265    pub fn validation(body: impl Into<String>) -> Self {
266        Self::Validation(body.into())
267    }
268
269    /// Create an auth error with a descriptive message.
270    pub fn auth(message: impl Into<String>) -> Self {
271        Self::Auth(message.into())
272    }
273
274    /// Create an auth error with a message and underlying cause.
275    pub fn auth_with_cause(message: &str, cause: &dyn std::fmt::Display) -> Self {
276        Self::Auth(format!("{message} (cause: {cause})"))
277    }
278
279    /// Create a token store error.
280    pub fn token_store(message: impl Into<String>) -> Self {
281        Self::TokenStore(message.into())
282    }
283
284    /// Returns true if this is an API error (HTTP status >= 400).
285    #[must_use]
286    pub fn is_api(&self) -> bool {
287        matches!(self, Self::Api { .. })
288    }
289
290    /// Returns true if this is a validation error.
291    #[must_use]
292    pub fn is_validation(&self) -> bool {
293        matches!(self, Self::Validation(_))
294    }
295
296    /// Returns a typed kebab-case identifier for this error.
297    ///
298    /// The closed set is the envelope `reason` vocabulary that agents
299    /// pattern-match on. Never returns English; never embeds state.
300    ///
301    /// | Variant                | `kind()`         |
302    /// | ---------------------- | ---------------- |
303    /// | `Auth`                 | `auth-required`  |
304    /// | `TokenStore`           | `token-store`    |
305    /// | `Api { 401, .. }`      | `auth-required`  |
306    /// | `Api { 429, .. }`      | `rate-limited`   |
307    /// | `Api { 404, .. }`      | `not-found`      |
308    /// | `Api { other, .. }`    | `network-error`  |
309    /// | `Http`                 | `network-error`  |
310    /// | `Io`                   | `io`             |
311    /// | `Json`                 | `serialization`  |
312    /// | `InvalidMethod`        | `invalid-method` |
313    /// | `Validation`           | `validation`     |
314    /// | `InvalidUrl`           | `invalid-url`    |
315    /// | `InvalidPathParam`     | `invalid-path-param` |
316    /// | `Internal`             | `internal`       |
317    /// | `EnvelopeAlreadyEmitted` | `confirmation-required` |
318    /// | `AuthMethodMismatch`   | `auth-method-mismatch` |
319    #[must_use]
320    pub fn kind(&self) -> &'static str {
321        match self {
322            Self::Auth(_) => "auth-required",
323            Self::TokenStore(_) => "token-store",
324            Self::Api { status: 401, .. } => "auth-required",
325            Self::Api { status: 429, .. } => "rate-limited",
326            Self::Api { status: 404, .. } => "not-found",
327            Self::Api { .. } => "network-error",
328            Self::Http(_) => "network-error",
329            Self::Io(_) => "io",
330            Self::Json(_) => "serialization",
331            Self::InvalidMethod(_) => "invalid-method",
332            Self::AuthMethodMismatch { .. } => "auth-method-mismatch",
333            Self::Validation(_) => "validation",
334            Self::InvalidUrl(_) => "invalid-url",
335            Self::InvalidPathParam { .. } => "invalid-path-param",
336            Self::Internal(_) => "internal",
337            Self::EnvelopeAlreadyEmitted { .. } => "confirmation-required",
338        }
339    }
340
341    /// Returns the structured exit code for this error.
342    ///
343    /// Pattern-matches on `Api { status, .. }` directly for HTTP errors,
344    /// preserves string-scanning for `Http` transport errors (no structured
345    /// status available), and maps `Validation` to `EXIT_GENERAL_ERROR`.
346    #[must_use]
347    pub fn exit_code(&self) -> i32 {
348        match self {
349            Self::Auth(_) | Self::TokenStore(_) => EXIT_AUTH_REQUIRED,
350            Self::Api { status: 401, .. } => EXIT_AUTH_REQUIRED,
351            Self::Api { status: 429, .. } => EXIT_RATE_LIMITED,
352            Self::Api { status: 404, .. } => EXIT_NOT_FOUND,
353            Self::Http(msg) if msg.contains("401") || msg.contains("Unauthorized") => {
354                EXIT_AUTH_REQUIRED
355            }
356            Self::Http(msg) if msg.contains("429") => EXIT_RATE_LIMITED,
357            Self::Http(msg) if msg.contains("404") => EXIT_NOT_FOUND,
358            Self::Io(_) => EXIT_NETWORK_ERROR,
359            Self::Validation(_)
360            | Self::InvalidUrl(_)
361            | Self::InvalidPathParam { .. }
362            | Self::Internal(_) => EXIT_GENERAL_ERROR,
363            Self::AuthMethodMismatch { .. } => EXIT_AUTH_MISMATCH,
364            Self::EnvelopeAlreadyEmitted { exit_code } => *exit_code,
365            _ => EXIT_GENERAL_ERROR,
366        }
367    }
368}
369
370impl From<reqwest::Error> for XurlError {
371    fn from(err: reqwest::Error) -> Self {
372        Self::Http(err.to_string())
373    }
374}
375
376impl From<std::io::Error> for XurlError {
377    fn from(err: std::io::Error) -> Self {
378        Self::Io(err.to_string())
379    }
380}
381
382impl From<serde_json::Error> for XurlError {
383    fn from(err: serde_json::Error) -> Self {
384        Self::Json(err.to_string())
385    }
386}
387
388impl From<serde_yaml::Error> for XurlError {
389    fn from(err: serde_yaml::Error) -> Self {
390        Self::Json(err.to_string())
391    }
392}
393
394impl From<url::ParseError> for XurlError {
395    fn from(err: url::ParseError) -> Self {
396        Self::Http(err.to_string())
397    }
398}
399
400/// Convenience alias used throughout the crate.
401pub type Result<T> = std::result::Result<T, XurlError>;
402
403// ── Exit codes ─────────────────────────────────────────────────────
404
405/// Structured exit codes for machine-readable error handling.
406///
407/// Follows the sysexits-style matrix from the agent-native CLI envelope
408/// pattern (corpus doc #1):
409/// - `0` (`EXIT_SUCCESS`): success.
410/// - `1` (`EXIT_GENERAL_ERROR`): general / user-recoverable error.
411/// - `2` (`EXIT_USAGE_ERROR`): clap usage error (invalid args). Not surfaced
412///   here; emitted directly by the runner on parse failure.
413/// - `3` (`EXIT_RATE_LIMITED`): API rate limit hit — agent should back off.
414/// - `4` (`EXIT_NOT_FOUND`): resource not found.
415/// - `5` (`EXIT_NETWORK_ERROR`): network / connectivity issue.
416/// - `77` (`EXIT_AUTH_REQUIRED`): authentication required. Matches sysexits
417///   `EX_NOPERM`; disambiguates from clap `EX_USAGE` (2). Behavior change in
418///   v1.3.0 — auth-required errors were previously exit `2`.
419/// - `2` (`EXIT_AUTH_MISMATCH`): auth method mismatch — the user supplied
420///   `--auth X` for an endpoint that does not accept `X`. Distinct from
421///   `EXIT_AUTH_REQUIRED` (missing credential) — this signals a *wrong*
422///   credential request that's fixable by changing `--auth`. Shares the
423///   `EX_USAGE` numeric value with clap because both are usage faults.
424#[allow(dead_code)] // Public library API — used by consumers
425pub const EXIT_SUCCESS: i32 = 0;
426/// General / user-recoverable error. Sysexits default for anything not
427/// covered by a more specific code.
428#[allow(dead_code)] // Public library API — used by consumers
429pub const EXIT_GENERAL_ERROR: i32 = 1;
430/// Auth method mismatch. `EX_USAGE` from sysexits — `2`.
431///
432/// Surfaces when `--auth X` is in the user's invocation and the endpoint's
433/// matrix entry does not accept `X`. Also surfaces on the auto-detect
434/// empty-intersection path when no stored credential on the active app
435/// satisfies the endpoint. Distinct from [`EXIT_AUTH_REQUIRED`] (= `77`,
436/// missing credential).
437#[allow(dead_code)] // Public library API — used by consumers
438pub const EXIT_AUTH_MISMATCH: i32 = 2;
439/// Authentication required. `EX_NOPERM` from sysexits — `77`.
440///
441/// **Behavior change in v1.3.0:** auth-required errors moved from exit `2`
442/// to `77` so the code unambiguously distinguishes auth failures from clap
443/// usage errors (which keep `EX_USAGE` = `2`).
444#[allow(dead_code)] // Public library API — used by consumers
445pub const EXIT_AUTH_REQUIRED: i32 = 77;
446/// API rate limit hit (HTTP 429). Agents should back off and retry per
447/// the response's rate-limit headers.
448#[allow(dead_code)] // Public library API — used by consumers
449pub const EXIT_RATE_LIMITED: i32 = 3;
450/// Resource not found (HTTP 404).
451#[allow(dead_code)] // Public library API — used by consumers
452pub const EXIT_NOT_FOUND: i32 = 4;
453/// Network / connectivity issue. Surfaces for non-401/404/429 HTTP errors
454/// and for `reqwest` transport failures (DNS, TLS, timeout).
455#[allow(dead_code)] // Public library API — used by consumers
456pub const EXIT_NETWORK_ERROR: i32 = 5;
457
458/// Maps an [`XurlError`] to a structured exit code.
459///
460/// Free-function shim delegating to [`XurlError::exit_code`].
461#[allow(dead_code)] // Public library API — used by consumers
462#[must_use]
463pub fn exit_code_for_error(e: &XurlError) -> i32 {
464    e.exit_code()
465}