Skip to content
Merged

444 #455

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<div align="right">

Expand Down Expand Up @@ -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<u64> {
None::<u64>.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));
~~~

</details>

<details>
Expand Down
21 changes: 20 additions & 1 deletion README.template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<div align="right">

Expand Down Expand Up @@ -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<u64> {
None::<u64>.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));
~~~

</details>

<details>
Expand Down
6 changes: 6 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
91 changes: 84 additions & 7 deletions src/macros.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<u64> {
/// let found = None::<u64>;
/// 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)+))
};
}
101 changes: 101 additions & 0 deletions tests/app_error_macro.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
//
// 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<u64> {
None::<u64>.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<i64> {
input
.parse::<i64>()
.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));
}
Loading