diff --git a/README.md b/README.md index b0621d7..83823ec 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,9 @@ of redaction and metadata. a heuristic classifier and conservative mappings into the canonical taxonomy for teams that want a consistent baseline out of the box. - **Typed control-flow macros.** `ensure!` and `fail!` short-circuit functions - with your domain errors without allocating or formatting on the happy path. + with your domain errors without allocating or formatting on the happy path, + while `app_error!` builds ad-hoc `AppError` values with `format!`-style + messages in expression position.
@@ -333,6 +335,23 @@ assert!(matches!(guard(false).unwrap_err().kind, AppErrorKind::BadRequest)); assert!(matches!(bail().unwrap_err().kind, AppErrorKind::Unauthorized)); ~~~ +`app_error!` is the `anyhow::anyhow!` counterpart: an expression macro that +builds an `AppError` from a kind and an optional `format!`-style message with +implicit capture. The kind-only form performs no allocation; the message form +allocates exactly once. + +~~~rust +use masterror::{AppErrorKind, AppResult, app_error}; + +fn find(id: u64) -> AppResult { + None::.ok_or_else(|| app_error!(AppErrorKind::NotFound, "no entity {id}")) +} + +let bare = app_error!(AppErrorKind::Timeout); +assert!(bare.message.is_none()); +assert!(matches!(find(7).unwrap_err().kind, AppErrorKind::NotFound)); +~~~ +
diff --git a/README.template.md b/README.template.md index b1ec72b..448747f 100644 --- a/README.template.md +++ b/README.template.md @@ -95,7 +95,9 @@ of redaction and metadata. a heuristic classifier and conservative mappings into the canonical taxonomy for teams that want a consistent baseline out of the box. - **Typed control-flow macros.** `ensure!` and `fail!` short-circuit functions - with your domain errors without allocating or formatting on the happy path. + with your domain errors without allocating or formatting on the happy path, + while `app_error!` builds ad-hoc `AppError` values with `format!`-style + messages in expression position.
@@ -328,6 +330,23 @@ assert!(matches!(guard(false).unwrap_err().kind, AppErrorKind::BadRequest)); assert!(matches!(bail().unwrap_err().kind, AppErrorKind::Unauthorized)); ~~~ +`app_error!` is the `anyhow::anyhow!` counterpart: an expression macro that +builds an `AppError` from a kind and an optional `format!`-style message with +implicit capture. The kind-only form performs no allocation; the message form +allocates exactly once. + +~~~rust +use masterror::{AppErrorKind, AppResult, app_error}; + +fn find(id: u64) -> AppResult { + None::.ok_or_else(|| app_error!(AppErrorKind::NotFound, "no entity {id}")) +} + +let bare = app_error!(AppErrorKind::Timeout); +assert!(bare.message.is_none()); +assert!(matches!(find(7).unwrap_err().kind, AppErrorKind::NotFound)); +~~~ +
diff --git a/src/lib.rs b/src/lib.rs index ec0a9f2..c7b7f31 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -364,6 +364,12 @@ mod macros; #[cfg(masterror_has_error_generic_member_access)] #[doc(hidden)] pub mod provide; + +/// Implementation detail of exported macros. Not part of the public API. +#[doc(hidden)] +pub mod __private { + pub use alloc::format; +} mod response; mod result_ext; diff --git a/src/macros.rs b/src/macros.rs index 46282c1..eb039ae 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -2,14 +2,20 @@ // // SPDX-License-Identifier: MIT -//! Control-flow macros for early returns with typed errors. +//! Control-flow and construction macros for typed errors. //! -//! These macros complement the typed [`AppError`](crate::AppError) APIs by -//! providing a lightweight, allocation-free way to short-circuit functions when -//! invariants are violated. Unlike the dynamic formatting helpers offered by -//! `anyhow` or `eyre`, the macros operate on pre-constructed error values so -//! the compiler keeps strong typing guarantees and no formatting work happens -//! on the success path. +//! The control-flow macros ([`ensure!`](crate::ensure) and +//! [`fail!`](crate::fail)) complement the typed [`AppError`](crate::AppError) +//! APIs by providing a lightweight, allocation-free way to short-circuit +//! functions when invariants are violated. Unlike the dynamic formatting +//! helpers offered by `anyhow` or `eyre`, they operate on pre-constructed +//! error values so the compiler keeps strong typing guarantees and no +//! formatting work happens on the success path. +//! +//! The expression macro [`app_error!`](crate::app_error) covers the ad-hoc +//! construction side: it builds an [`AppError`](crate::AppError) from a kind +//! and an optional format-style message, mirroring `anyhow::anyhow!` while +//! staying inside the typed taxonomy. //! //! ```rust //! use masterror::{AppError, AppErrorKind, AppResult}; @@ -102,3 +108,74 @@ macro_rules! fail { return Err($err); }; } + +/// Construct an [`AppError`](crate::AppError) expression from a kind and an +/// optional format-style message. +/// +/// This macro is the typed counterpart of `anyhow::anyhow!`. It evaluates to +/// an [`AppError`](crate::AppError) value, so it can be used anywhere an +/// expression is expected: `ok_or_else`, `map_err`, `return Err(...)` or as +/// the argument to [`fail!`](crate::fail). +/// +/// Two forms are supported: +/// +/// - `app_error!(kind)` expands to [`AppError::bare`](crate::AppError::bare) +/// and performs no allocation. +/// - `app_error!(kind, "format {args}")` expands to +/// [`AppError::with`](crate::AppError::with) with a message built by +/// [`format!`](alloc::format), including implicit named-argument capture. +/// Exactly one allocation happens, driven by `format_args!`. +/// +/// # Examples +/// +/// Kind-only, allocation-free: +/// +/// ```rust +/// use masterror::{AppErrorKind, app_error}; +/// +/// let err = app_error!(AppErrorKind::Timeout); +/// assert!(matches!(err.kind, AppErrorKind::Timeout)); +/// assert!(err.message.is_none()); +/// ``` +/// +/// Formatted message with implicit capture: +/// +/// ```rust +/// use masterror::{AppErrorKind, app_error}; +/// +/// let value = 42; +/// let err = app_error!(AppErrorKind::Validation, "bad value: {value}"); +/// assert!(matches!(err.kind, AppErrorKind::Validation)); +/// assert_eq!(err.message.as_deref(), Some("bad value: 42")); +/// ``` +/// +/// Expression position and composition with [`fail!`](crate::fail): +/// +/// ```rust +/// use masterror::{AppErrorKind, AppResult, app_error}; +/// +/// fn find(id: u64) -> AppResult { +/// let found = None::; +/// let value = found.ok_or_else(|| app_error!(AppErrorKind::NotFound, "no entity {id}"))?; +/// Ok(value) +/// } +/// +/// fn reject() -> AppResult<()> { +/// masterror::fail!(app_error!(AppErrorKind::Unauthorized, "token expired")); +/// } +/// +/// assert!(matches!(find(7).unwrap_err().kind, AppErrorKind::NotFound)); +/// assert!(matches!( +/// reject().unwrap_err().kind, +/// AppErrorKind::Unauthorized +/// )); +/// ``` +#[macro_export] +macro_rules! app_error { + ($kind:expr $(,)?) => { + $crate::AppError::bare($kind) + }; + ($kind:expr, $($fmt:tt)+) => { + $crate::AppError::with($kind, $crate::__private::format!($($fmt)+)) + }; +} diff --git a/tests/app_error_macro.rs b/tests/app_error_macro.rs new file mode 100644 index 0000000..6d6cdfe --- /dev/null +++ b/tests/app_error_macro.rs @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2025 RAprogramm +// +// SPDX-License-Identifier: MIT + +use masterror::{AppCode, AppErrorKind, AppResult, app_error}; + +#[test] +fn kind_only_form_builds_bare_error() { + let err = app_error!(AppErrorKind::Timeout); + assert!(matches!(err.kind, AppErrorKind::Timeout)); + assert!(err.message.is_none()); +} + +#[test] +fn kind_only_form_accepts_trailing_comma() { + let err = app_error!(AppErrorKind::NotFound,); + assert!(matches!(err.kind, AppErrorKind::NotFound)); + assert!(err.message.is_none()); +} + +#[test] +fn formatted_form_captures_variables() { + let user_id = 42; + let err = app_error!(AppErrorKind::Validation, "invalid user {user_id}"); + assert!(matches!(err.kind, AppErrorKind::Validation)); + assert_eq!(err.message.as_deref(), Some("invalid user 42")); +} + +#[test] +fn formatted_message_equals_format_output() { + let name = "payments"; + let attempt = 3; + let err = app_error!( + AppErrorKind::ExternalApi, + "service {name} failed after {attempt} attempts" + ); + assert_eq!( + err.message.as_deref(), + Some(format!("service {name} failed after {attempt} attempts").as_str()) + ); +} + +#[test] +fn formatted_form_supports_positional_arguments() { + let err = app_error!(AppErrorKind::BadRequest, "expected {}, got {}", 1, 2); + assert_eq!(err.message.as_deref(), Some("expected 1, got 2")); +} + +#[test] +fn kind_and_code_are_derived_from_kind() { + let err = app_error!(AppErrorKind::Unauthorized, "token expired"); + assert!(matches!(err.kind, AppErrorKind::Unauthorized)); + assert_eq!(err.code, AppCode::Unauthorized); +} + +#[test] +fn works_in_ok_or_else_expression_position() { + fn find(id: u64) -> AppResult { + None::.ok_or_else(|| app_error!(AppErrorKind::NotFound, "no entity {id}")) + } + let err = find(7).unwrap_err(); + assert!(matches!(err.kind, AppErrorKind::NotFound)); + assert_eq!(err.message.as_deref(), Some("no entity 7")); +} + +#[test] +fn works_in_map_err_expression_position() { + fn parse(input: &str) -> AppResult { + input + .parse::() + .map_err(|parse_err| app_error!(AppErrorKind::BadRequest, "not a number: {parse_err}")) + } + let err = parse("abc").unwrap_err(); + assert!(matches!(err.kind, AppErrorKind::BadRequest)); + assert!( + err.message + .as_deref() + .is_some_and(|msg| msg.starts_with("not a number:")) + ); +} + +#[test] +fn composes_with_fail_macro() { + fn reject() -> AppResult<()> { + masterror::fail!(app_error!(AppErrorKind::Forbidden, "admin only")); + } + let err = reject().unwrap_err(); + assert!(matches!(err.kind, AppErrorKind::Forbidden)); + assert_eq!(err.message.as_deref(), Some("admin only")); +} + +#[test] +fn composes_with_ensure_macro() { + fn guard(flag: bool) -> AppResult<()> { + masterror::ensure!(flag, app_error!(AppErrorKind::BadRequest, "flag required")); + Ok(()) + } + assert!(guard(true).is_ok()); + let err = guard(false).unwrap_err(); + assert!(matches!(err.kind, AppErrorKind::BadRequest)); +}