use std::borrow::Cow;
use thiserror::Error;
use crate::NextAction;
pub type Result<T> = std::result::Result<T, CliCoreError>;
pub trait ExitCoder {
fn exit_code(&self) -> i32;
}
pub trait DetailedError: std::error::Error {
fn error_code(&self) -> Cow<'static, str>;
fn error_system(&self) -> Option<Cow<'static, str>>;
fn error_request_id(&self) -> Option<Cow<'static, str>>;
fn error_fix(&self) -> Option<Cow<'static, str>> {
None
}
fn error_next_actions(&self) -> Vec<NextAction> {
Vec::new()
}
}
#[derive(Debug, Error)]
pub enum CliCoreError {
#[error("auth: no provider registered with name {0:?}")]
MissingAuthProvider(String),
#[error("auth: provider {provider:?}: {source}")]
AuthProvider {
provider: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("invalid output format {0:?}: must be one of toon, json, human")]
InvalidOutputFormat(String),
#[error("{0}")]
Message(String),
#[error("{message}")]
SystemMessage {
message: String,
system: String,
code: String,
request_id: String,
},
#[error("{source}")]
System {
system: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("{source}")]
Detailed {
code: String,
system: String,
request_id: String,
next_actions: Vec<NextAction>,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("{source}")]
ExitCode {
code: i32,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("{source}")]
Fix {
fix: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Transport(#[from] crate::transport::Error),
#[error(transparent)]
EnvConfig(#[from] crate::env_config::EnvConfigError),
}
impl CliCoreError {
#[must_use]
pub fn message(message: impl Into<String>) -> Self {
Self::Message(message.into())
}
#[must_use]
pub fn message_for_system(system: impl Into<String>, message: impl Into<String>) -> Self {
Self::SystemMessage {
message: message.into(),
system: system.into(),
code: "ERROR".to_owned(),
request_id: String::new(),
}
}
#[must_use]
pub fn with_system(
system: impl Into<String>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self::System {
system: system.into(),
source: Box::new(source),
}
}
#[must_use]
pub fn with_exit_code(
code: i32,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self::ExitCode {
code,
source: Box::new(source),
}
}
#[must_use]
pub fn with_fix(
fix: impl Into<String>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
let fix = fix.into();
if fix.is_empty() {
let source: Box<dyn std::error::Error + Send + Sync> = Box::new(source);
return match source.downcast::<Self>() {
Ok(inner) => *inner,
Err(source) => Self::Message(source.to_string()),
};
}
Self::Fix {
fix,
source: Box::new(source),
}
}
#[must_use]
pub fn with_detailed_error(source: impl DetailedError + Send + Sync + 'static) -> Self {
let code = source.error_code().into_owned();
let system = source
.error_system()
.map_or_else(String::new, Cow::into_owned);
let request_id = source
.error_request_id()
.map_or_else(String::new, Cow::into_owned);
let fix = source.error_fix().map_or_else(String::new, Cow::into_owned);
let next_actions = source.error_next_actions();
Self::with_fix(
fix,
Self::Detailed {
code,
system,
request_id,
next_actions,
source: Box::new(source),
},
)
}
#[must_use]
pub fn is_auth(&self) -> bool {
match self {
Self::MissingAuthProvider(_) | Self::AuthProvider { .. } => true,
Self::ExitCode { source, .. } | Self::Fix { source, .. } => {
source.downcast_ref::<Self>().is_some_and(Self::is_auth)
}
_ => false,
}
}
#[must_use]
pub fn system(&self) -> Option<&str> {
match self {
Self::SystemMessage { system, .. }
| Self::System { system, .. }
| Self::Detailed { system, .. }
if !system.is_empty() =>
{
Some(system)
}
Self::ExitCode { source, .. } | Self::Fix { source, .. } => {
source.downcast_ref::<Self>().and_then(Self::system)
}
Self::MissingAuthProvider(_)
| Self::AuthProvider { .. }
| Self::InvalidOutputFormat(_)
| Self::Message(_)
| Self::SystemMessage { .. }
| Self::System { .. }
| Self::Detailed { .. }
| Self::Io(_)
| Self::Json(_)
| Self::Transport(_)
| Self::EnvConfig(_) => None,
}
}
}
impl ExitCoder for CliCoreError {
fn exit_code(&self) -> i32 {
exit_code_for_error(self)
}
}
#[must_use]
pub fn exit_code_for_exit_coder(err: &dyn ExitCoder) -> i32 {
err.exit_code()
}
#[must_use]
pub fn exit_code_for_error(err: &(dyn std::error::Error + 'static)) -> i32 {
let mut current = Some(err);
while let Some(error) = current {
if let Some(CliCoreError::ExitCode { code, .. }) = error.downcast_ref::<CliCoreError>() {
return *code;
}
current = error.source();
}
let mut current = Some(err);
while let Some(error) = current {
if let Some(cli_err) = error.downcast_ref::<CliCoreError>() {
match cli_err {
CliCoreError::MissingAuthProvider(_) | CliCoreError::AuthProvider { .. } => {
return 2;
}
CliCoreError::InvalidOutputFormat(_) => return 3,
CliCoreError::System { .. }
| CliCoreError::Detailed { .. }
| CliCoreError::ExitCode { .. }
| CliCoreError::Fix { .. }
| CliCoreError::Message(_)
| CliCoreError::SystemMessage { .. }
| CliCoreError::Io(_)
| CliCoreError::Json(_)
| CliCoreError::Transport(_)
| CliCoreError::EnvConfig(_) => {}
}
}
current = error.source();
}
let msg = err.to_string().to_lowercase();
if msg.contains("auth") {
2
} else if msg.contains("validation") || msg.contains("invalid") {
3
} else if msg.contains("not found") {
4
} else if msg.contains("permission") || msg.contains("forbidden") {
5
} else if msg.contains("denied") {
6
} else {
1
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn system_walks_through_fix_and_exit_code_wrappers() {
let err = CliCoreError::with_exit_code(
2,
CliCoreError::with_fix(
"Run auth login",
CliCoreError::message_for_system("auth", "not logged in"),
),
);
assert_eq!(err.system(), Some("auth"));
}
#[test]
fn with_detailed_error_fix_preserves_system() {
#[derive(Debug, thiserror::Error)]
#[error("not logged in")]
struct AuthRequired;
impl DetailedError for AuthRequired {
fn error_code(&self) -> Cow<'static, str> {
Cow::Borrowed("AUTH_REQUIRED")
}
fn error_system(&self) -> Option<Cow<'static, str>> {
Some(Cow::Borrowed("auth"))
}
fn error_request_id(&self) -> Option<Cow<'static, str>> {
None
}
fn error_fix(&self) -> Option<Cow<'static, str>> {
Some(Cow::Borrowed("Run auth login"))
}
}
let err = CliCoreError::with_detailed_error(AuthRequired);
assert!(matches!(err, CliCoreError::Fix { .. }));
assert_eq!(err.system(), Some("auth"));
}
#[test]
fn with_detailed_error_captures_next_actions_before_erasure() {
#[derive(Debug, thiserror::Error)]
#[error("'/businesses' matches 2 operations")]
struct Ambiguous;
impl DetailedError for Ambiguous {
fn error_code(&self) -> Cow<'static, str> {
Cow::Borrowed("AMBIGUOUS_MATCH")
}
fn error_system(&self) -> Option<Cow<'static, str>> {
None
}
fn error_request_id(&self) -> Option<Cow<'static, str>> {
None
}
fn error_next_actions(&self) -> Vec<NextAction> {
vec![NextAction::new(
"api operation get /businesses --method GET",
"Get all businesses",
)]
}
}
let err = CliCoreError::with_detailed_error(Ambiguous);
assert!(matches!(err, CliCoreError::Detailed { .. }));
let CliCoreError::Detailed { next_actions, .. } = &err else {
unreachable!("just asserted this is Detailed");
};
assert_eq!(next_actions.len(), 1);
assert_eq!(
next_actions[0].command,
"api operation get /businesses --method GET"
);
}
#[test]
fn empty_with_fix_does_not_wrap() {
let inner = CliCoreError::message_for_system("auth", "not logged in");
let err = CliCoreError::with_fix("", inner);
assert!(matches!(err, CliCoreError::SystemMessage { .. }));
assert_eq!(err.system(), Some("auth"));
assert!(!matches!(err, CliCoreError::Fix { .. }));
}
#[test]
fn is_auth_walks_through_fix_and_exit_code_wrappers() {
let err = CliCoreError::with_exit_code(
2,
CliCoreError::with_fix(
"Run auth login",
CliCoreError::MissingAuthProvider("primary".to_owned()),
),
);
assert!(err.is_auth());
assert!(!CliCoreError::with_fix("hint", CliCoreError::message("boom")).is_auth());
}
}