1use std::borrow::Cow;
2
3use thiserror::Error;
4
5use crate::NextAction;
6
7pub type Result<T> = std::result::Result<T, CliCoreError>;
9
10pub trait ExitCoder {
12 fn exit_code(&self) -> i32;
14}
15
16pub trait DetailedError: std::error::Error {
18 fn error_code(&self) -> Cow<'static, str>;
20 fn error_system(&self) -> Option<Cow<'static, str>>;
22 fn error_request_id(&self) -> Option<Cow<'static, str>>;
24 fn error_fix(&self) -> Option<Cow<'static, str>> {
26 None
27 }
28 fn error_next_actions(&self) -> Vec<NextAction> {
30 Vec::new()
31 }
32}
33
34#[derive(Debug, Error)]
36pub enum CliCoreError {
37 #[error("auth: no provider registered with name {0:?}")]
39 MissingAuthProvider(String),
40 #[error("auth: provider {provider:?}: {source}")]
42 AuthProvider {
43 provider: String,
45 #[source]
47 source: Box<dyn std::error::Error + Send + Sync>,
48 },
49 #[error("invalid output format {0:?}: must be one of toon, json, human")]
51 InvalidOutputFormat(String),
52 #[error("{0}")]
54 Message(String),
55 #[error("{message}")]
57 SystemMessage {
58 message: String,
60 system: String,
62 code: String,
64 request_id: String,
66 },
67 #[error("{source}")]
69 System {
70 system: String,
72 #[source]
74 source: Box<dyn std::error::Error + Send + Sync>,
75 },
76 #[error("{source}")]
78 Detailed {
79 code: String,
81 system: String,
83 request_id: String,
85 next_actions: Vec<NextAction>,
90 #[source]
92 source: Box<dyn std::error::Error + Send + Sync>,
93 },
94 #[error("{source}")]
96 ExitCode {
97 code: i32,
99 #[source]
101 source: Box<dyn std::error::Error + Send + Sync>,
102 },
103 #[error("{source}")]
105 Fix {
106 fix: String,
108 #[source]
110 source: Box<dyn std::error::Error + Send + Sync>,
111 },
112 #[error(transparent)]
114 Io(#[from] std::io::Error),
115 #[error(transparent)]
117 Json(#[from] serde_json::Error),
118 #[error(transparent)]
120 Transport(#[from] crate::transport::Error),
121 #[error(transparent)]
124 EnvConfig(#[from] crate::env_config::EnvConfigError),
125}
126
127impl CliCoreError {
128 #[must_use]
130 pub fn message(message: impl Into<String>) -> Self {
131 Self::Message(message.into())
132 }
133
134 #[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 #[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 #[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 #[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 #[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 #[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 #[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#[must_use]
274pub fn exit_code_for_exit_coder(err: &dyn ExitCoder) -> i32 {
275 err.exit_code()
276}
277
278#[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}