Skip to main content

cli_engine/
error.rs

1use std::borrow::Cow;
2
3use thiserror::Error;
4
5use crate::NextAction;
6
7/// Crate-wide result type.
8pub type Result<T> = std::result::Result<T, CliCoreError>;
9
10/// Error trait for values that carry a process exit code.
11pub trait ExitCoder {
12    /// Returns the process-style exit code for the error.
13    fn exit_code(&self) -> i32;
14}
15
16/// Error trait for values that carry structured output-envelope metadata.
17pub trait DetailedError: std::error::Error {
18    /// Stable error code.
19    fn error_code(&self) -> Cow<'static, str>;
20    /// Optional backend/system id.
21    fn error_system(&self) -> Option<Cow<'static, str>>;
22    /// Optional backend request id.
23    fn error_request_id(&self) -> Option<Cow<'static, str>>;
24    /// Optional recovery hint for the envelope's top-level `fix` (defaults to [`None`]).
25    fn error_fix(&self) -> Option<Cow<'static, str>> {
26        None
27    }
28    /// Structured follow-up actions for the envelope's `next_actions` (defaults to empty).
29    fn error_next_actions(&self) -> Vec<NextAction> {
30        Vec::new()
31    }
32}
33
34/// Framework error type.
35#[derive(Debug, Error)]
36pub enum CliCoreError {
37    /// Requested auth provider has not been registered.
38    #[error("auth: no provider registered with name {0:?}")]
39    MissingAuthProvider(String),
40    /// Auth provider failed.
41    #[error("auth: provider {provider:?}: {source}")]
42    AuthProvider {
43        /// Provider name.
44        provider: String,
45        /// Source error.
46        #[source]
47        source: Box<dyn std::error::Error + Send + Sync>,
48    },
49    /// Output format is not supported.
50    #[error("invalid output format {0:?}: must be one of toon, json, human")]
51    InvalidOutputFormat(String),
52    /// Plain message error.
53    #[error("{0}")]
54    Message(String),
55    /// Structured message with explicit envelope metadata.
56    #[error("{message}")]
57    SystemMessage {
58        /// Error message.
59        message: String,
60        /// Backend/system id.
61        system: String,
62        /// Stable error code.
63        code: String,
64        /// Optional request id.
65        request_id: String,
66    },
67    /// Wrapped source error with backend/system attribution.
68    #[error("{source}")]
69    System {
70        /// Backend/system id.
71        system: String,
72        /// Source error.
73        #[source]
74        source: Box<dyn std::error::Error + Send + Sync>,
75    },
76    /// Wrapped source error with structured metadata captured up front.
77    #[error("{source}")]
78    Detailed {
79        /// Stable error code.
80        code: String,
81        /// Backend/system id.
82        system: String,
83        /// Optional request id.
84        request_id: String,
85        /// Structured follow-up actions captured from the source's
86        /// [`DetailedError::error_next_actions`] at wrap time — the source is
87        /// erased to `Box<dyn Error>` immediately below, so this can't be
88        /// recovered later by downcasting.
89        next_actions: Vec<NextAction>,
90        /// Source error.
91        #[source]
92        source: Box<dyn std::error::Error + Send + Sync>,
93    },
94    /// Wrapped source error with explicit process exit code.
95    #[error("{source}")]
96    ExitCode {
97        /// Process-style exit code.
98        code: i32,
99        /// Source error.
100        #[source]
101        source: Box<dyn std::error::Error + Send + Sync>,
102    },
103    /// Wrapped source error with a recovery hint for the output envelope.
104    #[error("{source}")]
105    Fix {
106        /// Recovery guidance shown as the envelope's top-level `fix`.
107        fix: String,
108        /// Source error.
109        #[source]
110        source: Box<dyn std::error::Error + Send + Sync>,
111    },
112    /// IO error.
113    #[error(transparent)]
114    Io(#[from] std::io::Error),
115    /// JSON serialization or decoding error.
116    #[error(transparent)]
117    Json(#[from] serde_json::Error),
118    /// Structured HTTP transport error.
119    #[error(transparent)]
120    Transport(#[from] crate::transport::Error),
121    /// An [`EnvConfig`](crate::env_config::EnvConfig) struct failed to
122    /// assemble from its [`SourceChain`](crate::env_config::SourceChain).
123    #[error(transparent)]
124    EnvConfig(#[from] crate::env_config::EnvConfigError),
125}
126
127impl CliCoreError {
128    /// Creates a plain message error.
129    #[must_use]
130    pub fn message(message: impl Into<String>) -> Self {
131        Self::Message(message.into())
132    }
133
134    /// Creates a structured message attributed to a backend/system id.
135    #[must_use]
136    pub fn message_for_system(system: impl Into<String>, message: impl Into<String>) -> Self {
137        Self::SystemMessage {
138            message: message.into(),
139            system: system.into(),
140            code: "ERROR".to_owned(),
141            request_id: String::new(),
142        }
143    }
144
145    /// Wraps a source error with backend/system attribution.
146    #[must_use]
147    pub fn with_system(
148        system: impl Into<String>,
149        source: impl std::error::Error + Send + Sync + 'static,
150    ) -> Self {
151        Self::System {
152            system: system.into(),
153            source: Box::new(source),
154        }
155    }
156
157    /// Wraps a source error with an explicit process exit code.
158    #[must_use]
159    pub fn with_exit_code(
160        code: i32,
161        source: impl std::error::Error + Send + Sync + 'static,
162    ) -> Self {
163        Self::ExitCode {
164            code,
165            source: Box::new(source),
166        }
167    }
168
169    /// Wraps a source error with a recovery hint for the output envelope.
170    ///
171    /// Empty hints do not wrap: a [`CliCoreError`] source is returned as-is.
172    #[must_use]
173    pub fn with_fix(
174        fix: impl Into<String>,
175        source: impl std::error::Error + Send + Sync + 'static,
176    ) -> Self {
177        let fix = fix.into();
178        if fix.is_empty() {
179            let source: Box<dyn std::error::Error + Send + Sync> = Box::new(source);
180            return match source.downcast::<Self>() {
181                Ok(inner) => *inner,
182                Err(source) => Self::Message(source.to_string()),
183            };
184        }
185        Self::Fix {
186            fix,
187            source: Box::new(source),
188        }
189    }
190
191    /// Captures structured metadata from a detailed source error.
192    #[must_use]
193    pub fn with_detailed_error(source: impl DetailedError + Send + Sync + 'static) -> Self {
194        let code = source.error_code().into_owned();
195        let system = source
196            .error_system()
197            .map_or_else(String::new, Cow::into_owned);
198        let request_id = source
199            .error_request_id()
200            .map_or_else(String::new, Cow::into_owned);
201        let fix = source.error_fix().map_or_else(String::new, Cow::into_owned);
202        let next_actions = source.error_next_actions();
203        Self::with_fix(
204            fix,
205            Self::Detailed {
206                code,
207                system,
208                request_id,
209                next_actions,
210                source: Box::new(source),
211            },
212        )
213    }
214
215    /// Reports whether this error originates from credential resolution.
216    ///
217    /// True for [`MissingAuthProvider`](Self::MissingAuthProvider) and
218    /// [`AuthProvider`](Self::AuthProvider), including when those variants are
219    /// wrapped by [`Fix`](Self::Fix) or [`ExitCode`](Self::ExitCode). The engine
220    /// uses this to classify a command outcome as `auth-error` rather than a
221    /// generic command error, based on the error a handler actually returns — so
222    /// a handler that swallows a resolution failure and then fails for another
223    /// reason is not misclassified.
224    #[must_use]
225    pub fn is_auth(&self) -> bool {
226        match self {
227            Self::MissingAuthProvider(_) | Self::AuthProvider { .. } => true,
228            Self::ExitCode { source, .. } | Self::Fix { source, .. } => {
229                source.downcast_ref::<Self>().is_some_and(Self::is_auth)
230            }
231            _ => false,
232        }
233    }
234
235    /// Returns backend/system attribution when the error carries one.
236    ///
237    /// [`Fix`](Self::Fix) / [`ExitCode`](Self::ExitCode) wrappers delegate to their source.
238    #[must_use]
239    pub fn system(&self) -> Option<&str> {
240        match self {
241            Self::SystemMessage { system, .. }
242            | Self::System { system, .. }
243            | Self::Detailed { system, .. }
244                if !system.is_empty() =>
245            {
246                Some(system)
247            }
248            Self::ExitCode { source, .. } | Self::Fix { source, .. } => {
249                source.downcast_ref::<Self>().and_then(Self::system)
250            }
251            Self::MissingAuthProvider(_)
252            | Self::AuthProvider { .. }
253            | Self::InvalidOutputFormat(_)
254            | Self::Message(_)
255            | Self::SystemMessage { .. }
256            | Self::System { .. }
257            | Self::Detailed { .. }
258            | Self::Io(_)
259            | Self::Json(_)
260            | Self::Transport(_)
261            | Self::EnvConfig(_) => None,
262        }
263    }
264}
265
266impl ExitCoder for CliCoreError {
267    fn exit_code(&self) -> i32 {
268        exit_code_for_error(self)
269    }
270}
271
272/// Returns the exit code carried by an [`ExitCoder`].
273#[must_use]
274pub fn exit_code_for_exit_coder(err: &dyn ExitCoder) -> i32 {
275    err.exit_code()
276}
277
278/// Maps an error chain to the framework's process-style exit code.
279#[must_use]
280pub fn exit_code_for_error(err: &(dyn std::error::Error + 'static)) -> i32 {
281    let mut current = Some(err);
282    while let Some(error) = current {
283        if let Some(CliCoreError::ExitCode { code, .. }) = error.downcast_ref::<CliCoreError>() {
284            return *code;
285        }
286        current = error.source();
287    }
288
289    let mut current = Some(err);
290    while let Some(error) = current {
291        if let Some(cli_err) = error.downcast_ref::<CliCoreError>() {
292            match cli_err {
293                CliCoreError::MissingAuthProvider(_) | CliCoreError::AuthProvider { .. } => {
294                    return 2;
295                }
296                CliCoreError::InvalidOutputFormat(_) => return 3,
297                CliCoreError::System { .. }
298                | CliCoreError::Detailed { .. }
299                | CliCoreError::ExitCode { .. }
300                | CliCoreError::Fix { .. }
301                | CliCoreError::Message(_)
302                | CliCoreError::SystemMessage { .. }
303                | CliCoreError::Io(_)
304                | CliCoreError::Json(_)
305                | CliCoreError::Transport(_)
306                | CliCoreError::EnvConfig(_) => {}
307            }
308        }
309        current = error.source();
310    }
311
312    let msg = err.to_string().to_lowercase();
313    if msg.contains("auth") {
314        2
315    } else if msg.contains("validation") || msg.contains("invalid") {
316        3
317    } else if msg.contains("not found") {
318        4
319    } else if msg.contains("permission") || msg.contains("forbidden") {
320        5
321    } else if msg.contains("denied") {
322        6
323    } else {
324        1
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn system_walks_through_fix_and_exit_code_wrappers() {
334        let err = CliCoreError::with_exit_code(
335            2,
336            CliCoreError::with_fix(
337                "Run auth login",
338                CliCoreError::message_for_system("auth", "not logged in"),
339            ),
340        );
341        assert_eq!(err.system(), Some("auth"));
342    }
343
344    #[test]
345    fn with_detailed_error_fix_preserves_system() {
346        #[derive(Debug, thiserror::Error)]
347        #[error("not logged in")]
348        struct AuthRequired;
349
350        impl DetailedError for AuthRequired {
351            fn error_code(&self) -> Cow<'static, str> {
352                Cow::Borrowed("AUTH_REQUIRED")
353            }
354
355            fn error_system(&self) -> Option<Cow<'static, str>> {
356                Some(Cow::Borrowed("auth"))
357            }
358
359            fn error_request_id(&self) -> Option<Cow<'static, str>> {
360                None
361            }
362
363            fn error_fix(&self) -> Option<Cow<'static, str>> {
364                Some(Cow::Borrowed("Run auth login"))
365            }
366        }
367
368        let err = CliCoreError::with_detailed_error(AuthRequired);
369        assert!(matches!(err, CliCoreError::Fix { .. }));
370        assert_eq!(err.system(), Some("auth"));
371    }
372
373    #[test]
374    fn with_detailed_error_captures_next_actions_before_erasure() {
375        #[derive(Debug, thiserror::Error)]
376        #[error("'/businesses' matches 2 operations")]
377        struct Ambiguous;
378
379        impl DetailedError for Ambiguous {
380            fn error_code(&self) -> Cow<'static, str> {
381                Cow::Borrowed("AMBIGUOUS_MATCH")
382            }
383
384            fn error_system(&self) -> Option<Cow<'static, str>> {
385                None
386            }
387
388            fn error_request_id(&self) -> Option<Cow<'static, str>> {
389                None
390            }
391
392            fn error_next_actions(&self) -> Vec<NextAction> {
393                vec![NextAction::new(
394                    "api operation get /businesses --method GET",
395                    "Get all businesses",
396                )]
397            }
398        }
399
400        let err = CliCoreError::with_detailed_error(Ambiguous);
401        assert!(matches!(err, CliCoreError::Detailed { .. }));
402        let CliCoreError::Detailed { next_actions, .. } = &err else {
403            unreachable!("just asserted this is Detailed");
404        };
405        assert_eq!(next_actions.len(), 1);
406        assert_eq!(
407            next_actions[0].command,
408            "api operation get /businesses --method GET"
409        );
410    }
411
412    #[test]
413    fn empty_with_fix_does_not_wrap() {
414        let inner = CliCoreError::message_for_system("auth", "not logged in");
415        let err = CliCoreError::with_fix("", inner);
416        assert!(matches!(err, CliCoreError::SystemMessage { .. }));
417        assert_eq!(err.system(), Some("auth"));
418        assert!(!matches!(err, CliCoreError::Fix { .. }));
419    }
420
421    #[test]
422    fn is_auth_walks_through_fix_and_exit_code_wrappers() {
423        let err = CliCoreError::with_exit_code(
424            2,
425            CliCoreError::with_fix(
426                "Run auth login",
427                CliCoreError::MissingAuthProvider("primary".to_owned()),
428            ),
429        );
430        assert!(err.is_auth());
431        assert!(!CliCoreError::with_fix("hint", CliCoreError::message("boom")).is_auth());
432    }
433}