From 5dc0480a7505e4a45c360f1ab17ae62bb18162f5 Mon Sep 17 00:00:00 2001 From: bodymindarts Date: Thu, 15 Oct 2026 21:35:46 +0200 Subject: [PATCH 1/2] feat(errlanes): enum-level shared rejection `code` `#[rejection(code = .., description = ..)]` on an enum is the code, description and level of every variant that declares neither its own `code`, `code_and_level_from` nor `delegate`. The generated code type gets one variant per distinct code, named after the enum, so `CODES`/`ALL` list a shared code once. A lift arm under a shared-code enum takes the enum's code rather than forwarding the source case's, because forwarding only applies when the destination has no locally declared code. Compile errors: shared code unused (every variant declares its own), on an enum with no variants, next to `code_prefix`, or a variant that takes the shared code declaring its own `description`. `#[rejection(from)]` on several variants already emitted one `From` per variant; it is now covered by a test. Co-Authored-By: Claude Sonnet 5.5 --- errlanes-derive/src/rejection.rs | 83 ++++++- errlanes-derive/tests/derive.rs | 4 + .../tests/trybuild/shared_code_empty_enum.rs | 5 + .../trybuild/shared_code_empty_enum.stderr | 5 + .../tests/trybuild/shared_code_unused.rs | 10 + .../tests/trybuild/shared_code_unused.stderr | 5 + .../shared_code_variant_description.rs | 9 + .../shared_code_variant_description.stderr | 5 + .../tests/trybuild/shared_code_with_prefix.rs | 8 + .../trybuild/shared_code_with_prefix.stderr | 5 + errlanes/README.md | 98 ++++++++ errlanes/tests/shared_code.rs | 215 ++++++++++++++++++ 12 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 errlanes-derive/tests/trybuild/shared_code_empty_enum.rs create mode 100644 errlanes-derive/tests/trybuild/shared_code_empty_enum.stderr create mode 100644 errlanes-derive/tests/trybuild/shared_code_unused.rs create mode 100644 errlanes-derive/tests/trybuild/shared_code_unused.stderr create mode 100644 errlanes-derive/tests/trybuild/shared_code_variant_description.rs create mode 100644 errlanes-derive/tests/trybuild/shared_code_variant_description.stderr create mode 100644 errlanes-derive/tests/trybuild/shared_code_with_prefix.rs create mode 100644 errlanes-derive/tests/trybuild/shared_code_with_prefix.stderr create mode 100644 errlanes/tests/shared_code.rs diff --git a/errlanes-derive/src/rejection.rs b/errlanes-derive/src/rejection.rs index 8ea46f10..dda31eb4 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 named like its enum cannot take the shared code: 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..b5d7b8b7 100644 --- a/errlanes-derive/tests/derive.rs +++ b/errlanes-derive/tests/derive.rs @@ -5,6 +5,10 @@ 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/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_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..5598c071 --- /dev/null +++ b/errlanes/tests/shared_code.rs @@ -0,0 +1,215 @@ +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"); +} From 5c039ba8ae2e798cca74df998ad24067b594a3a0 Mon Sep 17 00:00:00 2001 From: bodymindarts Date: Thu, 15 Oct 2026 22:31:09 +0200 Subject: [PATCH 2/2] fix(errlanes): shared-code name clash is with own-code variants The shared code's variant in the generated code type is named after the enum. The clash check looked at the variants that take the shared code, which emit no code variant of their own, and missed the ones that do: a variant with its own `code`, `delegate` or `code_and_level_from` named like the enum produced a second identical code variant and a derive output that did not compile. Reject that case with a clear error, and allow a shared-code variant named like its enum. Co-Authored-By: Claude Sonnet 5.5 --- errlanes-derive/src/rejection.rs | 6 +++--- errlanes-derive/tests/derive.rs | 1 + .../shared_code_own_code_named_like_enum.rs | 9 +++++++++ .../shared_code_own_code_named_like_enum.stderr | 5 +++++ errlanes/tests/shared_code.rs | 14 ++++++++++++++ 5 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 errlanes-derive/tests/trybuild/shared_code_own_code_named_like_enum.rs create mode 100644 errlanes-derive/tests/trybuild/shared_code_own_code_named_like_enum.stderr diff --git a/errlanes-derive/src/rejection.rs b/errlanes-derive/src/rejection.rs index dda31eb4..c1c1b66f 100644 --- a/errlanes-derive/src/rejection.rs +++ b/errlanes-derive/src/rejection.rs @@ -332,11 +332,11 @@ pub fn derive(ast: &syn::DeriveInput) -> darling::Result { } if let Some(clash) = variants .iter() - .find(|v| !v.takes_own_code() && v.ident == *ident) + .find(|v| v.takes_own_code() && v.ident == *ident) { return Err(darling::Error::custom( - "a variant named like its enum cannot take the shared code: the shared \ - code's variant in the code type is named after the enum", + "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)); } diff --git a/errlanes-derive/tests/derive.rs b/errlanes-derive/tests/derive.rs index b5d7b8b7..1f19ac2e 100644 --- a/errlanes-derive/tests/derive.rs +++ b/errlanes-derive/tests/derive.rs @@ -9,6 +9,7 @@ fn ui() { 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_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/tests/shared_code.rs b/errlanes/tests/shared_code.rs index 5598c071..f7283035 100644 --- a/errlanes/tests/shared_code.rs +++ b/errlanes/tests/shared_code.rs @@ -213,3 +213,17 @@ fn compose_imports_shared_code_family() { 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); +}