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}