Skip to main content

cli_engine/
error.rs

1use std::borrow::Cow;
2
3use thiserror::Error;
4
5/// Crate-wide result type.
6pub type Result<T> = std::result::Result<T, CliCoreError>;
7
8/// Error trait for values that carry a process exit code.
9pub trait ExitCoder {
10    /// Returns the process-style exit code for the error.
11    fn exit_code(&self) -> i32;
12}
13
14/// Error trait for values that carry structured output-envelope metadata.
15pub trait DetailedError: std::error::Error {
16    /// Stable error code.
17    fn error_code(&self) -> Cow<'static, str>;
18    /// Optional backend/system id.
19    fn error_system(&self) -> Option<Cow<'static, str>>;
20    /// Optional backend request id.
21    fn error_request_id(&self) -> Option<Cow<'static, str>>;
22}
23
24/// Framework error type.
25#[derive(Debug, Error)]
26pub enum CliCoreError {
27    /// Requested auth provider has not been registered.
28    #[error("auth: no provider registered with name {0:?}")]
29    MissingAuthProvider(String),
30    /// Auth provider failed.
31    #[error("auth: provider {provider:?}: {source}")]
32    AuthProvider {
33        /// Provider name.
34        provider: String,
35        /// Source error.
36        #[source]
37        source: Box<dyn std::error::Error + Send + Sync>,
38    },
39    /// Output format is not supported.
40    #[error("invalid output format {0:?}: must be one of toon, json, human")]
41    InvalidOutputFormat(String),
42    /// Plain message error.
43    #[error("{0}")]
44    Message(String),
45    /// Structured message with explicit envelope metadata.
46    #[error("{message}")]
47    SystemMessage {
48        /// Error message.
49        message: String,
50        /// Backend/system id.
51        system: String,
52        /// Stable error code.
53        code: String,
54        /// Optional request id.
55        request_id: String,
56    },
57    /// Wrapped source error with backend/system attribution.
58    #[error("{source}")]
59    System {
60        /// Backend/system id.
61        system: String,
62        /// Source error.
63        #[source]
64        source: Box<dyn std::error::Error + Send + Sync>,
65    },
66    /// Wrapped source error with structured metadata captured up front.
67    #[error("{source}")]
68    Detailed {
69        /// Stable error code.
70        code: String,
71        /// Backend/system id.
72        system: String,
73        /// Optional request id.
74        request_id: String,
75        /// Source error.
76        #[source]
77        source: Box<dyn std::error::Error + Send + Sync>,
78    },
79    /// Wrapped source error with explicit process exit code.
80    #[error("{source}")]
81    ExitCode {
82        /// Process-style exit code.
83        code: i32,
84        /// Source error.
85        #[source]
86        source: Box<dyn std::error::Error + Send + Sync>,
87    },
88    /// IO error.
89    #[error(transparent)]
90    Io(#[from] std::io::Error),
91    /// JSON serialization or decoding error.
92    #[error(transparent)]
93    Json(#[from] serde_json::Error),
94    /// Structured HTTP transport error.
95    #[error(transparent)]
96    Transport(#[from] crate::transport::Error),
97}
98
99impl CliCoreError {
100    /// Creates a plain message error.
101    #[must_use]
102    pub fn message(message: impl Into<String>) -> Self {
103        Self::Message(message.into())
104    }
105
106    /// Creates a structured message attributed to a backend/system id.
107    #[must_use]
108    pub fn message_for_system(system: impl Into<String>, message: impl Into<String>) -> Self {
109        Self::SystemMessage {
110            message: message.into(),
111            system: system.into(),
112            code: "ERROR".to_owned(),
113            request_id: String::new(),
114        }
115    }
116
117    /// Wraps a source error with backend/system attribution.
118    #[must_use]
119    pub fn with_system(
120        system: impl Into<String>,
121        source: impl std::error::Error + Send + Sync + 'static,
122    ) -> Self {
123        Self::System {
124            system: system.into(),
125            source: Box::new(source),
126        }
127    }
128
129    /// Wraps a source error with an explicit process exit code.
130    #[must_use]
131    pub fn with_exit_code(
132        code: i32,
133        source: impl std::error::Error + Send + Sync + 'static,
134    ) -> Self {
135        Self::ExitCode {
136            code,
137            source: Box::new(source),
138        }
139    }
140
141    /// Captures structured metadata from a detailed source error.
142    #[must_use]
143    pub fn with_detailed_error(source: impl DetailedError + Send + Sync + 'static) -> Self {
144        let code = source.error_code().into_owned();
145        let system = source
146            .error_system()
147            .map_or_else(String::new, Cow::into_owned);
148        let request_id = source
149            .error_request_id()
150            .map_or_else(String::new, Cow::into_owned);
151        Self::Detailed {
152            code,
153            system,
154            request_id,
155            source: Box::new(source),
156        }
157    }
158
159    /// Reports whether this error originates from credential resolution.
160    ///
161    /// True for [`MissingAuthProvider`](Self::MissingAuthProvider) and
162    /// [`AuthProvider`](Self::AuthProvider). The engine uses this to classify a
163    /// command outcome as `auth-error` rather than a generic command error, based
164    /// on the error a handler actually returns — so a handler that swallows a
165    /// resolution failure and then fails for another reason is not misclassified.
166    #[must_use]
167    pub fn is_auth(&self) -> bool {
168        matches!(
169            self,
170            Self::MissingAuthProvider(_) | Self::AuthProvider { .. }
171        )
172    }
173
174    /// Returns backend/system attribution when the error carries one.
175    #[must_use]
176    pub fn system(&self) -> Option<&str> {
177        match self {
178            Self::SystemMessage { system, .. }
179            | Self::System { system, .. }
180            | Self::Detailed { system, .. }
181                if !system.is_empty() =>
182            {
183                Some(system)
184            }
185            Self::MissingAuthProvider(_)
186            | Self::AuthProvider { .. }
187            | Self::InvalidOutputFormat(_)
188            | Self::Message(_)
189            | Self::SystemMessage { .. }
190            | Self::System { .. }
191            | Self::Detailed { .. }
192            | Self::ExitCode { .. }
193            | Self::Io(_)
194            | Self::Json(_)
195            | Self::Transport(_) => None,
196        }
197    }
198}
199
200impl ExitCoder for CliCoreError {
201    fn exit_code(&self) -> i32 {
202        exit_code_for_error(self)
203    }
204}
205
206/// Returns the exit code carried by an [`ExitCoder`].
207#[must_use]
208pub fn exit_code_for_exit_coder(err: &dyn ExitCoder) -> i32 {
209    err.exit_code()
210}
211
212/// Maps an error chain to the framework's process-style exit code.
213#[must_use]
214pub fn exit_code_for_error(err: &(dyn std::error::Error + 'static)) -> i32 {
215    let mut current = Some(err);
216    while let Some(error) = current {
217        if let Some(CliCoreError::ExitCode { code, .. }) = error.downcast_ref::<CliCoreError>() {
218            return *code;
219        }
220        current = error.source();
221    }
222
223    let mut current = Some(err);
224    while let Some(error) = current {
225        if let Some(cli_err) = error.downcast_ref::<CliCoreError>() {
226            match cli_err {
227                CliCoreError::MissingAuthProvider(_) | CliCoreError::AuthProvider { .. } => {
228                    return 2;
229                }
230                CliCoreError::InvalidOutputFormat(_) => return 3,
231                CliCoreError::System { .. }
232                | CliCoreError::Detailed { .. }
233                | CliCoreError::ExitCode { .. }
234                | CliCoreError::Message(_)
235                | CliCoreError::SystemMessage { .. }
236                | CliCoreError::Io(_)
237                | CliCoreError::Json(_)
238                | CliCoreError::Transport(_) => {}
239            }
240        }
241        current = error.source();
242    }
243
244    let msg = err.to_string().to_lowercase();
245    if msg.contains("auth") {
246        2
247    } else if msg.contains("validation") || msg.contains("invalid") {
248        3
249    } else if msg.contains("not found") {
250        4
251    } else if msg.contains("permission") || msg.contains("forbidden") {
252        5
253    } else if msg.contains("denied") {
254        6
255    } else {
256        1
257    }
258}