From f9b3f062d21e7d2fdc12e1c09b9e5cd8ff3cbfb3 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Tue, 23 Sep 2025 10:12:51 +0700 Subject: [PATCH] Add Masterror derive attribute support --- CHANGELOG.md | 22 + Cargo.lock | 4 +- Cargo.toml | 6 +- README.md | 90 +++- README.ru.md | 76 ++- README.template.md | 76 ++- masterror-derive/Cargo.toml | 2 +- masterror-derive/src/input.rs | 273 +++++++++- masterror-derive/src/lib.rs | 28 + masterror-derive/src/masterror_impl.rs | 508 ++++++++++++++++++ src/app_error/core.rs | 9 +- src/lib.rs | 76 ++- src/mapping.rs | 129 +++++ tests/error_derive_from_trybuild.rs | 12 + tests/masterror_macro.rs | 157 ++++++ .../fail/enum_missing_variant.stderr | 7 +- tests/ui/app_error/fail/missing_code.stderr | 4 +- tests/ui/app_error/fail/missing_kind.stderr | 2 +- tests/ui/formatter/fail/duplicate_fmt.stderr | 2 +- .../fail/implicit_after_named.stderr | 1 + .../ui/formatter/fail/unsupported_flag.stderr | 4 +- .../fail/unsupported_formatter.stderr | 4 +- .../ui/formatter/fail/uppercase_binary.stderr | 4 +- .../formatter/fail/uppercase_pointer.stderr | 4 +- tests/ui/masterror/fail/duplicate_attr.rs | 9 + tests/ui/masterror/fail/duplicate_attr.stderr | 13 + .../ui/masterror/fail/duplicate_telemetry.rs | 13 + .../masterror/fail/duplicate_telemetry.stderr | 13 + tests/ui/masterror/fail/empty_redact.rs | 8 + tests/ui/masterror/fail/empty_redact.stderr | 13 + .../ui/masterror/fail/enum_missing_variant.rs | 12 + .../fail/enum_missing_variant.stderr | 14 + tests/ui/masterror/fail/missing_attr.rs | 7 + tests/ui/masterror/fail/missing_attr.stderr | 5 + tests/ui/masterror/fail/missing_category.rs | 8 + .../ui/masterror/fail/missing_category.stderr | 13 + tests/ui/masterror/fail/missing_code.rs | 8 + tests/ui/masterror/fail/missing_code.stderr | 13 + tests/ui/masterror/fail/unknown_option.rs | 8 + tests/ui/masterror/fail/unknown_option.stderr | 13 + tests/ui/masterror/pass/struct.rs | 19 + 41 files changed, 1642 insertions(+), 47 deletions(-) create mode 100644 masterror-derive/src/masterror_impl.rs create mode 100644 src/mapping.rs create mode 100644 tests/masterror_macro.rs create mode 100644 tests/ui/masterror/fail/duplicate_attr.rs create mode 100644 tests/ui/masterror/fail/duplicate_attr.stderr create mode 100644 tests/ui/masterror/fail/duplicate_telemetry.rs create mode 100644 tests/ui/masterror/fail/duplicate_telemetry.stderr create mode 100644 tests/ui/masterror/fail/empty_redact.rs create mode 100644 tests/ui/masterror/fail/empty_redact.stderr create mode 100644 tests/ui/masterror/fail/enum_missing_variant.rs create mode 100644 tests/ui/masterror/fail/enum_missing_variant.stderr create mode 100644 tests/ui/masterror/fail/missing_attr.rs create mode 100644 tests/ui/masterror/fail/missing_attr.stderr create mode 100644 tests/ui/masterror/fail/missing_category.rs create mode 100644 tests/ui/masterror/fail/missing_category.stderr create mode 100644 tests/ui/masterror/fail/missing_code.rs create mode 100644 tests/ui/masterror/fail/missing_code.stderr create mode 100644 tests/ui/masterror/fail/unknown_option.rs create mode 100644 tests/ui/masterror/fail/unknown_option.stderr create mode 100644 tests/ui/masterror/pass/struct.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 957b2cb..2db66fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,28 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.13.0] - 2025-09-23 + +### Added +- Introduced `#[derive(Masterror)]` and the `#[masterror(...)]` attribute to + convert domain errors directly into [`masterror::Error`] while capturing + metadata, message redaction policy and optional transport mappings. +- Added transport mapping descriptors in `mapping::{HttpMapping, GrpcMapping, + ProblemMapping}` generated by the new derive for HTTP/gRPC/problem-json + integrations. + +### Changed +- Re-exported the `Masterror` derive from the crate root alongside the existing + `Error` derive. + +### Documentation +- Expanded crate docs and both READMEs with `Masterror` examples, telemetry + guidance and redaction policy notes. + +### Tests +- Added integration tests and trybuild coverage exercising the + `#[masterror(...)]` attribute and generated mapping tables. + ## [0.12.1] - 2025-10-30 ### Added diff --git a/Cargo.lock b/Cargo.lock index 2c79379..a038364 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1606,7 +1606,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.12.1" +version = "0.13.0" dependencies = [ "actix-web", "axum", @@ -1637,7 +1637,7 @@ dependencies = [ [[package]] name = "masterror-derive" -version = "0.6.6" +version = "0.7.0" dependencies = [ "masterror-template", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 523c059..7437da7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.12.1" +version = "0.13.0" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -71,11 +71,11 @@ turnkey = [] openapi = ["dep:utoipa"] [workspace.dependencies] -masterror-derive = { version = "0.6.6" } +masterror-derive = { version = "0.7.0" } masterror-template = { version = "0.3.6" } [dependencies] -masterror-derive = { version = "0.6" } +masterror-derive = { version = "0.7" } masterror-template = { workspace = true } tracing = "0.1" diff --git a/README.md b/README.md index 49416f2..1844265 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,9 @@ Core is framework-agnostic; integrations are opt-in via feature flags. Stable categories, conservative HTTP mapping, no `unsafe`. - Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse`, `Metadata` -- Derive macros: `#[derive(Error)]`, `#[app_error]`, `#[provide]` for domain - mappings and structured telemetry +- Derive macros: `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, + `#[masterror(...)]`, `#[provide]` for domain mappings and structured + telemetry - Optional Axum/Actix integration and browser/WASM console logging - Optional OpenAPI schema (via `utoipa`) - Structured metadata helpers via `field::*` builders @@ -37,9 +38,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.12.1", default-features = false } +masterror = { version = "0.13.0", default-features = false } # or with features: -# masterror = { version = "0.12.1", features = [ +# masterror = { version = "0.13.0", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -75,10 +76,10 @@ masterror = { version = "0.12.1", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.12.1", default-features = false } +masterror = { version = "0.13.0", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.12.1", features = [ +# masterror = { version = "0.13.0", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -179,6 +180,77 @@ assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); placeholder, making it easy to branch on the requested rendering behaviour without manually matching every enum variant. +#### `#[derive(Masterror)]` and `#[masterror(...)]` + +`#[derive(Masterror)]` wires a domain error directly into [`masterror::Error`], +augmenting it with metadata, redaction policy and optional transport mappings. +The accompanying `#[masterror(...)]` attribute mirrors the `#[app_error]` +syntax while remaining explicit about telemetry: + +~~~rust +use masterror::{ + mapping::HttpMapping, AppCode, AppErrorKind, Error, Masterror, MessageEditPolicy +}; + +#[derive(Debug, Masterror)] +#[error("user {user_id} missing flag {flag}")] +#[masterror( + code = AppCode::NotFound, + category = AppErrorKind::NotFound, + message, + redact(message), + telemetry( + Some(masterror::field::str("user_id", user_id.clone())), + attempt.map(|value| masterror::field::u64("attempt", value)) + ), + map.grpc = 5, + map.problem = "https://errors.example.com/not-found" +)] +struct MissingFlag { + user_id: String, + flag: &'static str, + attempt: Option, + #[source] + source: Option +} + +let err = MissingFlag { + user_id: "alice".into(), + flag: "beta", + attempt: Some(2), + source: None +}; +let converted: Error = err.into(); +assert_eq!(converted.code, AppCode::NotFound); +assert_eq!(converted.kind, AppErrorKind::NotFound); +assert_eq!(converted.edit_policy, MessageEditPolicy::Redact); +assert!(converted.metadata().get("user_id").is_some()); + +assert_eq!( + MissingFlag::HTTP_MAPPING, + HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) +); +~~~ + +- `code` / `category` pick the public [`AppCode`] and internal + [`AppErrorKind`]. +- `message` forwards the formatted [`Display`] output as the safe public + message. Omit it to keep the message private. +- `redact(message)` flips [`MessageEditPolicy`] to redactable at the transport + boundary. +- `telemetry(...)` accepts expressions that evaluate to + `Option`. Each populated field is inserted into the + resulting [`Metadata`]; use `telemetry()` when no fields are attached. +- `map.grpc` / `map.problem` capture optional gRPC status codes (as `i32`) and + RFC 7807 `type` URIs. The derive emits tables such as + `MyError::HTTP_MAPPING`, `MyError::GRPC_MAPPING` and + `MyError::PROBLEM_MAPPING` (or slice variants for enums) for downstream + integrations. + +All familiar field-level attributes (`#[from]`, `#[source]`, `#[backtrace]`) +are still honoured. Sources and backtraces are automatically attached to the +generated [`masterror::Error`]. + #### Display shorthand projections `#[error("...")]` supports the same shorthand syntax as `thiserror` for @@ -637,13 +709,13 @@ assert_eq!(resp.status, 401); Minimal core: ~~~toml -masterror = { version = "0.12.1", default-features = false } +masterror = { version = "0.13.0", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.12.1", features = [ +masterror = { version = "0.13.0", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -652,7 +724,7 @@ masterror = { version = "0.12.1", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.12.1", features = [ +masterror = { version = "0.13.0", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/README.ru.md b/README.ru.md index 855fe37..43fb663 100644 --- a/README.ru.md +++ b/README.ru.md @@ -20,8 +20,9 @@ ## Основные возможности - Базовые типы: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse`, `Metadata`. -- Деривы `#[derive(Error)]`, `#[app_error]`, `#[provide]` для типизированного - телеметрического контекста и прямых конверсий доменных ошибок. +- Деривы `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, + `#[masterror(...)]`, `#[provide]` для типизированного телеметрического + контекста и прямых конверсий доменных ошибок. - Адаптеры для Axum и Actix плюс логирование в браузер/`JsValue` для WASM (по фичам). - Генерация схем OpenAPI через `utoipa`. @@ -38,9 +39,9 @@ ~~~toml [dependencies] # минимальное ядро -masterror = { version = "0.12.1", default-features = false } +masterror = { version = "0.13.0", default-features = false } # или с нужными интеграциями -# masterror = { version = "0.12.1", features = [ +# masterror = { version = "0.13.0", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -193,6 +194,73 @@ assert!(matches!(app.kind, AppErrorKind::Service)); информацию и прямой маппинг в `AppError`/`AppCode` без ручных реализаций `From`. +## `#[derive(Masterror)]` и атрибут `#[masterror(...)]` + +Когда нужно сразу получить [`masterror::Error`], используйте `#[derive(Masterror)]`. +Атрибут `#[masterror(...)]` описывает код, категорию, телеметрию, политику +редактирования и транспортные подсказки: + +~~~rust +use masterror::{ + mapping::HttpMapping, AppCode, AppErrorKind, Error, Masterror, MessageEditPolicy +}; + +#[derive(Debug, Masterror)] +#[error("пользователь {user_id} без флага {flag}")] +#[masterror( + code = AppCode::NotFound, + category = AppErrorKind::NotFound, + message, + redact(message), + telemetry( + Some(masterror::field::str("user_id", user_id.clone())), + attempt.map(|value| masterror::field::u64("attempt", value)) + ), + map.grpc = 5, + map.problem = "https://errors.example.com/not-found" +)] +struct MissingFlag { + user_id: String, + flag: &'static str, + attempt: Option, + #[source] + source: Option, +} + +let err = MissingFlag { + user_id: "alice".into(), + flag: "beta", + attempt: Some(2), + source: None, +}; +let converted: Error = err.into(); +assert_eq!(converted.code, AppCode::NotFound); +assert_eq!(converted.kind, AppErrorKind::NotFound); +assert_eq!(converted.edit_policy, MessageEditPolicy::Redact); +assert!(converted.metadata().get("user_id").is_some()); + +assert_eq!( + MissingFlag::HTTP_MAPPING, + HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) +); +~~~ + +- `code` / `category` задают публичный [`AppCode`] и внутренний + [`AppErrorKind`]. +- `message` включает текст, возвращаемый [`Display`], в публичное сообщение. +- `redact(message)` выставляет [`MessageEditPolicy`] в режим редактирования на + транспортной границе. +- `telemetry(...)` принимает выражения, возвращающие + `Option`. Каждое присутствующее поле добавляется в + [`Metadata`]; пустые выражения пропускаются. +- `map.grpc` / `map.problem` позволяют зафиксировать код gRPC (целое `i32`) и + URI для problem+json. Дерив генерирует таблицы `TYPE::HTTP_MAPPING`, + `TYPE::GRPC_MAPPING` и `TYPE::PROBLEM_MAPPING` (или срезы для перечислений) + для дальнейшей интеграции. + +Атрибуты `#[from]`, `#[source]`, `#[backtrace]` продолжают работать: источники и +бектрейсы автоматически прикрепляются к результирующему [`masterror::Error`]. + ## Форматирование шаблонов `#[error]` Шаблон `#[error("...")]` по умолчанию использует `Display`, но любая diff --git a/README.template.md b/README.template.md index 3a757cc..1b399e7 100644 --- a/README.template.md +++ b/README.template.md @@ -20,8 +20,9 @@ Core is framework-agnostic; integrations are opt-in via feature flags. Stable categories, conservative HTTP mapping, no `unsafe`. - Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse`, `Metadata` -- Derive macros: `#[derive(Error)]`, `#[app_error]`, `#[provide]` for domain - mappings and structured telemetry +- Derive macros: `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, + `#[masterror(...)]`, `#[provide]` for domain mappings and structured + telemetry - Optional Axum/Actix integration and browser/WASM console logging - Optional OpenAPI schema (via `utoipa`) - Structured metadata helpers via `field::*` builders @@ -173,6 +174,77 @@ assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); placeholder, making it easy to branch on the requested rendering behaviour without manually matching every enum variant. +#### `#[derive(Masterror)]` and `#[masterror(...)]` + +`#[derive(Masterror)]` wires a domain error directly into [`masterror::Error`], +augmenting it with metadata, redaction policy and optional transport mappings. +The accompanying `#[masterror(...)]` attribute mirrors the `#[app_error]` +syntax while remaining explicit about telemetry: + +~~~rust +use masterror::{ + mapping::HttpMapping, AppCode, AppErrorKind, Error, Masterror, MessageEditPolicy +}; + +#[derive(Debug, Masterror)] +#[error("user {user_id} missing flag {flag}")] +#[masterror( + code = AppCode::NotFound, + category = AppErrorKind::NotFound, + message, + redact(message), + telemetry( + Some(masterror::field::str("user_id", user_id.clone())), + attempt.map(|value| masterror::field::u64("attempt", value)) + ), + map.grpc = 5, + map.problem = "https://errors.example.com/not-found" +)] +struct MissingFlag { + user_id: String, + flag: &'static str, + attempt: Option, + #[source] + source: Option +} + +let err = MissingFlag { + user_id: "alice".into(), + flag: "beta", + attempt: Some(2), + source: None +}; +let converted: Error = err.into(); +assert_eq!(converted.code, AppCode::NotFound); +assert_eq!(converted.kind, AppErrorKind::NotFound); +assert_eq!(converted.edit_policy, MessageEditPolicy::Redact); +assert!(converted.metadata().get("user_id").is_some()); + +assert_eq!( + MissingFlag::HTTP_MAPPING, + HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) +); +~~~ + +- `code` / `category` pick the public [`AppCode`] and internal + [`AppErrorKind`]. +- `message` forwards the formatted [`Display`] output as the safe public + message. Omit it to keep the message private. +- `redact(message)` flips [`MessageEditPolicy`] to redactable at the transport + boundary. +- `telemetry(...)` accepts expressions that evaluate to + `Option`. Each populated field is inserted into the + resulting [`Metadata`]; use `telemetry()` when no fields are attached. +- `map.grpc` / `map.problem` capture optional gRPC status codes (as `i32`) and + RFC 7807 `type` URIs. The derive emits tables such as + `MyError::HTTP_MAPPING`, `MyError::GRPC_MAPPING` and + `MyError::PROBLEM_MAPPING` (or slice variants for enums) for downstream + integrations. + +All familiar field-level attributes (`#[from]`, `#[source]`, `#[backtrace]`) +are still honoured. Sources and backtraces are automatically attached to the +generated [`masterror::Error`]. + #### Display shorthand projections `#[error("...")]` supports the same shorthand syntax as `thiserror` for diff --git a/masterror-derive/Cargo.toml b/masterror-derive/Cargo.toml index c9ba1a6..dadb1cd 100644 --- a/masterror-derive/Cargo.toml +++ b/masterror-derive/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "masterror-derive" rust-version = "1.90" -version = "0.6.6" +version = "0.7.0" edition = "2024" license = "MIT OR Apache-2.0" repository = "https://github.com/RAprogramm/masterror" diff --git a/masterror-derive/src/input.rs b/masterror-derive/src/input.rs index 73408b8..64a00e0 100644 --- a/masterror-derive/src/input.rs +++ b/masterror-derive/src/input.rs @@ -33,7 +33,8 @@ pub struct StructData { pub display: DisplaySpec, #[allow(dead_code)] pub format_args: FormatArgsSpec, - pub app_error: Option + pub app_error: Option, + pub masterror: Option } #[derive(Debug)] @@ -44,6 +45,7 @@ pub struct VariantData { #[allow(dead_code)] pub format_args: FormatArgsSpec, pub app_error: Option, + pub masterror: Option, pub span: Span } @@ -55,6 +57,19 @@ pub struct AppErrorSpec { pub attribute_span: Span } +#[derive(Clone, Debug)] +pub struct MasterrorSpec { + pub code: Expr, + pub category: ExprPath, + pub expose_message: bool, + pub redact_message: bool, + pub telemetry: Vec, + pub map_grpc: Option, + pub map_problem: Option, + #[allow(dead_code)] + pub attribute_span: Span +} + #[derive(Debug)] pub enum Fields { Unit, @@ -524,6 +539,7 @@ fn parse_struct( ) -> Result { let display = extract_display_spec(attrs, ident.span(), errors)?; let app_error = extract_app_error_spec(attrs, errors)?; + let masterror = extract_masterror_spec(attrs, errors)?; let fields = Fields::from_syn(&data.fields, errors); validate_from_usage(&fields, &display, errors); @@ -534,7 +550,8 @@ fn parse_struct( fields, display, format_args: FormatArgsSpec::default(), - app_error + app_error, + masterror }))) } @@ -573,6 +590,7 @@ fn parse_variant(variant: syn::Variant, errors: &mut Vec) -> Result) -> Result +) -> Result, ()> { + let mut spec = None; + let mut had_error = false; + + for attr in attrs { + if !path_is(attr, "masterror") { + continue; + } + + if spec.is_some() { + errors.push(Error::new_spanned( + attr, + "duplicate #[masterror(...)] attribute" + )); + had_error = true; + continue; + } + + match parse_masterror_attribute(attr) { + Ok(parsed) => spec = Some(parsed), + Err(err) => { + errors.push(err); + had_error = true; + } + } + } + + if had_error { Err(()) } else { Ok(spec) } +} + fn extract_app_error_spec( attrs: &[Attribute], errors: &mut Vec @@ -734,6 +786,223 @@ fn parse_app_error_attribute(attr: &Attribute) -> Result { }) } +fn parse_masterror_attribute(attr: &Attribute) -> Result { + attr.parse_args_with(|input: ParseStream| { + let mut code = None; + let mut category = None; + let mut expose_message = false; + let mut redact_message = false; + let mut telemetry = None; + let mut map_grpc = None; + let mut map_problem = None; + + while !input.is_empty() { + let ident: Ident = input.call(Ident::parse_any)?; + match ident.to_string().as_str() { + "code" => { + if code.is_some() { + return Err(Error::new(ident.span(), "duplicate code specification")); + } + input.parse::()?; + let value: Expr = input.parse()?; + code = Some(value); + } + "category" => { + if category.is_some() { + return Err(Error::new(ident.span(), "duplicate category specification")); + } + input.parse::()?; + let value: ExprPath = input.parse()?; + category = Some(value); + } + "message" => { + if expose_message { + return Err(Error::new(ident.span(), "duplicate message flag")); + } + expose_message = parse_flag_value(input)?; + } + "redact" => { + if redact_message { + return Err(Error::new(ident.span(), "duplicate redact(...) block")); + } + redact_message = parse_redact_block(input, ident.span())?; + } + "telemetry" => { + if telemetry.is_some() { + return Err(Error::new(ident.span(), "duplicate telemetry(...) block")); + } + telemetry = Some(parse_telemetry_block(input, ident.span())?); + } + "map" => { + input.parse::()?; + let sub: Ident = input.call(Ident::parse_any)?; + match sub.to_string().as_str() { + "grpc" => { + if map_grpc.is_some() { + return Err(Error::new( + sub.span(), + "duplicate map.grpc specification" + )); + } + input.parse::()?; + let value: Expr = input.parse()?; + map_grpc = Some(value); + } + "problem" => { + if map_problem.is_some() { + return Err(Error::new( + sub.span(), + "duplicate map.problem specification" + )); + } + input.parse::()?; + let value: Expr = input.parse()?; + map_problem = Some(value); + } + other => { + return Err(Error::new( + sub.span(), + format!("unknown #[masterror] mapping `map.{other}`") + )); + } + } + } + other => { + return Err(Error::new( + ident.span(), + format!("unknown #[masterror] option `{other}`") + )); + } + } + + if input.peek(Token![,]) { + input.parse::()?; + } else if !input.is_empty() { + return Err(Error::new( + input.span(), + "expected `,` or end of input in #[masterror(...)]" + )); + } + } + + let code = match code { + Some(value) => value, + None => { + return Err(Error::new( + attr.span(), + "missing `code = ...` in #[masterror(...)]" + )); + } + }; + + let category = match category { + Some(value) => value, + None => { + return Err(Error::new( + attr.span(), + "missing `category = ...` in #[masterror(...)]" + )); + } + }; + + Ok(MasterrorSpec { + code, + category, + expose_message, + redact_message, + telemetry: telemetry.unwrap_or_default(), + map_grpc, + map_problem, + attribute_span: attr.span() + }) + }) +} + +fn parse_flag_value(input: ParseStream) -> Result { + if input.peek(Token![=]) { + input.parse::()?; + let value: LitBool = input.parse()?; + Ok(value.value) + } else { + Ok(true) + } +} + +fn parse_redact_block(input: ParseStream, span: Span) -> Result { + let content; + syn::parenthesized!(content in input); + + if content.is_empty() { + return Err(Error::new(span, "redact(...) requires at least one option")); + } + + let mut redact_message = false; + + while !content.is_empty() { + let ident: Ident = content.call(Ident::parse_any)?; + match ident.to_string().as_str() { + "message" => { + if redact_message { + return Err(Error::new(ident.span(), "duplicate redact(message) option")); + } + if content.peek(Token![=]) { + content.parse::()?; + let value: LitBool = content.parse()?; + redact_message = value.value; + } else { + redact_message = true; + } + } + other => { + return Err(Error::new( + ident.span(), + format!("unknown redact option `{other}`") + )); + } + } + + if content.peek(Token![,]) { + content.parse::()?; + } else if !content.is_empty() { + return Err(Error::new( + content.span(), + "expected `,` or end of input in redact(...)" + )); + } + } + + Ok(redact_message) +} + +fn parse_telemetry_block(input: ParseStream, span: Span) -> Result, Error> { + let content; + syn::parenthesized!(content in input); + + let mut entries = Vec::new(); + + while !content.is_empty() { + let expr: Expr = content.parse()?; + entries.push(expr); + + if content.peek(Token![,]) { + content.parse::()?; + if content.is_empty() { + return Err(Error::new( + span, + "expected expression after comma in telemetry(...)" + )); + } + } else if !content.is_empty() { + return Err(Error::new( + content.span(), + "expected `,` or end of input in telemetry(...)" + )); + } + } + + Ok(entries) +} + fn parse_error_attribute(attr: &Attribute) -> Result { mod kw { syn::custom_keyword!(transparent); diff --git a/masterror-derive/src/lib.rs b/masterror-derive/src/lib.rs index cffacd7..ded7de6 100644 --- a/masterror-derive/src/lib.rs +++ b/masterror-derive/src/lib.rs @@ -8,6 +8,7 @@ mod display; mod error_trait; mod from_impl; mod input; +mod masterror_impl; mod span; mod template_support; @@ -24,6 +25,18 @@ pub fn derive_error(tokens: TokenStream) -> TokenStream { } } +#[proc_macro_derive( + Masterror, + attributes(error, source, from, backtrace, masterror, provide) +)] +pub fn derive_masterror(tokens: TokenStream) -> TokenStream { + let input = parse_macro_input!(tokens as DeriveInput); + match expand_masterror(input) { + Ok(stream) => stream.into(), + Err(err) => err.to_compile_error().into() + } +} + fn expand(input: DeriveInput) -> Result { let parsed = input::parse_input(input)?; let display_impl = display::expand(&parsed)?; @@ -38,3 +51,18 @@ fn expand(input: DeriveInput) -> Result { #(#app_error_impls)* }) } + +fn expand_masterror(input: DeriveInput) -> Result { + let parsed = input::parse_input(input)?; + let display_impl = display::expand(&parsed)?; + let error_impl = error_trait::expand(&parsed)?; + let from_impls = from_impl::expand(&parsed)?; + let masterror_impl = masterror_impl::expand(&parsed)?; + + Ok(quote! { + #display_impl + #error_impl + #(#from_impls)* + #masterror_impl + }) +} diff --git a/masterror-derive/src/masterror_impl.rs b/masterror-derive/src/masterror_impl.rs new file mode 100644 index 0000000..e51b7d0 --- /dev/null +++ b/masterror-derive/src/masterror_impl.rs @@ -0,0 +1,508 @@ +use proc_macro2::{Ident, TokenStream}; +use quote::{format_ident, quote}; +use syn::{Error, Expr, ExprPath, Index}; + +use crate::input::{ + ErrorData, ErrorInput, Field, Fields, MasterrorSpec, StructData, VariantData, is_option_type +}; + +pub fn expand(input: &ErrorInput) -> Result { + match &input.data { + ErrorData::Struct(data) => expand_struct(input, data), + ErrorData::Enum(variants) => expand_enum(input, variants) + } +} + +fn expand_struct(input: &ErrorInput, data: &StructData) -> Result { + let spec = data.masterror.as_ref().ok_or_else(|| { + Error::new( + input.ident.span(), + "#[derive(Masterror)] requires #[masterror(...)] on structs" + ) + })?; + + let conversion = struct_conversion_impl(input, data, spec); + let mappings = struct_mapping_impl(input, spec); + + Ok(quote! { + #conversion + #mappings + }) +} + +fn expand_enum(input: &ErrorInput, variants: &[VariantData]) -> Result { + ensure_all_variants_have_masterror(variants)?; + + let conversion = enum_conversion_impl(input, variants); + let mappings = enum_mapping_impl(input, variants); + + Ok(quote! { + #conversion + #mappings + }) +} + +fn ensure_all_variants_have_masterror(variants: &[VariantData]) -> Result<(), Error> { + for variant in variants { + if variant.masterror.is_none() { + return Err(Error::new( + variant.span, + "all variants must use #[masterror(...)] to derive masterror::Error conversion" + )); + } + } + Ok(()) +} + +struct BoundField<'a> { + field: &'a Field, + binding: Ident +} + +fn struct_conversion_impl( + input: &ErrorInput, + data: &StructData, + spec: &MasterrorSpec +) -> TokenStream { + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + let code = &spec.code; + let category = &spec.category; + + let message_init = message_initialization(spec.expose_message, quote!(&value)); + let (destructure, bound_fields) = bind_struct_fields(ident, &data.fields); + let field_usage = field_usage_tokens(&bound_fields); + let telemetry_init = telemetry_initialization(&spec.telemetry); + let metadata_attach = metadata_attach_tokens(); + let redact_tokens = redact_tokens(spec.redact_message); + let source_tokens = source_attachment_tokens(&bound_fields); + let backtrace_tokens = backtrace_attachment_tokens(&data.fields, &bound_fields); + + quote! { + impl #impl_generics core::convert::From<#ident #ty_generics> for masterror::Error #where_clause { + fn from(value: #ident #ty_generics) -> Self { + #message_init + #destructure + #field_usage + #telemetry_init + let mut __masterror_error = match __masterror_message { + Some(message) => masterror::Error::with((#category), message), + None => masterror::Error::bare((#category)) + }; + __masterror_error = __masterror_error.with_code((#code)); + #metadata_attach + #redact_tokens + #source_tokens + #backtrace_tokens + __masterror_error + } + } + } +} + +fn enum_conversion_impl(input: &ErrorInput, variants: &[VariantData]) -> TokenStream { + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let mut arms = Vec::new(); + + let mut message_arms = Vec::new(); + + for variant in variants { + let spec = variant.masterror.as_ref().expect("presence checked"); + let code = &spec.code; + let category = &spec.category; + let (pattern, bound_fields) = bind_variant_fields(ident, variant); + let field_usage = field_usage_tokens(&bound_fields); + let telemetry_init = telemetry_initialization(&spec.telemetry); + let metadata_attach = metadata_attach_tokens(); + let redact_tokens = redact_tokens(spec.redact_message); + let source_tokens = source_attachment_tokens(&bound_fields); + let backtrace_tokens = backtrace_attachment_tokens(&variant.fields, &bound_fields); + message_arms.push(enum_message_arm(ident, variant, spec.expose_message)); + + arms.push(quote! { + #pattern => { + #field_usage + #telemetry_init + let mut __masterror_error = match __masterror_message { + Some(message) => masterror::Error::with((#category), message), + None => masterror::Error::bare((#category)) + }; + __masterror_error = __masterror_error.with_code((#code)); + #metadata_attach + #redact_tokens + #source_tokens + #backtrace_tokens + __masterror_error + } + }); + } + + let message_match = quote! { + let __masterror_message: Option = match &value { + #(#message_arms)* + }; + }; + + quote! { + impl #impl_generics core::convert::From<#ident #ty_generics> for masterror::Error #where_clause { + fn from(value: #ident #ty_generics) -> Self { + #message_match + match value { + #(#arms),* + } + } + } + } +} + +fn enum_message_arm( + enum_ident: &Ident, + variant: &VariantData, + expose_message: bool +) -> TokenStream { + if expose_message { + let binding = format_ident!("__masterror_variant_ref"); + let pattern = enum_message_pattern(enum_ident, variant, Some(&binding)); + quote! { + #pattern => Some(std::string::ToString::to_string(#binding)), + } + } else { + let pattern = enum_message_pattern(enum_ident, variant, None); + quote! { + #pattern => None, + } + } +} + +fn enum_message_pattern( + enum_ident: &Ident, + variant: &VariantData, + binding: Option<&Ident> +) -> TokenStream { + let variant_ident = &variant.ident; + match (&variant.fields, binding) { + (Fields::Unit, Some(binding)) => quote!(#binding @ #enum_ident::#variant_ident), + (Fields::Unit, None) => quote!(#enum_ident::#variant_ident), + (Fields::Named(_), Some(binding)) => quote!(#binding @ #enum_ident::#variant_ident { .. }), + (Fields::Named(_), None) => quote!(#enum_ident::#variant_ident { .. }), + (Fields::Unnamed(_), Some(binding)) => quote!(#binding @ #enum_ident::#variant_ident(..)), + (Fields::Unnamed(_), None) => quote!(#enum_ident::#variant_ident(..)) + } +} + +fn field_usage_tokens(bound_fields: &[BoundField<'_>]) -> TokenStream { + if bound_fields.is_empty() { + return TokenStream::new(); + } + + let names = bound_fields.iter().map(|field| &field.binding); + quote! { + let _ = (#(&#names),*); + } +} + +fn struct_mapping_impl(input: &ErrorInput, spec: &MasterrorSpec) -> TokenStream { + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + let code = &spec.code; + let category = &spec.category; + let grpc_mapping = + mapping_option_tokens(spec.map_grpc.as_ref(), code, category, MappingKind::Grpc); + let problem_mapping = mapping_option_tokens( + spec.map_problem.as_ref(), + code, + category, + MappingKind::Problem + ); + + quote! { + impl #impl_generics #ident #ty_generics #where_clause { + /// HTTP mapping for this error type. + pub const HTTP_MAPPING: masterror::mapping::HttpMapping = + masterror::mapping::HttpMapping::new((#code), (#category)); + + /// gRPC mapping for this error type. + pub const GRPC_MAPPING: Option = #grpc_mapping; + + /// Problem JSON mapping for this error type. + pub const PROBLEM_MAPPING: Option = #problem_mapping; + } + } +} + +fn enum_mapping_impl(input: &ErrorInput, variants: &[VariantData]) -> TokenStream { + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let http_entries: Vec<_> = variants + .iter() + .map(|variant| { + let spec = variant.masterror.as_ref().expect("presence checked"); + let code = &spec.code; + let category = &spec.category; + quote!(masterror::mapping::HttpMapping::new((#code), (#category))) + }) + .collect(); + + let grpc_entries: Vec<_> = variants + .iter() + .filter_map(|variant| { + let spec = variant.masterror.as_ref().expect("presence checked"); + let code = &spec.code; + let category = &spec.category; + spec.map_grpc.as_ref().map( + |expr| quote!(masterror::mapping::GrpcMapping::new((#code), (#category), (#expr))) + ) + }) + .collect(); + + let problem_entries: Vec<_> = variants + .iter() + .filter_map(|variant| { + let spec = variant.masterror.as_ref().expect("presence checked"); + let code = &spec.code; + let category = &spec.category; + spec.map_problem.as_ref().map(|expr| { + quote!(masterror::mapping::ProblemMapping::new((#code), (#category), (#expr))) + }) + }) + .collect(); + + let http_len = Index::from(http_entries.len()); + + let grpc_slice = if grpc_entries.is_empty() { + quote!(&[] as &[masterror::mapping::GrpcMapping]) + } else { + quote!(&[#(#grpc_entries),*]) + }; + + let problem_slice = if problem_entries.is_empty() { + quote!(&[] as &[masterror::mapping::ProblemMapping]) + } else { + quote!(&[#(#problem_entries),*]) + }; + + quote! { + impl #impl_generics #ident #ty_generics #where_clause { + /// HTTP mappings for enum variants. + pub const HTTP_MAPPINGS: [masterror::mapping::HttpMapping; #http_len] = [#(#http_entries),*]; + + /// gRPC mappings for enum variants. + pub const GRPC_MAPPINGS: &'static [masterror::mapping::GrpcMapping] = #grpc_slice; + + /// Problem JSON mappings for enum variants. + pub const PROBLEM_MAPPINGS: &'static [masterror::mapping::ProblemMapping] = #problem_slice; + } + } +} + +fn message_initialization(enabled: bool, value: TokenStream) -> TokenStream { + if enabled { + quote! { + let __masterror_message = Some(std::string::ToString::to_string(#value)); + } + } else { + quote! { + let __masterror_message: Option = None; + } + } +} + +fn bind_struct_fields<'a>( + ident: &Ident, + fields: &'a Fields +) -> (TokenStream, Vec>) { + match fields { + Fields::Unit => (quote!(let _ = value;), Vec::new()), + Fields::Named(list) => { + let mut pattern = Vec::new(); + let mut bound = Vec::new(); + for field in list { + let binding = binding_ident(field); + let pattern_binding = binding.clone(); + pattern.push(quote!(#pattern_binding)); + bound.push(BoundField { + field, + binding + }); + } + let pattern_tokens = quote!(let #ident { #(#pattern),* } = value;); + (pattern_tokens, bound) + } + Fields::Unnamed(list) => { + let mut pattern = Vec::new(); + let mut bound = Vec::new(); + for field in list { + let binding = binding_ident(field); + let pattern_binding = binding.clone(); + pattern.push(quote!(#pattern_binding)); + bound.push(BoundField { + field, + binding + }); + } + let pattern_tokens = quote!(let #ident(#(#pattern),*) = value;); + (pattern_tokens, bound) + } + } +} + +fn bind_variant_fields<'a>( + enum_ident: &Ident, + variant: &'a VariantData +) -> (TokenStream, Vec>) { + let variant_ident = &variant.ident; + + match &variant.fields { + Fields::Unit => (quote!(#enum_ident::#variant_ident), Vec::new()), + Fields::Named(list) => { + let mut pattern = Vec::new(); + let mut bound = Vec::new(); + for field in list { + let binding = binding_ident(field); + let pattern_binding = binding.clone(); + pattern.push(quote!(#pattern_binding)); + bound.push(BoundField { + field, + binding + }); + } + (quote!(#enum_ident::#variant_ident { #(#pattern),* }), bound) + } + Fields::Unnamed(list) => { + let mut pattern = Vec::new(); + let mut bound = Vec::new(); + for field in list { + let binding = binding_ident(field); + let pattern_binding = binding.clone(); + pattern.push(quote!(#pattern_binding)); + bound.push(BoundField { + field, + binding + }); + } + (quote!(#enum_ident::#variant_ident(#(#pattern),*)), bound) + } + } +} + +fn telemetry_initialization(entries: &[Expr]) -> TokenStream { + if entries.is_empty() { + quote!(let __masterror_metadata: Option = None;) + } else { + let inserts = entries.iter().map(|expr| { + quote! { + if let Some(field) = (#expr) { + __masterror_metadata_inner.insert(field); + } + } + }); + quote! { + let mut __masterror_metadata_inner = masterror::Metadata::new(); + #(#inserts)* + let __masterror_metadata = if __masterror_metadata_inner.is_empty() { + None + } else { + Some(__masterror_metadata_inner) + }; + } + } +} + +fn metadata_attach_tokens() -> TokenStream { + quote! { + if let Some(metadata) = __masterror_metadata { + __masterror_error = __masterror_error.with_metadata(metadata); + } + } +} + +fn redact_tokens(enabled: bool) -> TokenStream { + if enabled { + quote!( + __masterror_error = __masterror_error.redactable(); + ) + } else { + TokenStream::new() + } +} + +fn source_attachment_tokens(bound_fields: &[BoundField<'_>]) -> TokenStream { + for bound in bound_fields { + if bound.field.attrs.has_source() { + let binding = &bound.binding; + if is_option_type(&bound.field.ty) { + return quote! { + if let Some(source) = #binding { + __masterror_error = __masterror_error.with_source(source); + } + }; + } else { + return quote! { + __masterror_error = __masterror_error.with_source(#binding); + }; + } + } + } + TokenStream::new() +} + +fn backtrace_attachment_tokens(fields: &Fields, bound_fields: &[BoundField<'_>]) -> TokenStream { + let Some(backtrace_field) = fields.backtrace_field() else { + return TokenStream::new(); + }; + let index = backtrace_field.index(); + let Some(binding) = bound_fields + .iter() + .find(|bound| bound.field.index == index) + .map(|bound| &bound.binding) + else { + return TokenStream::new(); + }; + + if is_option_type(&backtrace_field.field().ty) { + quote! { + if let Some(trace) = #binding { + __masterror_error = __masterror_error.with_backtrace(trace); + } + } + } else { + quote! { + __masterror_error = __masterror_error.with_backtrace(#binding); + } + } +} + +#[derive(Clone, Copy)] +enum MappingKind { + Grpc, + Problem +} + +fn mapping_option_tokens( + expr: Option<&Expr>, + code: &Expr, + category: &ExprPath, + kind: MappingKind +) -> TokenStream { + match expr { + Some(value) => match kind { + MappingKind::Grpc => { + quote!(Some(masterror::mapping::GrpcMapping::new((#code), (#category), (#value)))) + } + MappingKind::Problem => { + quote!(Some(masterror::mapping::ProblemMapping::new((#code), (#category), (#value)))) + } + }, + None => quote!(None) + } +} + +fn binding_ident(field: &Field) -> Ident { + field + .ident + .clone() + .unwrap_or_else(|| format_ident!("__field{}", field.index, span = field.span)) +} diff --git a/src/app_error/core.rs b/src/app_error/core.rs index 6b27f33..3787d9e 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -11,20 +11,15 @@ use super::metadata::{Field, Metadata}; use crate::{AppCode, AppErrorKind, RetryAdvice}; /// Controls whether the public message may be redacted before exposure. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum MessageEditPolicy { /// Message must be preserved as-is. + #[default] Preserve, /// Message may be redacted or replaced at the transport boundary. Redact } -impl Default for MessageEditPolicy { - fn default() -> Self { - Self::Preserve - } -} - /// Rich application error preserving domain code, taxonomy and metadata. #[derive(Debug)] pub struct Error { diff --git a/src/lib.rs b/src/lib.rs index 67dbcd2..b5653b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -84,6 +84,75 @@ //! Use `#[provide]` to forward typed telemetry that downstream consumers can //! extract from [`AppError`] via `std::error::Request`. //! +//! ## Masterror derive: end-to-end domain errors +//! +//! `#[derive(Masterror)]` builds on top of `#[derive(Error)]`, wiring a domain +//! error directly into [`struct@crate::Error`] with typed telemetry, redaction +//! policy and transport hints. The `#[masterror(...)]` attribute mirrors the +//! `thiserror` style while keeping redaction decisions and metadata in one +//! place. +//! +//! ```rust +//! use masterror::{ +//! AppCode, AppErrorKind, Error, Masterror, MessageEditPolicy, mapping::HttpMapping +//! }; +//! +//! #[derive(Debug, Masterror)] +//! #[error("user {user_id} missing flag {flag}")] +//! #[masterror( +//! code = AppCode::NotFound, +//! category = AppErrorKind::NotFound, +//! message, +//! redact(message), +//! telemetry( +//! Some(masterror::field::str("user_id", user_id.clone())), +//! attempt.map(|value| masterror::field::u64("attempt", value)) +//! ), +//! map.grpc = 5, +//! map.problem = "https://errors.example.com/not-found" +//! )] +//! struct MissingFlag { +//! user_id: String, +//! flag: &'static str, +//! attempt: Option, +//! #[source] +//! source: Option +//! } +//! +//! let err = MissingFlag { +//! user_id: "alice".into(), +//! flag: "beta", +//! attempt: Some(2), +//! source: None +//! }; +//! let converted: Error = err.into(); +//! assert_eq!(converted.code, AppCode::NotFound); +//! assert_eq!(converted.kind, AppErrorKind::NotFound); +//! assert_eq!(converted.edit_policy, MessageEditPolicy::Redact); +//! assert!(converted.metadata().get("user_id").is_some()); +//! assert_eq!( +//! MissingFlag::HTTP_MAPPING, +//! HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) +//! ); +//! ``` +//! +//! - `code` — public [`AppCode`]. +//! - `category` — semantic [`AppErrorKind`]. +//! - `message` — expose the formatted [`core::fmt::Display`] output as the +//! public message. +//! - `redact(message)` — mark the message as redactable at the transport +//! boundary. +//! - `telemetry(...)` — list of expressions producing +//! `Option` to be inserted into [`Metadata`]. +//! - `map.grpc` / `map.problem` — optional gRPC status (as `i32`) and +//! problem+json type for generated mapping tables. Access them via +//! `TYPE::HTTP_MAPPING`, `TYPE::GRPC_MAPPING`/`MAPPINGS` and +//! `TYPE::PROBLEM_MAPPING`/`MAPPINGS`. +//! +//! The derive continues to honour `#[from]`, `#[source]` and `#[backtrace]` +//! field attributes, automatically attaching sources and captured backtraces to +//! the resulting [`struct@Error`]. +//! //! # Domain integrations: Turnkey //! //! With the `turnkey` feature enabled, the crate exports a `turnkey` module @@ -246,12 +315,15 @@ pub mod turnkey; /// Minimal prelude re-exporting core types for handler signatures. pub mod prelude; +/// Transport mapping descriptors for generated domain errors. +pub mod mapping; + pub use app_error::{ AppError, AppResult, Context, Error, Field, FieldValue, MessageEditPolicy, Metadata, field }; pub use code::AppCode; pub use kind::AppErrorKind; -/// Re-export derive macros so users only depend on [`masterror`]. +/// Re-export derive macros so users only depend on this crate. /// /// # Examples /// @@ -277,6 +349,6 @@ pub use kind::AppErrorKind; /// .into(); /// assert!(matches!(code, AppCode::BadRequest)); /// ``` -pub use masterror_derive::*; +pub use masterror_derive::{Error, Masterror}; pub use response::{ErrorResponse, RetryAdvice}; pub use result_ext::ResultExt; diff --git a/src/mapping.rs b/src/mapping.rs new file mode 100644 index 0000000..8b56f3d --- /dev/null +++ b/src/mapping.rs @@ -0,0 +1,129 @@ +//! Transport mapping descriptors generated by `#[derive(Masterror)]`. +//! +//! The derive macro produces compile-time tables describing how each domain +//! error maps to transport-specific representations. Use these helpers to +//! integrate with HTTP, gRPC or RFC 7807 problem+json responses without +//! duplicating per-variant logic. + +use crate::{AppCode, AppErrorKind}; + +/// HTTP mapping for a domain error. +/// +/// Stores the stable public [`AppCode`] and semantic [`AppErrorKind`]. The +/// HTTP status code can be derived from the kind via +/// [`AppErrorKind::http_status`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HttpMapping { + code: AppCode, + kind: AppErrorKind +} + +impl HttpMapping { + /// Create a new HTTP mapping entry. + #[must_use] + pub const fn new(code: AppCode, kind: AppErrorKind) -> Self { + Self { + code, + kind + } + } + + /// Stable machine-readable error code. + #[must_use] + pub const fn code(&self) -> AppCode { + self.code + } + + /// Semantic application error category. + #[must_use] + pub const fn kind(&self) -> AppErrorKind { + self.kind + } + + /// Derive the HTTP status code from the error kind. + #[must_use] + pub fn status(&self) -> u16 { + self.kind.http_status() + } +} + +/// gRPC mapping for a domain error. +/// +/// Stores the [`AppCode`], [`AppErrorKind`] and a gRPC status code (as `i32`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GrpcMapping { + code: AppCode, + kind: AppErrorKind, + status: i32 +} + +impl GrpcMapping { + /// Create a new gRPC mapping entry. + #[must_use] + pub const fn new(code: AppCode, kind: AppErrorKind, status: i32) -> Self { + Self { + code, + kind, + status + } + } + + /// Stable machine-readable error code. + #[must_use] + pub const fn code(&self) -> AppCode { + self.code + } + + /// Semantic application error category. + #[must_use] + pub const fn kind(&self) -> AppErrorKind { + self.kind + } + + /// gRPC status code (matching `tonic::Code` discriminants). + #[must_use] + pub const fn status(&self) -> i32 { + self.status + } +} + +/// RFC 7807 problem+json mapping. +/// +/// Associates an error with the [`AppCode`], [`AppErrorKind`] and a canonical +/// problem `type` URI. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProblemMapping { + code: AppCode, + kind: AppErrorKind, + r#type: &'static str +} + +impl ProblemMapping { + /// Create a new problem+json mapping entry. + #[must_use] + pub const fn new(code: AppCode, kind: AppErrorKind, type_uri: &'static str) -> Self { + Self { + code, + kind, + r#type: type_uri + } + } + + /// Stable machine-readable error code. + #[must_use] + pub const fn code(&self) -> AppCode { + self.code + } + + /// Semantic application error category. + #[must_use] + pub const fn kind(&self) -> AppErrorKind { + self.kind + } + + /// Canonical problem `type` URI. + #[must_use] + pub const fn type_uri(&self) -> &'static str { + self.r#type + } +} diff --git a/tests/error_derive_from_trybuild.rs b/tests/error_derive_from_trybuild.rs index 0879942..8f267bf 100644 --- a/tests/error_derive_from_trybuild.rs +++ b/tests/error_derive_from_trybuild.rs @@ -41,3 +41,15 @@ fn app_error_attribute_compile_failures() { let t = TestCases::new(); t.compile_fail("tests/ui/app_error/fail/*.rs"); } + +#[test] +fn masterror_attribute_passes() { + let t = TestCases::new(); + t.pass("tests/ui/masterror/pass/*.rs"); +} + +#[test] +fn masterror_attribute_compile_failures() { + let t = TestCases::new(); + t.compile_fail("tests/ui/masterror/fail/*.rs"); +} diff --git a/tests/masterror_macro.rs b/tests/masterror_macro.rs new file mode 100644 index 0000000..e9815e0 --- /dev/null +++ b/tests/masterror_macro.rs @@ -0,0 +1,157 @@ +#![allow(non_shorthand_field_patterns)] + +use masterror::{ + AppCode, AppErrorKind, Error as MasterrorError, Masterror, MessageEditPolicy, + mapping::{GrpcMapping, HttpMapping, ProblemMapping} +}; + +#[derive(Debug, Masterror)] +#[error("missing feature flag {flag}")] +#[masterror( + code = AppCode::NotFound, + category = AppErrorKind::NotFound, + message, + redact(message), + telemetry( + Some(masterror::field::str("user_id", user_id.clone())), + attempt.map(|value| masterror::field::u64("attempt", value)) + ), + map.grpc = 5, + map.problem = "https://errors.example.com/not-found" +)] +struct MissingFlag { + user_id: String, + flag: &'static str, + attempt: Option, + #[source] + source: Option +} + +#[derive(Debug, Masterror)] +enum ApiError { + #[error("invalid payload: {details}")] + #[masterror( + code = AppCode::BadRequest, + category = AppErrorKind::BadRequest, + message, + telemetry(Some(masterror::field::str("details", details))), + map.problem = "https://errors.example.com/bad-request" + )] + BadPayload { + details: &'static str, + #[allow(non_shorthand_field_patterns)] + #[source] + _source: std::io::Error + }, + #[error("storage offline")] + #[masterror( + code = AppCode::Service, + category = AppErrorKind::Service, + telemetry(), + map.grpc = 14 + )] + StorageOffline +} + +#[test] +fn struct_masterror_conversion_populates_metadata_and_source() { + let source = std::io::Error::new(std::io::ErrorKind::Other, "backend down"); + let err = MissingFlag { + user_id: "alice".into(), + flag: "beta", + attempt: Some(3), + source: Some(source) + }; + + let converted: MasterrorError = err.into(); + + assert_eq!(converted.code, AppCode::NotFound); + assert_eq!(converted.kind, AppErrorKind::NotFound); + assert_eq!(converted.edit_policy, MessageEditPolicy::Redact); + assert!( + converted + .message + .as_deref() + .is_some_and(|message| message.contains("beta")) + ); + + let user_id = converted + .metadata() + .get("user_id") + .and_then(|value| match value { + masterror::FieldValue::Str(value) => Some(value.as_ref()), + _ => None + }); + assert_eq!(user_id, Some("alice")); + + let attempt = converted + .metadata() + .get("attempt") + .and_then(|value| match value { + masterror::FieldValue::U64(value) => Some(*value), + _ => None + }); + assert_eq!(attempt, Some(3)); + + assert!(converted.source_ref().is_some()); + + assert_eq!( + MissingFlag::HTTP_MAPPING, + HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) + ); + assert_eq!(MissingFlag::HTTP_MAPPING.status(), 404); + + let grpc = MissingFlag::GRPC_MAPPING.expect("grpc mapping"); + assert_eq!(grpc.status(), 5); + assert_eq!(grpc.kind(), AppErrorKind::NotFound); + + let problem = MissingFlag::PROBLEM_MAPPING.expect("problem mapping"); + assert_eq!(problem.type_uri(), "https://errors.example.com/not-found"); +} + +#[test] +fn enum_masterror_conversion_handles_variants() { + let io_error = std::io::Error::new(std::io::ErrorKind::InvalidInput, "format"); + let payload = ApiError::BadPayload { + details: "missing field", + _source: io_error + }; + + let converted: MasterrorError = payload.into(); + assert_eq!(converted.code, AppCode::BadRequest); + assert_eq!(converted.kind, AppErrorKind::BadRequest); + assert!(converted.metadata().get("details").is_some_and( + |value| matches!(value, masterror::FieldValue::Str(detail) if detail == "missing field") + )); + assert!(converted.source_ref().is_some()); + + let offline: MasterrorError = ApiError::StorageOffline.into(); + assert_eq!(offline.code, AppCode::Service); + assert_eq!(offline.kind, AppErrorKind::Service); + assert!(offline.metadata().is_empty()); + + assert_eq!(ApiError::HTTP_MAPPINGS.len(), 2); + assert!( + ApiError::HTTP_MAPPINGS + .iter() + .any(|mapping| mapping.kind() == AppErrorKind::BadRequest) + ); + + assert_eq!( + ApiError::GRPC_MAPPINGS, + &[GrpcMapping::new( + AppCode::Service, + AppErrorKind::Service, + 14 + )] + ); + + assert_eq!( + ApiError::PROBLEM_MAPPINGS, + &[ProblemMapping::new( + AppCode::BadRequest, + AppErrorKind::BadRequest, + "https://errors.example.com/bad-request" + )] + ); +} diff --git a/tests/ui/app_error/fail/enum_missing_variant.stderr b/tests/ui/app_error/fail/enum_missing_variant.stderr index d000de1..bbc297c 100644 --- a/tests/ui/app_error/fail/enum_missing_variant.stderr +++ b/tests/ui/app_error/fail/enum_missing_variant.stderr @@ -1,8 +1,9 @@ error: all variants must use #[app_error(...)] to derive AppError conversion --> tests/ui/app_error/fail/enum_missing_variant.rs:8:5 | -8 | #[error("without")] - | ^ +8 | / #[error("without")] +9 | | Without, + | |___________^ warning: unused import: `AppErrorKind` --> tests/ui/app_error/fail/enum_missing_variant.rs:1:17 @@ -10,4 +11,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Error}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/app_error/fail/missing_code.stderr b/tests/ui/app_error/fail/missing_code.stderr index 70ccade..4f02301 100644 --- a/tests/ui/app_error/fail/missing_code.stderr +++ b/tests/ui/app_error/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: AppCode conversion requires `code = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_code.rs:9:5 | 9 | #[app_error(kind = AppErrorKind::Service)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/app_error/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Error}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/app_error/fail/missing_kind.stderr b/tests/ui/app_error/fail/missing_kind.stderr index c615e98..021c135 100644 --- a/tests/ui/app_error/fail/missing_kind.stderr +++ b/tests/ui/app_error/fail/missing_kind.stderr @@ -2,4 +2,4 @@ error: missing `kind = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_kind.rs:5:1 | 5 | #[app_error(message)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/duplicate_fmt.stderr b/tests/ui/formatter/fail/duplicate_fmt.stderr index 5b08225..5b8f363 100644 --- a/tests/ui/formatter/fail/duplicate_fmt.stderr +++ b/tests/ui/formatter/fail/duplicate_fmt.stderr @@ -2,4 +2,4 @@ error: duplicate `fmt` handler specified --> tests/ui/formatter/fail/duplicate_fmt.rs:4:36 | 4 | #[error(fmt = crate::format_error, fmt = crate::format_error)] - | ^^^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/implicit_after_named.stderr b/tests/ui/formatter/fail/implicit_after_named.stderr index d416399..be76742 100644 --- a/tests/ui/formatter/fail/implicit_after_named.stderr +++ b/tests/ui/formatter/fail/implicit_after_named.stderr @@ -8,4 +8,5 @@ error: multiple unused formatting arguments | argument never used | argument never used | + = note: consider adding 2 format specifiers = note: this error originates in the derive macro `Error` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/formatter/fail/unsupported_flag.stderr b/tests/ui/formatter/fail/unsupported_flag.stderr index d7acdb1..b8bf229 100644 --- a/tests/ui/formatter/fail/unsupported_flag.stderr +++ b/tests/ui/formatter/fail/unsupported_flag.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..11 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_flag.rs:4:9 + --> tests/ui/formatter/fail/unsupported_flag.rs:4:10 | 4 | #[error("{value:##x}")] - | ^^^^^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/unsupported_formatter.stderr b/tests/ui/formatter/fail/unsupported_formatter.stderr index 5869420..a6a40c2 100644 --- a/tests/ui/formatter/fail/unsupported_formatter.stderr +++ b/tests/ui/formatter/fail/unsupported_formatter.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_formatter.rs:4:9 + --> tests/ui/formatter/fail/unsupported_formatter.rs:4:10 | 4 | #[error("{value:y}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_binary.stderr b/tests/ui/formatter/fail/uppercase_binary.stderr index bbe04b4..3d332c7 100644 --- a/tests/ui/formatter/fail/uppercase_binary.stderr +++ b/tests/ui/formatter/fail/uppercase_binary.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_binary.rs:4:9 + --> tests/ui/formatter/fail/uppercase_binary.rs:4:10 | 4 | #[error("{value:B}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_pointer.stderr b/tests/ui/formatter/fail/uppercase_pointer.stderr index 2c30e71..0bd10fa 100644 --- a/tests/ui/formatter/fail/uppercase_pointer.stderr +++ b/tests/ui/formatter/fail/uppercase_pointer.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_pointer.rs:4:9 + --> tests/ui/formatter/fail/uppercase_pointer.rs:4:10 | 4 | #[error("{value:P}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/masterror/fail/duplicate_attr.rs b/tests/ui/masterror/fail/duplicate_attr.rs new file mode 100644 index 0000000..026649f --- /dev/null +++ b/tests/ui/masterror/fail/duplicate_attr.rs @@ -0,0 +1,9 @@ +use masterror::{AppCode, AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +#[error("dup")] +#[masterror(code = AppCode::Internal, category = AppErrorKind::Internal)] +#[masterror(code = AppCode::Internal, category = AppErrorKind::Internal)] +struct Duplicate; + +fn main() {} diff --git a/tests/ui/masterror/fail/duplicate_attr.stderr b/tests/ui/masterror/fail/duplicate_attr.stderr new file mode 100644 index 0000000..113a10d --- /dev/null +++ b/tests/ui/masterror/fail/duplicate_attr.stderr @@ -0,0 +1,13 @@ +error: duplicate #[masterror(...)] attribute + --> tests/ui/masterror/fail/duplicate_attr.rs:6:1 + | +6 | #[masterror(code = AppCode::Internal, category = AppErrorKind::Internal)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +warning: unused imports: `AppCode` and `AppErrorKind` + --> tests/ui/masterror/fail/duplicate_attr.rs:1:17 + | +1 | use masterror::{AppCode, AppErrorKind, Masterror}; + | ^^^^^^^ ^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/duplicate_telemetry.rs b/tests/ui/masterror/fail/duplicate_telemetry.rs new file mode 100644 index 0000000..fb0b43d --- /dev/null +++ b/tests/ui/masterror/fail/duplicate_telemetry.rs @@ -0,0 +1,13 @@ +use masterror::{AppCode, AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +#[error("oops")] +#[masterror( + code = AppCode::Internal, + category = AppErrorKind::Internal, + telemetry(), + telemetry() +)] +struct DuplicateTelemetry; + +fn main() {} diff --git a/tests/ui/masterror/fail/duplicate_telemetry.stderr b/tests/ui/masterror/fail/duplicate_telemetry.stderr new file mode 100644 index 0000000..9ada290 --- /dev/null +++ b/tests/ui/masterror/fail/duplicate_telemetry.stderr @@ -0,0 +1,13 @@ +error: duplicate telemetry(...) block + --> tests/ui/masterror/fail/duplicate_telemetry.rs:9:5 + | +9 | telemetry() + | ^^^^^^^^^ + +warning: unused imports: `AppCode` and `AppErrorKind` + --> tests/ui/masterror/fail/duplicate_telemetry.rs:1:17 + | +1 | use masterror::{AppCode, AppErrorKind, Masterror}; + | ^^^^^^^ ^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/empty_redact.rs b/tests/ui/masterror/fail/empty_redact.rs new file mode 100644 index 0000000..0a4c67d --- /dev/null +++ b/tests/ui/masterror/fail/empty_redact.rs @@ -0,0 +1,8 @@ +use masterror::{AppCode, AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +#[error("oops")] +#[masterror(code = AppCode::Internal, category = AppErrorKind::Internal, redact())] +struct EmptyRedact; + +fn main() {} diff --git a/tests/ui/masterror/fail/empty_redact.stderr b/tests/ui/masterror/fail/empty_redact.stderr new file mode 100644 index 0000000..fd151cc --- /dev/null +++ b/tests/ui/masterror/fail/empty_redact.stderr @@ -0,0 +1,13 @@ +error: redact(...) requires at least one option + --> tests/ui/masterror/fail/empty_redact.rs:5:74 + | +5 | #[masterror(code = AppCode::Internal, category = AppErrorKind::Internal, redact())] + | ^^^^^^ + +warning: unused imports: `AppCode` and `AppErrorKind` + --> tests/ui/masterror/fail/empty_redact.rs:1:17 + | +1 | use masterror::{AppCode, AppErrorKind, Masterror}; + | ^^^^^^^ ^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/enum_missing_variant.rs b/tests/ui/masterror/fail/enum_missing_variant.rs new file mode 100644 index 0000000..d6ae160 --- /dev/null +++ b/tests/ui/masterror/fail/enum_missing_variant.rs @@ -0,0 +1,12 @@ +use masterror::{AppCode, AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +enum Mixed { + #[error("with")] + #[masterror(code = AppCode::Internal, category = AppErrorKind::Internal)] + With, + #[error("missing")] + Missing +} + +fn main() {} diff --git a/tests/ui/masterror/fail/enum_missing_variant.stderr b/tests/ui/masterror/fail/enum_missing_variant.stderr new file mode 100644 index 0000000..5a25e12 --- /dev/null +++ b/tests/ui/masterror/fail/enum_missing_variant.stderr @@ -0,0 +1,14 @@ +error: all variants must use #[masterror(...)] to derive masterror::Error conversion + --> tests/ui/masterror/fail/enum_missing_variant.rs:8:5 + | +8 | / #[error("missing")] +9 | | Missing + | |___________^ + +warning: unused imports: `AppCode` and `AppErrorKind` + --> tests/ui/masterror/fail/enum_missing_variant.rs:1:17 + | +1 | use masterror::{AppCode, AppErrorKind, Masterror}; + | ^^^^^^^ ^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/missing_attr.rs b/tests/ui/masterror/fail/missing_attr.rs new file mode 100644 index 0000000..92197f2 --- /dev/null +++ b/tests/ui/masterror/fail/missing_attr.rs @@ -0,0 +1,7 @@ +use masterror::Masterror; + +#[derive(Debug, Masterror)] +#[error("no attribute")] +struct Missing; + +fn main() {} diff --git a/tests/ui/masterror/fail/missing_attr.stderr b/tests/ui/masterror/fail/missing_attr.stderr new file mode 100644 index 0000000..3c757ed --- /dev/null +++ b/tests/ui/masterror/fail/missing_attr.stderr @@ -0,0 +1,5 @@ +error: #[derive(Masterror)] requires #[masterror(...)] on structs + --> tests/ui/masterror/fail/missing_attr.rs:5:8 + | +5 | struct Missing; + | ^^^^^^^ diff --git a/tests/ui/masterror/fail/missing_category.rs b/tests/ui/masterror/fail/missing_category.rs new file mode 100644 index 0000000..2b8a52c --- /dev/null +++ b/tests/ui/masterror/fail/missing_category.rs @@ -0,0 +1,8 @@ +use masterror::{AppCode, Masterror}; + +#[derive(Debug, Masterror)] +#[error("oops")] +#[masterror(code = AppCode::Internal)] +struct MissingCategory; + +fn main() {} diff --git a/tests/ui/masterror/fail/missing_category.stderr b/tests/ui/masterror/fail/missing_category.stderr new file mode 100644 index 0000000..bdadf45 --- /dev/null +++ b/tests/ui/masterror/fail/missing_category.stderr @@ -0,0 +1,13 @@ +error: missing `category = ...` in #[masterror(...)] + --> tests/ui/masterror/fail/missing_category.rs:5:1 + | +5 | #[masterror(code = AppCode::Internal)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +warning: unused import: `AppCode` + --> tests/ui/masterror/fail/missing_category.rs:1:17 + | +1 | use masterror::{AppCode, Masterror}; + | ^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/missing_code.rs b/tests/ui/masterror/fail/missing_code.rs new file mode 100644 index 0000000..72c21e0 --- /dev/null +++ b/tests/ui/masterror/fail/missing_code.rs @@ -0,0 +1,8 @@ +use masterror::{AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +#[error("oops")] +#[masterror(category = AppErrorKind::Internal)] +struct MissingCode; + +fn main() {} diff --git a/tests/ui/masterror/fail/missing_code.stderr b/tests/ui/masterror/fail/missing_code.stderr new file mode 100644 index 0000000..037fac8 --- /dev/null +++ b/tests/ui/masterror/fail/missing_code.stderr @@ -0,0 +1,13 @@ +error: missing `code = ...` in #[masterror(...)] + --> tests/ui/masterror/fail/missing_code.rs:5:1 + | +5 | #[masterror(category = AppErrorKind::Internal)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +warning: unused import: `AppErrorKind` + --> tests/ui/masterror/fail/missing_code.rs:1:17 + | +1 | use masterror::{AppErrorKind, Masterror}; + | ^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/unknown_option.rs b/tests/ui/masterror/fail/unknown_option.rs new file mode 100644 index 0000000..67aa586 --- /dev/null +++ b/tests/ui/masterror/fail/unknown_option.rs @@ -0,0 +1,8 @@ +use masterror::{AppCode, AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +#[error("oops")] +#[masterror(code = AppCode::Internal, category = AppErrorKind::Internal, unknown)] +struct Unknown; + +fn main() {} diff --git a/tests/ui/masterror/fail/unknown_option.stderr b/tests/ui/masterror/fail/unknown_option.stderr new file mode 100644 index 0000000..1822edf --- /dev/null +++ b/tests/ui/masterror/fail/unknown_option.stderr @@ -0,0 +1,13 @@ +error: unknown #[masterror] option `unknown` + --> tests/ui/masterror/fail/unknown_option.rs:5:74 + | +5 | #[masterror(code = AppCode::Internal, category = AppErrorKind::Internal, unknown)] + | ^^^^^^^ + +warning: unused imports: `AppCode` and `AppErrorKind` + --> tests/ui/masterror/fail/unknown_option.rs:1:17 + | +1 | use masterror::{AppCode, AppErrorKind, Masterror}; + | ^^^^^^^ ^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/pass/struct.rs b/tests/ui/masterror/pass/struct.rs new file mode 100644 index 0000000..19c2b5d --- /dev/null +++ b/tests/ui/masterror/pass/struct.rs @@ -0,0 +1,19 @@ +use masterror::{AppCode, AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +#[error("simple {value}")] +#[masterror( + code = AppCode::Internal, + category = AppErrorKind::Internal, + telemetry(), + map.problem = "urn:example:internal" +)] +struct Simple { + value: u8 +} + +fn main() { + let err = Simple { value: 1 }; + let converted: masterror::Error = err.into(); + assert_eq!(converted.code, AppCode::Internal); +}