//! Unified error tree for the sunbeam CLI. //! //! Every module returns `Result`. Errors bubble up to `main`, //! which maps them to exit codes and log output. /// Exit codes for the sunbeam CLI. #[allow(dead_code)] pub mod exit { pub const SUCCESS: i32 = 0; pub const GENERAL: i32 = 1; pub const USAGE: i32 = 2; pub const KUBE: i32 = 3; pub const CONFIG: i32 = 4; pub const NETWORK: i32 = 5; pub const SECRETS: i32 = 6; pub const BUILD: i32 = 7; pub const IDENTITY: i32 = 8; pub const EXTERNAL_TOOL: i32 = 9; } /// Top-level error type for the sunbeam CLI. /// /// Each variant maps to a logical error category with its own exit code. /// Leaf errors (io, json, yaml, kube, reqwest, etc.) are converted via `From` impls. #[derive(Debug, thiserror::Error)] pub enum SunbeamError { /// Kubernetes API or cluster-related error. #[error("{context}")] Kube { context: String, #[source] source: Option, }, /// Configuration error (missing config, invalid config, bad arguments). #[error("{0}")] Config(String), /// Network/HTTP error. #[error("{context}")] Network { context: String, #[source] source: Option, }, /// OpenBao / Vault error. #[error("{0}")] Secrets(String), /// Image build error. #[error("{0}")] Build(String), /// Identity / user management error (Kratos, Hydra). #[error("{0}")] Identity(String), /// External tool error (kustomize, linkerd, buildctl, yarn, etc.). #[error("{tool}: {detail}")] ExternalTool { tool: String, detail: String }, /// IO error. #[error("{context}: {source}")] Io { context: String, source: std::io::Error, }, /// JSON serialization/deserialization error. #[error("{0}")] Json(#[from] serde_json::Error), /// YAML serialization/deserialization error. #[error("{0}")] Yaml(#[from] serde_yaml::Error), /// Catch-all for errors that don't fit a specific category. #[error("{0}")] Other(String), } /// Convenience type alias used throughout the codebase. pub type Result = std::result::Result; impl SunbeamError { /// Map this error to a process exit code. pub fn exit_code(&self) -> i32 { match self { SunbeamError::Config(_) => exit::CONFIG, SunbeamError::Kube { .. } => exit::KUBE, SunbeamError::Network { .. } => exit::NETWORK, SunbeamError::Secrets(_) => exit::SECRETS, SunbeamError::Build(_) => exit::BUILD, SunbeamError::Identity(_) => exit::IDENTITY, SunbeamError::ExternalTool { .. } => exit::EXTERNAL_TOOL, SunbeamError::Io { .. } => exit::GENERAL, SunbeamError::Json(_) => exit::GENERAL, SunbeamError::Yaml(_) => exit::GENERAL, SunbeamError::Other(_) => exit::GENERAL, } } } // --------------------------------------------------------------------------- // From impls for automatic conversion // --------------------------------------------------------------------------- impl From for SunbeamError { fn from(e: kube::Error) -> Self { SunbeamError::Kube { context: e.to_string(), source: Some(e), } } } impl From for SunbeamError { fn from(e: reqwest::Error) -> Self { SunbeamError::Network { context: e.to_string(), source: Some(e), } } } impl From for SunbeamError { fn from(e: std::io::Error) -> Self { SunbeamError::Io { context: "IO error".into(), source: e, } } } impl From for SunbeamError { fn from(e: lettre::transport::smtp::Error) -> Self { SunbeamError::Network { context: format!("SMTP error: {e}"), source: None, } } } impl From for SunbeamError { fn from(e: lettre::error::Error) -> Self { SunbeamError::Other(format!("Email error: {e}")) } } impl From for SunbeamError { fn from(e: base64::DecodeError) -> Self { SunbeamError::Other(format!("Base64 decode error: {e}")) } } impl From for SunbeamError { fn from(e: std::string::FromUtf8Error) -> Self { SunbeamError::Other(format!("UTF-8 error: {e}")) } } // --------------------------------------------------------------------------- // Context extension trait (replaces anyhow's .context()) // --------------------------------------------------------------------------- /// Extension trait that adds `.ctx()` to `Result` for adding context strings. /// Replaces `anyhow::Context`. pub trait ResultExt { /// Add context to an error, converting it to `SunbeamError`. fn ctx(self, context: &str) -> Result; /// Add lazy context to an error. fn with_ctx String>(self, f: F) -> Result; } impl> ResultExt for std::result::Result { fn ctx(self, context: &str) -> Result { self.map_err(|e| { let inner = e.into(); match inner { SunbeamError::Kube { source, .. } => SunbeamError::Kube { context: context.to_string(), source, }, SunbeamError::Network { source, .. } => SunbeamError::Network { context: context.to_string(), source, }, SunbeamError::Io { source, .. } => SunbeamError::Io { context: context.to_string(), source, }, SunbeamError::Secrets(msg) => SunbeamError::Secrets(format!("{context}: {msg}")), SunbeamError::Config(msg) => SunbeamError::Config(format!("{context}: {msg}")), SunbeamError::Build(msg) => SunbeamError::Build(format!("{context}: {msg}")), SunbeamError::Identity(msg) => SunbeamError::Identity(format!("{context}: {msg}")), SunbeamError::ExternalTool { tool, detail } => SunbeamError::ExternalTool { tool, detail: format!("{context}: {detail}"), }, other => SunbeamError::Other(format!("{context}: {other}")), } }) } fn with_ctx String>(self, f: F) -> Result { self.map_err(|e| { let context = f(); let inner = e.into(); match inner { SunbeamError::Kube { source, .. } => SunbeamError::Kube { context, source, }, SunbeamError::Network { source, .. } => SunbeamError::Network { context, source, }, SunbeamError::Io { source, .. } => SunbeamError::Io { context, source, }, SunbeamError::Secrets(msg) => SunbeamError::Secrets(format!("{context}: {msg}")), SunbeamError::Config(msg) => SunbeamError::Config(format!("{context}: {msg}")), SunbeamError::Build(msg) => SunbeamError::Build(format!("{context}: {msg}")), SunbeamError::Identity(msg) => SunbeamError::Identity(format!("{context}: {msg}")), SunbeamError::ExternalTool { tool, detail } => SunbeamError::ExternalTool { tool, detail: format!("{context}: {detail}"), }, other => SunbeamError::Other(format!("{context}: {other}")), } }) } } impl ResultExt for Option { fn ctx(self, context: &str) -> Result { self.ok_or_else(|| SunbeamError::Other(context.to_string())) } fn with_ctx String>(self, f: F) -> Result { self.ok_or_else(|| SunbeamError::Other(f())) } } // --------------------------------------------------------------------------- // Convenience constructors // --------------------------------------------------------------------------- impl SunbeamError { pub fn kube(context: impl Into) -> Self { SunbeamError::Kube { context: context.into(), source: None, } } pub fn config(msg: impl Into) -> Self { SunbeamError::Config(msg.into()) } pub fn network(context: impl Into) -> Self { SunbeamError::Network { context: context.into(), source: None, } } pub fn secrets(msg: impl Into) -> Self { SunbeamError::Secrets(msg.into()) } pub fn build(msg: impl Into) -> Self { SunbeamError::Build(msg.into()) } pub fn identity(msg: impl Into) -> Self { SunbeamError::Identity(msg.into()) } pub fn tool(tool: impl Into, detail: impl Into) -> Self { SunbeamError::ExternalTool { tool: tool.into(), detail: detail.into(), } } } // --------------------------------------------------------------------------- // bail! macro replacement // --------------------------------------------------------------------------- /// Like anyhow::bail! but produces a SunbeamError::Other. #[macro_export] macro_rules! bail { ($($arg:tt)*) => { return Err($crate::error::SunbeamError::Other(format!($($arg)*))) }; } #[cfg(test)] mod tests { use super::*; #[test] fn test_exit_codes() { assert_eq!(SunbeamError::config("bad").exit_code(), exit::CONFIG); assert_eq!(SunbeamError::kube("fail").exit_code(), exit::KUBE); assert_eq!(SunbeamError::network("fail").exit_code(), exit::NETWORK); assert_eq!(SunbeamError::secrets("fail").exit_code(), exit::SECRETS); assert_eq!(SunbeamError::build("fail").exit_code(), exit::BUILD); assert_eq!(SunbeamError::identity("fail").exit_code(), exit::IDENTITY); assert_eq!( SunbeamError::tool("kustomize", "not found").exit_code(), exit::EXTERNAL_TOOL ); assert_eq!(SunbeamError::Other("oops".into()).exit_code(), exit::GENERAL); } #[test] fn test_display_formatting() { let e = SunbeamError::tool("kustomize", "build failed"); assert_eq!(e.to_string(), "kustomize: build failed"); let e = SunbeamError::config("missing --domain"); assert_eq!(e.to_string(), "missing --domain"); } #[test] fn test_kube_from() { // Just verify the From impl compiles and categorizes correctly let e = SunbeamError::kube("test"); assert!(matches!(e, SunbeamError::Kube { .. })); } #[test] fn test_context_extension() { let result: std::result::Result<(), std::io::Error> = Err(std::io::Error::new(std::io::ErrorKind::NotFound, "gone")); let mapped = result.ctx("reading config"); assert!(mapped.is_err()); let e = mapped.unwrap_err(); assert!(e.to_string().starts_with("reading config")); assert_eq!(e.exit_code(), exit::GENERAL); // IO maps to general } #[test] fn test_option_context() { let val: Option = None; let result = val.ctx("value not found"); assert!(result.is_err()); assert_eq!(result.unwrap_err().to_string(), "value not found"); } #[test] fn test_bail_macro() { fn failing() -> Result<()> { bail!("something went wrong: {}", 42); } let e = failing().unwrap_err(); assert_eq!(e.to_string(), "something went wrong: 42"); } }