diff --git a/errlanes-derive/src/rejection.rs b/errlanes-derive/src/rejection.rs index 8ea46f10..c1c1b66f 100644 --- a/errlanes-derive/src/rejection.rs +++ b/errlanes-derive/src/rejection.rs @@ -131,7 +131,17 @@ fn doc_summary(attrs: &[syn::Attribute]) -> Option { (!lines.is_empty()).then(|| lines.join(" ")) } +struct SharedCode { + code: String, + level: TokenStream, + description: TokenStream, +} + impl RejectionVariant { + fn takes_own_code(&self) -> bool { + self.code.is_some() || self.delegate || self.code_and_level_from.is_some() + } + fn is_delegating(&self) -> bool { self.delegate } @@ -296,6 +306,49 @@ pub fn derive(ast: &syn::DeriveInput) -> darling::Result { let schema = crate::composition::schema(ast).map_err(darling::Error::from)?; let error_manual = input.error_manual()?; + let shared = match &input.code { + None => None, + Some(code) => { + if input.code_prefix.is_some() { + return Err(darling::Error::custom( + "`code_prefix` and an enum-level `code` conflict: the prefix is for \ + per-variant generated codes, a shared code is spelled in full", + ) + .with_span(&input.ident)); + } + if variants.is_empty() { + return Err(darling::Error::custom( + "an enum-level `code` on an enum without variants: an empty family has no \ + rejection to report", + ) + .with_span(&input.ident)); + } + if variants.iter().all(|v| v.takes_own_code()) { + return Err(darling::Error::custom( + "the enum-level `code` is unused: every variant declares its own `code`, \ + `code_and_level_from` or `delegate`", + ) + .with_span(&input.ident)); + } + if let Some(clash) = variants + .iter() + .find(|v| v.takes_own_code() && v.ident == *ident) + { + return Err(darling::Error::custom( + "a variant that declares its own code cannot be named like its enum: the \ + shared code's variant in the code type is named after the enum", + ) + .with_span(&clash.ident)); + } + Some(SharedCode { + code: code.clone(), + level: level_expr(&input.level, ident)?, + description: description_tokens(&leaf_description(&input.description, &ast.attrs)), + }) + } + }; + let mut shared_code_variant_emitted = false; + let mut code_variants = Vec::new(); let mut into_str_arms = Vec::new(); let mut code_match_arms = Vec::new(); @@ -416,7 +469,8 @@ pub fn derive(ast: &syn::DeriveInput) -> darling::Result { let mut whole_value_source: Option = None; // Lift owns conversion generation. Its mapping also identifies the // default metadata source; no Lift implementation is required here. - if code_and_level_from.is_none() && v.code.is_none() && !v.delegate { + let takes_shared = shared.is_some() && !v.takes_own_code(); + if code_and_level_from.is_none() && v.code.is_none() && !v.delegate && !takes_shared { let mappings: Vec<_> = raw .attrs .iter() @@ -473,7 +527,32 @@ pub fn derive(ast: &syn::DeriveInput) -> darling::Result { )) .with_span(&v.ident) }; - let (code, level, variant_codes) = if let Some(mut source) = code_and_level_from { + let (code, level, variant_codes) = if let (true, Some(shared)) = (takes_shared, &shared) { + if v.description.is_some() { + return Err(darling::Error::custom( + "one code, one description: this variant takes the enum's shared `code`, \ + so its description is the enum-level one", + ) + .with_span(&v.ident)); + } + if !shared_code_variant_emitted { + shared_code_variant_emitted = true; + code_variants.push(quote!(#ident)); + let shared_code = &shared.code; + into_str_arms.push(quote!(#code_ident::#ident => #shared_code)); + } + let shared_code = &shared.code; + let description = &shared.description; + ( + quote!(#code_ident::#ident), + if v.level.is_some() { + v.level_expr() + } else { + shared.level.clone() + }, + quote!(&[errlanes::CodeInfo { code: #shared_code, description: #description }]), + ) + } else if let Some(mut source) = code_and_level_from { if v.description.is_some() { return Err(forwards_to("e!(#source))); } diff --git a/errlanes-derive/tests/derive.rs b/errlanes-derive/tests/derive.rs index 0f71aed7..1f19ac2e 100644 --- a/errlanes-derive/tests/derive.rs +++ b/errlanes-derive/tests/derive.rs @@ -5,6 +5,11 @@ fn ui() { t.pass("tests/trybuild/rejection_pass.rs"); t.pass("tests/trybuild/rejection_multi_lift.rs"); t.pass("tests/trybuild/error_format_args_pass.rs"); + t.compile_fail("tests/trybuild/shared_code_unused.rs"); + t.compile_fail("tests/trybuild/shared_code_variant_description.rs"); + t.compile_fail("tests/trybuild/shared_code_with_prefix.rs"); + t.compile_fail("tests/trybuild/shared_code_empty_enum.rs"); + t.compile_fail("tests/trybuild/shared_code_own_code_named_like_enum.rs"); t.compile_fail("tests/trybuild/error_format_args_unknown_field.rs"); t.compile_fail("tests/trybuild/error_format_args_arbitrary_expr.rs"); t.compile_fail("tests/trybuild/error_format_args_bare_auto_index_still_rejected.rs"); diff --git a/errlanes-derive/tests/trybuild/shared_code_empty_enum.rs b/errlanes-derive/tests/trybuild/shared_code_empty_enum.rs new file mode 100644 index 00000000..654812da --- /dev/null +++ b/errlanes-derive/tests/trybuild/shared_code_empty_enum.rs @@ -0,0 +1,5 @@ +#[derive(errlanes::Rejection)] +#[rejection(code = "SHARED")] +enum Empty {} + +fn main() {} diff --git a/errlanes-derive/tests/trybuild/shared_code_empty_enum.stderr b/errlanes-derive/tests/trybuild/shared_code_empty_enum.stderr new file mode 100644 index 00000000..e1284f61 --- /dev/null +++ b/errlanes-derive/tests/trybuild/shared_code_empty_enum.stderr @@ -0,0 +1,5 @@ +error: an enum-level `code` on an enum without variants: an empty family has no rejection to report + --> tests/trybuild/shared_code_empty_enum.rs:3:6 + | +3 | enum Empty {} + | ^^^^^ diff --git a/errlanes-derive/tests/trybuild/shared_code_own_code_named_like_enum.rs b/errlanes-derive/tests/trybuild/shared_code_own_code_named_like_enum.rs new file mode 100644 index 00000000..e608c598 --- /dev/null +++ b/errlanes-derive/tests/trybuild/shared_code_own_code_named_like_enum.rs @@ -0,0 +1,9 @@ +#[derive(Debug, errlanes::Rejection)] +#[rejection(code = "SHARED")] +enum Clash { + #[rejection(code = "OWN")] + Clash, + Other, +} + +fn main() {} diff --git a/errlanes-derive/tests/trybuild/shared_code_own_code_named_like_enum.stderr b/errlanes-derive/tests/trybuild/shared_code_own_code_named_like_enum.stderr new file mode 100644 index 00000000..1563d98e --- /dev/null +++ b/errlanes-derive/tests/trybuild/shared_code_own_code_named_like_enum.stderr @@ -0,0 +1,5 @@ +error: a variant that declares its own code cannot be named like its enum: the shared code's variant in the code type is named after the enum + --> tests/trybuild/shared_code_own_code_named_like_enum.rs:5:5 + | +5 | Clash, + | ^^^^^ diff --git a/errlanes-derive/tests/trybuild/shared_code_unused.rs b/errlanes-derive/tests/trybuild/shared_code_unused.rs new file mode 100644 index 00000000..c6150cae --- /dev/null +++ b/errlanes-derive/tests/trybuild/shared_code_unused.rs @@ -0,0 +1,10 @@ +#[derive(Debug, errlanes::Rejection)] +#[rejection(code = "SHARED")] +enum Unused { + #[rejection(code = "A")] + A, + #[rejection(code = "B")] + B, +} + +fn main() {} diff --git a/errlanes-derive/tests/trybuild/shared_code_unused.stderr b/errlanes-derive/tests/trybuild/shared_code_unused.stderr new file mode 100644 index 00000000..50af5a95 --- /dev/null +++ b/errlanes-derive/tests/trybuild/shared_code_unused.stderr @@ -0,0 +1,5 @@ +error: the enum-level `code` is unused: every variant declares its own `code`, `code_and_level_from` or `delegate` + --> tests/trybuild/shared_code_unused.rs:3:6 + | +3 | enum Unused { + | ^^^^^^ diff --git a/errlanes-derive/tests/trybuild/shared_code_variant_description.rs b/errlanes-derive/tests/trybuild/shared_code_variant_description.rs new file mode 100644 index 00000000..14263bb0 --- /dev/null +++ b/errlanes-derive/tests/trybuild/shared_code_variant_description.rs @@ -0,0 +1,9 @@ +#[derive(Debug, errlanes::Rejection)] +#[rejection(code = "SHARED", description = "shared")] +enum Described { + #[rejection(description = "mine")] + A, + B, +} + +fn main() {} diff --git a/errlanes-derive/tests/trybuild/shared_code_variant_description.stderr b/errlanes-derive/tests/trybuild/shared_code_variant_description.stderr new file mode 100644 index 00000000..f169d3ba --- /dev/null +++ b/errlanes-derive/tests/trybuild/shared_code_variant_description.stderr @@ -0,0 +1,5 @@ +error: one code, one description: this variant takes the enum's shared `code`, so its description is the enum-level one + --> tests/trybuild/shared_code_variant_description.rs:5:5 + | +5 | A, + | ^ diff --git a/errlanes-derive/tests/trybuild/shared_code_with_prefix.rs b/errlanes-derive/tests/trybuild/shared_code_with_prefix.rs new file mode 100644 index 00000000..14c8515d --- /dev/null +++ b/errlanes-derive/tests/trybuild/shared_code_with_prefix.rs @@ -0,0 +1,8 @@ +#[derive(Debug, errlanes::Rejection)] +#[rejection(code = "SHARED", code_prefix = "P_")] +enum Prefixed { + A, + B, +} + +fn main() {} diff --git a/errlanes-derive/tests/trybuild/shared_code_with_prefix.stderr b/errlanes-derive/tests/trybuild/shared_code_with_prefix.stderr new file mode 100644 index 00000000..7956b7bb --- /dev/null +++ b/errlanes-derive/tests/trybuild/shared_code_with_prefix.stderr @@ -0,0 +1,5 @@ +error: `code_prefix` and an enum-level `code` conflict: the prefix is for per-variant generated codes, a shared code is spelled in full + --> tests/trybuild/shared_code_with_prefix.rs:3:6 + | +3 | enum Prefixed { + | ^^^^^^^^ diff --git a/errlanes/README.md b/errlanes/README.md index a4a763b7..29f7dbcc 100644 --- a/errlanes/README.md +++ b/errlanes/README.md @@ -368,6 +368,104 @@ assert!(matches!(Settlement::lift(exceeded), Ok(Settlement::BelowFloor(3)))); assert!(Settlement::lift(Batch::Apply(Apply::Locked)).is_err()); ``` +### One code, several causes + +`#[rejection(code = "..", description = "..")]` on an enum is the code (and +description, and `level` if given) of every variant that declares neither its +own `code` nor `delegate`. A cause that the client cannot distinguish is a +variant under the family's code, not a wrapper around the foreign enum. +`#[error]`, `#[source]`, `#[rejection(from)]` and `#[lift]` work on each +variant as before, and a `#[lift]` arm takes the enum's code instead of +forwarding the source case's. The generated code type has one variant per +distinct code, and `CODES` lists each code once. + +```rust +use errlanes::{Fail, Rejection, RejectionCode, ResultExt, lanes}; + +#[derive(Debug, errlanes::Rejection)] +enum Cala { + #[rejection(code = "CALA_DUPLICATE_ID")] + DuplicateId(u64), + #[rejection(code = "CALA_CODE_ALREADY_EXISTS")] + CodeAlreadyExists(String), + #[rejection(code = "CALA_UNRELATED")] + Unrelated, +} + +#[derive(Debug, errlanes::Rejection, errlanes::Lift)] +#[rejection( + code = "HOLDING_ACCOUNT_ALREADY_EXISTS", + description = "A holding account with this id or reference already exists." +)] +#[lift(Cala, unhandled = fatal)] +enum CreateAccountRejection { + #[error("holding account id {0} already exists")] + #[lift(Cala::DuplicateId)] + DuplicateId(u64), + #[error("holding account code {0} already exists")] + #[lift(Cala::CodeAlreadyExists)] + CodeAlreadyExists(String), +} + +assert_eq!(CreateAccountRejectionCode::CODES.len(), 1); + +let duplicate: Result<(), Fail> = + Err::<(), _>(Cala::CodeAlreadyExists("H1".into())).lift(); +let rejected = duplicate.unwrap_err().rejected().unwrap(); +assert_eq!( + Into::<&str>::into(rejected.code()), + "HOLDING_ACCOUNT_ALREADY_EXISTS" +); +assert_eq!(rejected.to_string(), "holding account code H1 already exists"); +``` + +Variants that still declare their own `code`, or `delegate`, keep it, so a +family can report one code for most causes and a distinct one for a few. One +code has one description: a variant that takes the shared code cannot declare +its own `description`. An enum-level `code` is a compile error when every +variant declares its own code or delegates, when the enum has no variants, or +next to `code_prefix`. + +```rust +use errlanes::{Rejection, RejectionCode}; + +#[derive(Debug, errlanes::Rejection)] +#[rejection(code = "PARTNER_NOT_ACTIVE")] +#[error("the partner is not active")] +struct PartnerNotActive; + +#[derive(Debug, errlanes::Rejection)] +#[rejection( + code = "PARTNER_API_KEY_CREDENTIALS_REJECTED", + description = "The presented API key credentials are not usable." +)] +enum ApiKeyCredentialsRejected { + #[rejection(from)] + #[error("the presented secret is not validly encoded")] + InvalidEncoding(#[source] std::str::Utf8Error), + #[rejection(from)] + #[error("the presented secret has the wrong length")] + InvalidLength(#[source] std::array::TryFromSliceError), + #[error("no api key matches the presented secret")] + Unknown, + #[error("the api key is not usable")] + Inactive, + #[rejection(delegate, from)] + #[error("{0}")] + PartnerNotActive(PartnerNotActive), +} + +assert_eq!(ApiKeyCredentialsRejectedCode::CODES.len(), 2); +assert_eq!( + Into::<&str>::into(ApiKeyCredentialsRejected::Unknown.code()), + "PARTNER_API_KEY_CREDENTIALS_REJECTED" +); +assert_eq!( + Into::<&str>::into(ApiKeyCredentialsRejected::from(PartnerNotActive).code()), + "PARTNER_NOT_ACTIVE" +); +``` + ## Errors from other crates Every local error type says, through one trait, how it enters the lanes: diff --git a/errlanes/tests/shared_code.rs b/errlanes/tests/shared_code.rs new file mode 100644 index 00000000..f7283035 --- /dev/null +++ b/errlanes/tests/shared_code.rs @@ -0,0 +1,229 @@ +use std::error::Error; + +use errlanes::{Fail, Level, Rejection, RejectionCode, ResultExt, lanes}; + +#[allow(dead_code)] +#[derive(Debug, errlanes::Rejection)] +enum Cala { + #[rejection(code = "CALA_DUPLICATE_ID")] + DuplicateId(u64), + #[rejection(code = "CALA_CODE_ALREADY_EXISTS")] + CodeAlreadyExists(#[source] CodeExists), + #[rejection(code = "CALA_EXTERNAL_ID_ALREADY_EXISTS")] + ExternalIdAlreadyExists(#[source] ExternalIdExists), + #[rejection(code = "CALA_OTHER")] + Other, +} + +#[derive(Debug)] +struct CodeExists(String); + +impl std::fmt::Display for CodeExists { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "code {} exists", self.0) + } +} + +impl Error for CodeExists {} + +#[derive(Debug)] +struct ExternalIdExists(String); + +impl std::fmt::Display for ExternalIdExists { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "external id {} exists", self.0) + } +} + +impl Error for ExternalIdExists {} + +#[derive(Debug, errlanes::Rejection, errlanes::Lift)] +#[rejection( + code = "HOLDING_ACCOUNT_ALREADY_EXISTS", + description = "A holding account with this id or reference already exists." +)] +#[lift(Cala, unhandled = fatal)] +enum CreateAccountRejection { + #[error("holding account id {0} already exists")] + #[lift(Cala::DuplicateId)] + DuplicateId(u64), + #[error("{0}")] + #[lift(Cala::CodeAlreadyExists)] + CodeAlreadyExists(#[source] CodeExists), + #[error("{0}")] + #[lift(Cala::ExternalIdAlreadyExists)] + ExternalIdAlreadyExists(#[source] ExternalIdExists), +} + +fn code(r: &R) -> &'static str { + r.code().into() +} + +#[test] +fn shared_code_reports_once_in_catalogue() { + assert_eq!(CreateAccountRejectionCode::CODES.len(), 1); + assert_eq!( + CreateAccountRejectionCode::ALL, + &["HOLDING_ACCOUNT_ALREADY_EXISTS"] + ); + let entry = ::CODES[0]; + assert_eq!( + entry.description, + Some("A holding account with this id or reference already exists.") + ); + assert_eq!( + CreateAccountRejection::DuplicateId(1).code().to_string(), + "HOLDING_ACCOUNT_ALREADY_EXISTS" + ); +} + +#[test] +fn shared_code_variant_display_is_per_variant() { + assert_eq!( + CreateAccountRejection::DuplicateId(7).to_string(), + "holding account id 7 already exists" + ); + let v = CreateAccountRejection::CodeAlreadyExists(CodeExists("X".into())); + assert_eq!(v.to_string(), "code X exists"); + assert_eq!(v.source().unwrap().to_string(), "code X exists"); + assert_eq!( + code(&CreateAccountRejection::ExternalIdAlreadyExists( + ExternalIdExists("e".into()) + )), + "HOLDING_ACCOUNT_ALREADY_EXISTS" + ); +} + +#[test] +fn lift_arm_takes_local_shared_code_over_source_code() { + let src: Result<(), Fail> = Err(Fail::Rejected(Cala::DuplicateId(3))); + let lifted: Result<(), Fail> = src.lift(); + let r = lifted.unwrap_err().rejected().unwrap(); + assert!(matches!(r, CreateAccountRejection::DuplicateId(3))); + assert_eq!(code(&r), "HOLDING_ACCOUNT_ALREADY_EXISTS"); + + let src: Result<(), Fail> = Err(Fail::Rejected(Cala::Other)); + let lifted: Result<(), Fail> = src.lift(); + assert!(matches!(lifted.unwrap_err(), Fail::Fatal(_))); +} + +#[derive(Debug, errlanes::Rejection)] +#[rejection(code = "PARTNER_NOT_ACTIVE")] +#[error("partner not active")] +struct PartnerNotActive; + +#[derive(Debug)] +struct DecodeError; +impl std::fmt::Display for DecodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("decode") + } +} +impl Error for DecodeError {} + +#[derive(Debug)] +struct LengthError; +impl std::fmt::Display for LengthError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("length") + } +} +impl Error for LengthError {} + +#[derive(Debug, errlanes::Rejection)] +#[rejection( + code = "PARTNER_API_KEY_CREDENTIALS_REJECTED", + description = "The presented API key credentials are not usable.", + level = "info" +)] +enum ApiKeyCredentialsRejected { + #[rejection(from)] + #[error("the presented secret is not validly encoded")] + InvalidEncoding(#[source] DecodeError), + #[rejection(from)] + #[error("the presented secret has the wrong length")] + InvalidLength(#[source] LengthError), + #[error("no api key matches the presented secret")] + Unknown, + #[rejection(level = "error")] + #[error("the api key is not usable")] + Inactive, + #[rejection(delegate, from)] + #[error("{0}")] + PartnerNotActive(PartnerNotActive), +} + +#[test] +fn delegate_variant_keeps_its_own_code_under_shared_code() { + assert_eq!(ApiKeyCredentialsRejectedCode::CODES.len(), 2); + assert_eq!( + ApiKeyCredentialsRejectedCode::ALL, + &["PARTNER_API_KEY_CREDENTIALS_REJECTED", "PARTNER_NOT_ACTIVE"] + ); + assert_eq!( + code(&ApiKeyCredentialsRejected::PartnerNotActive( + PartnerNotActive + )), + "PARTNER_NOT_ACTIVE" + ); + assert_eq!( + code(&ApiKeyCredentialsRejected::Unknown), + "PARTNER_API_KEY_CREDENTIALS_REJECTED" + ); +} + +#[test] +fn shared_level_is_the_default_and_a_variant_level_wins() { + assert_eq!(ApiKeyCredentialsRejected::Unknown.level(), Level::Info); + assert_eq!(ApiKeyCredentialsRejected::Inactive.level(), Level::Error); +} + +#[test] +fn from_on_two_variants_emits_two_from_impls() { + assert!(matches!( + ApiKeyCredentialsRejected::from(DecodeError), + ApiKeyCredentialsRejected::InvalidEncoding(_) + )); + assert!(matches!( + ApiKeyCredentialsRejected::from(LengthError), + ApiKeyCredentialsRejected::InvalidLength(_) + )); + assert!(matches!( + ApiKeyCredentialsRejected::from(PartnerNotActive), + ApiKeyCredentialsRejected::PartnerNotActive(_) + )); +} + +#[errlanes::compose(ApiKeyCredentialsRejected as Key)] +#[derive(Debug)] +enum Gateway { + #[error("local")] + #[rejection(code = "GATEWAY_LOCAL")] + Local, +} + +#[test] +fn compose_imports_shared_code_family() { + let imported = Gateway::from(ApiKeyCredentialsRejected::Unknown); + assert_eq!(code(&imported), "PARTNER_API_KEY_CREDENTIALS_REJECTED"); + assert_eq!(imported.level(), Level::Info); + let delegated = Gateway::from(ApiKeyCredentialsRejected::PartnerNotActive( + PartnerNotActive, + )); + assert_eq!(code(&delegated), "PARTNER_NOT_ACTIVE"); + assert_eq!(code(&Gateway::Local), "GATEWAY_LOCAL"); +} + +#[derive(Debug, errlanes::Rejection)] +#[rejection(code = "NAMED_LIKE_ENUM")] +enum NamedLikeEnum { + NamedLikeEnum, + Other, +} + +#[test] +fn a_shared_code_variant_may_be_named_like_its_enum() { + assert_eq!(code(&NamedLikeEnum::NamedLikeEnum), "NAMED_LIKE_ENUM"); + assert_eq!(code(&NamedLikeEnum::Other), "NAMED_LIKE_ENUM"); + assert_eq!(NamedLikeEnumCode::CODES.len(), 1); +}