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}