use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ErrorDetail {
pub category: ErrorCategory,
pub code: ErrorCode,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_error: Option<String>,
pub recoverable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub suggestion: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub doc_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_chain: Option<Vec<String>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCategory {
Auth,
Flag,
Provider,
Network,
Unsupported,
}
impl ErrorCategory {
pub fn exit_code(self) -> i32 {
match self {
Self::Auth => 1,
Self::Flag => 2,
Self::Provider => 3,
Self::Network => 4,
Self::Unsupported => 5,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
AuthMissing,
AuthExpired,
AccessDenied,
QuerySyntax,
InvalidTimeRange,
MissingRequired,
InvalidFlag,
ConfigError,
BackendError,
RateLimited,
NotFound,
Timeout,
DnsError,
TlsError,
ConnectionError,
NotSupported,
}
#[derive(Debug, thiserror::Error)]
pub enum ObzError {
#[error("{message}")]
Provider {
code: ErrorCode,
message: String,
raw_error: Option<String>,
recoverable: bool,
suggestion: Option<String>,
doc_url: Option<String>,
},
#[error("authentication error: {message}")]
Auth {
code: ErrorCode,
message: String,
recoverable: bool,
suggestion: Option<String>,
},
#[error("invalid argument: {message}")]
InvalidArgument {
code: ErrorCode,
message: String,
suggestion: Option<String>,
},
#[error("network error: {message}")]
Network {
code: ErrorCode,
message: String,
recoverable: bool,
source_chain: Option<Vec<String>>,
},
#[error("{message}")]
Unsupported {
message: String,
provider: Option<String>,
suggestion: Option<String>,
},
}
impl ObzError {
pub fn to_error_detail(&self, provider: Option<&str>) -> ErrorDetail {
let mut detail = match self {
Self::Provider {
code,
message,
raw_error,
recoverable,
suggestion,
doc_url,
} => ErrorDetail {
category: ErrorCategory::Provider,
code: *code,
provider: None,
message: message.clone(),
raw_error: raw_error.clone(),
recoverable: *recoverable,
suggestion: suggestion.clone(),
doc_url: doc_url.clone(),
source_chain: None,
},
Self::Auth {
code,
message,
recoverable,
suggestion,
} => ErrorDetail {
category: ErrorCategory::Auth,
code: *code,
provider: None,
message: message.clone(),
raw_error: None,
recoverable: *recoverable,
suggestion: suggestion.clone(),
doc_url: None,
source_chain: None,
},
Self::InvalidArgument {
code,
message,
suggestion,
} => ErrorDetail {
category: ErrorCategory::Flag,
code: *code,
provider: None,
message: message.clone(),
raw_error: None,
recoverable: false,
suggestion: suggestion.clone(),
doc_url: None,
source_chain: None,
},
Self::Network {
code,
message,
recoverable,
source_chain,
} => ErrorDetail {
category: ErrorCategory::Network,
code: *code,
provider: None,
message: message.clone(),
raw_error: None,
recoverable: *recoverable,
suggestion: None,
doc_url: None,
source_chain: source_chain.clone(),
},
Self::Unsupported {
message,
provider,
suggestion,
} => ErrorDetail {
category: ErrorCategory::Unsupported,
code: ErrorCode::NotSupported,
provider: provider.clone(),
message: message.clone(),
raw_error: None,
recoverable: false,
suggestion: suggestion.clone(),
doc_url: None,
source_chain: None,
},
};
if detail.provider.is_none() {
detail.provider = provider.map(str::to_string);
}
detail
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_category_exit_codes() {
assert_eq!(ErrorCategory::Auth.exit_code(), 1);
assert_eq!(ErrorCategory::Flag.exit_code(), 2);
assert_eq!(ErrorCategory::Provider.exit_code(), 3);
assert_eq!(ErrorCategory::Network.exit_code(), 4);
assert_eq!(ErrorCategory::Unsupported.exit_code(), 5);
}
#[test]
fn test_error_category_serialization() {
assert_eq!(
serde_json::to_string(&ErrorCategory::Auth).unwrap(),
r#""auth""#
);
assert_eq!(
serde_json::to_string(&ErrorCategory::Provider).unwrap(),
r#""provider""#
);
assert_eq!(
serde_json::to_string(&ErrorCategory::Unsupported).unwrap(),
r#""unsupported""#
);
}
#[test]
fn test_error_code_config_error_serialization() {
assert_eq!(
serde_json::to_string(&ErrorCode::ConfigError).unwrap(),
r#""config_error""#
);
}
#[test]
fn test_config_error_to_detail() {
let err = ObzError::InvalidArgument {
code: ErrorCode::ConfigError,
message: "failed to parse config.yaml".to_string(),
suggestion: None,
};
let detail = err.to_error_detail(None);
assert_eq!(detail.category, ErrorCategory::Flag);
assert_eq!(detail.code, ErrorCode::ConfigError);
assert!(!detail.recoverable);
assert!(detail.source_chain.is_none());
}
#[test]
fn test_obz_error_to_detail() {
let err = ObzError::Provider {
code: ErrorCode::QuerySyntax,
message: "invalid expression".to_string(),
raw_error: Some("bad_data".to_string()),
recoverable: false,
suggestion: Some("Check your PromQL syntax".to_string()),
doc_url: None,
};
let detail = err.to_error_detail(None);
assert_eq!(detail.category, ErrorCategory::Provider);
assert_eq!(detail.code, ErrorCode::QuerySyntax);
assert!(!detail.recoverable);
}
#[test]
fn test_network_error_preserves_source_chain() {
let err = ObzError::Network {
code: ErrorCode::TlsError,
message: "TLS error: certificate verify failed".to_string(),
recoverable: false,
source_chain: Some(vec![
"rustls::Error::InvalidCertificate(UnknownIssuer)".to_string(),
"certificate not trusted: CA not in trust store".to_string(),
]),
};
let detail = err.to_error_detail(None);
assert_eq!(detail.category, ErrorCategory::Network);
assert_eq!(detail.code, ErrorCode::TlsError);
let chain = detail.source_chain.unwrap();
assert_eq!(chain.len(), 2);
assert!(chain[0].contains("UnknownIssuer"));
}
#[test]
fn test_source_chain_none_omitted_from_json() {
let detail = ErrorDetail {
category: ErrorCategory::Flag,
code: ErrorCode::MissingRequired,
provider: None,
message: "missing --provider".to_string(),
raw_error: None,
recoverable: false,
suggestion: None,
doc_url: None,
source_chain: None,
};
let json = serde_json::to_string(&detail).unwrap();
assert!(
!json.contains("source_chain"),
"None source_chain should be omitted: {json}"
);
}
#[test]
fn test_auth_recoverable_true_propagated() {
let err = ObzError::Auth {
code: ErrorCode::AuthExpired,
message: "token expired".to_string(),
recoverable: true,
suggestion: Some("Retry the command".to_string()),
};
let detail = err.to_error_detail(None);
assert!(detail.recoverable);
assert_eq!(detail.suggestion.as_deref(), Some("Retry the command"));
}
#[test]
fn test_auth_recoverable_false_propagated() {
let err = ObzError::Auth {
code: ErrorCode::AuthMissing,
message: "no credentials".to_string(),
recoverable: false,
suggestion: None,
};
let detail = err.to_error_detail(None);
assert!(!detail.recoverable);
}
#[test]
fn test_invalid_argument_suggestion_propagated() {
let err = ObzError::InvalidArgument {
code: ErrorCode::MissingRequired,
message: "--provider is required".to_string(),
suggestion: Some("Set default_provider in config.yaml".to_string()),
};
let detail = err.to_error_detail(None);
assert_eq!(
detail.suggestion.as_deref(),
Some("Set default_provider in config.yaml")
);
}
#[test]
fn test_invalid_argument_suggestion_none() {
let err = ObzError::InvalidArgument {
code: ErrorCode::InvalidTimeRange,
message: "invalid time".to_string(),
suggestion: None,
};
let detail = err.to_error_detail(None);
assert!(detail.suggestion.is_none());
}
#[test]
fn test_to_error_detail_injects_provider() {
let err = ObzError::Provider {
code: ErrorCode::BackendError,
message: "HTTP 500".to_string(),
raw_error: None,
recoverable: true,
suggestion: None,
doc_url: None,
};
let detail = err.to_error_detail(Some("my-vm"));
assert_eq!(detail.provider.as_deref(), Some("my-vm"));
}
#[test]
fn test_to_error_detail_does_not_override_existing_provider() {
let err = ObzError::Unsupported {
message: "not supported".to_string(),
provider: Some("existing-provider".to_string()),
suggestion: None,
};
let detail = err.to_error_detail(Some("caller-provider"));
assert_eq!(detail.provider.as_deref(), Some("existing-provider"));
}
#[test]
fn test_source_chain_some_included_in_json() {
let detail = ErrorDetail {
category: ErrorCategory::Network,
code: ErrorCode::TlsError,
provider: None,
message: "TLS error".to_string(),
raw_error: None,
recoverable: false,
suggestion: None,
doc_url: None,
source_chain: Some(vec!["cause1".to_string(), "cause2".to_string()]),
};
let json = serde_json::to_value(&detail).unwrap();
let chain = json["source_chain"].as_array().unwrap();
assert_eq!(chain.len(), 2);
assert_eq!(chain[0], "cause1");
assert_eq!(chain[1], "cause2");
}
}