From fb82ecd359afd1d779b2eee70d12bfc1cd98b0ef Mon Sep 17 00:00:00 2001
From: RA <70325462+RAprogramm@users.noreply.github.com>
Date: Wed, 24 Sep 2025 16:38:02 +0700
Subject: [PATCH] Add typed control-flow macros for early error returns
---
CHANGELOG.md | 11 +++++
Cargo.lock | 2 +-
Cargo.toml | 2 +-
README.md | 32 +++++++++++++-
README.ru.md | 29 ++++++++++++-
README.template.md | 28 ++++++++++++
src/lib.rs | 30 +++++++++++++
src/macros.rs | 100 +++++++++++++++++++++++++++++++++++++++++++
tests/ensure_fail.rs | 78 +++++++++++++++++++++++++++++++++
9 files changed, 306 insertions(+), 6 deletions(-)
create mode 100644 src/macros.rs
create mode 100644 tests/ensure_fail.rs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9909129..dd9e8f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+## [0.21.0] - 2025-10-08
+
+### Added
+- Introduced typed `ensure!` and `fail!` macros as allocation-free alternatives
+ to `anyhow::ensure!`/`anyhow::bail!`, complete with documentation and tests.
+
+### Changed
+- Highlighted the new control-flow macros across the English and Russian
+ READMEs and module documentation so adopters discover them alongside the
+ derive tooling.
+
## [0.20.7] - 2025-10-07
### Fixed
diff --git a/Cargo.lock b/Cargo.lock
index 8d14c1d..b71bd79 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1727,7 +1727,7 @@ dependencies = [
[[package]]
name = "masterror"
-version = "0.20.7"
+version = "0.21.0"
dependencies = [
"actix-web",
"axum 0.8.4",
diff --git a/Cargo.toml b/Cargo.toml
index d79a866..ffeffb4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "masterror"
-version = "0.20.7"
+version = "0.21.0"
rust-version = "1.90"
edition = "2024"
license = "MIT OR Apache-2.0"
diff --git a/README.md b/README.md
index cd31c96..57a881b 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,8 @@ of redaction and metadata.
- **Turnkey defaults.** The `turnkey` module ships a ready-to-use error catalog,
helper builders and tracing instrumentation 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.
### Workspace crates
@@ -71,9 +73,9 @@ The build script keeps the full feature snippet below in sync with
~~~toml
[dependencies]
-masterror = { version = "0.20.7", default-features = false }
+masterror = { version = "0.21.0", default-features = false }
# or with features:
-# masterror = { version = "0.20.7", features = [
+# masterror = { version = "0.21.0", features = [
# "axum", "actix", "openapi", "serde_json",
# "tracing", "metrics", "backtrace", "sqlx",
# "sqlx-migrate", "reqwest", "redis", "validator",
@@ -114,6 +116,32 @@ fn do_work(flag: bool) -> AppResult<()> {
+
+ Fail fast without sacrificing typing
+
+`ensure!` and `fail!` provide typed alternatives to the formatting-heavy
+`anyhow::ensure!`/`anyhow::bail!` helpers. They evaluate the error expression
+only when the guard trips, so success paths stay allocation-free.
+
+~~~rust
+use masterror::{AppError, AppErrorKind, AppResult};
+
+fn guard(flag: bool) -> AppResult<()> {
+ masterror::ensure!(flag, AppError::bad_request("flag must be set"));
+ Ok(())
+}
+
+fn bail() -> AppResult<()> {
+ masterror::fail!(AppError::unauthorized("token expired"));
+}
+
+assert!(guard(true).is_ok());
+assert!(matches!(guard(false).unwrap_err().kind, AppErrorKind::BadRequest));
+assert!(matches!(bail().unwrap_err().kind, AppErrorKind::Unauthorized));
+~~~
+
+
+
Derive domain errors and map them to transports
diff --git a/README.ru.md b/README.ru.md
index 2c90dfc..a3d5331 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -39,6 +39,8 @@ MSRV зафиксирован, а родные деривы позволяют
- **Готовые настройки.** Модуль `turnkey` поставляет готовый каталог ошибок,
билдеры и интеграцию с `tracing` для команд, которым нужна стартовая
конфигурация «из коробки».
+- **Типобезопасные макросы управления потоком.** `ensure!` и `fail!` прерывают
+ выполнение с доменной ошибкой без аллокаций и форматирования на удачной ветке.
## Состав workspace
@@ -65,9 +67,9 @@ MSRV зафиксирован, а родные деривы позволяют
~~~toml
[dependencies]
-masterror = { version = "0.20.5", default-features = false }
+masterror = { version = "0.21.0", default-features = false }
# или с нужными фичами:
-# masterror = { version = "0.20.5", features = [
+# masterror = { version = "0.21.0", features = [
# "axum", "actix", "openapi", "serde_json",
# "tracing", "metrics", "backtrace", "sqlx",
# "sqlx-migrate", "reqwest", "redis", "validator",
@@ -105,6 +107,29 @@ fn do_work(flag: bool) -> AppResult<()> {
}
~~~
+### Макросы для раннего возврата без потери типизации
+
+`ensure!` и `fail!` — типизированные аналоги `anyhow::ensure!`/`anyhow::bail!`.
+Они вычисляют выражение ошибки только при срабатывании гварда, поэтому
+успешный путь остаётся без аллокаций.
+
+~~~rust
+use masterror::{AppError, AppErrorKind, AppResult};
+
+fn guard(flag: bool) -> AppResult<()> {
+ masterror::ensure!(flag, AppError::bad_request("Флаг обязателен"));
+ Ok(())
+}
+
+fn bail() -> AppResult<()> {
+ masterror::fail!(AppError::unauthorized("Токен истёк"));
+}
+
+assert!(guard(true).is_ok());
+assert!(matches!(guard(false).unwrap_err().kind, AppErrorKind::BadRequest));
+assert!(matches!(bail().unwrap_err().kind, AppErrorKind::Unauthorized));
+~~~
+
### Деривы для доменных ошибок и транспорта
`masterror` предоставляет родные деривы, чтобы типы оставались выразительными, а
diff --git a/README.template.md b/README.template.md
index 1f743b3..74dc2d7 100644
--- a/README.template.md
+++ b/README.template.md
@@ -42,6 +42,8 @@ of redaction and metadata.
- **Turnkey defaults.** The `turnkey` module ships a ready-to-use error catalog,
helper builders and tracing instrumentation 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.
### Workspace crates
@@ -110,6 +112,32 @@ fn do_work(flag: bool) -> AppResult<()> {
+
+ Fail fast without sacrificing typing
+
+`ensure!` and `fail!` provide typed alternatives to the formatting-heavy
+`anyhow::ensure!`/`anyhow::bail!` helpers. They evaluate the error expression
+only when the guard trips, so success paths stay allocation-free.
+
+~~~rust
+use masterror::{AppError, AppErrorKind, AppResult};
+
+fn guard(flag: bool) -> AppResult<()> {
+ masterror::ensure!(flag, AppError::bad_request("flag must be set"));
+ Ok(())
+}
+
+fn bail() -> AppResult<()> {
+ masterror::fail!(AppError::unauthorized("token expired"));
+}
+
+assert!(guard(true).is_ok());
+assert!(matches!(guard(false).unwrap_err().kind, AppErrorKind::BadRequest));
+assert!(matches!(bail().unwrap_err().kind, AppErrorKind::Unauthorized));
+~~~
+
+
+
Derive domain errors and map them to transports
diff --git a/src/lib.rs b/src/lib.rs
index b91d32d..dff5e40 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -246,6 +246,35 @@
//! assert!(matches!(resp.code, AppCode::NotFound));
//! ```
//!
+//! # Typed control-flow macros
+//!
+//! Reach for [`ensure!`] and [`fail!`] when you need to exit early with a typed
+//! error without paying for string formatting or heap allocations on the
+//! success path.
+//!
+//! ```rust
+//! use masterror::{AppError, AppErrorKind, AppResult};
+//!
+//! fn guard(flag: bool) -> AppResult<()> {
+//! masterror::ensure!(flag, AppError::bad_request("flag must be set"));
+//! Ok(())
+//! }
+//!
+//! fn bail() -> AppResult<()> {
+//! masterror::fail!(AppError::unauthorized("token expired"));
+//! }
+//!
+//! assert!(guard(true).is_ok());
+//! assert!(matches!(
+//! guard(false).unwrap_err().kind,
+//! AppErrorKind::BadRequest
+//! ));
+//! assert!(matches!(
+//! bail().unwrap_err().kind,
+//! AppErrorKind::Unauthorized
+//! ));
+//! ```
+//!
//! # Axum integration
//!
//! With the `axum` feature enabled, you can return [`AppError`] directly from
@@ -302,6 +331,7 @@ mod code;
mod convert;
pub mod error;
mod kind;
+mod macros;
#[cfg(error_generic_member_access)]
#[doc(hidden)]
pub mod provide;
diff --git a/src/macros.rs b/src/macros.rs
new file mode 100644
index 0000000..321f9a0
--- /dev/null
+++ b/src/macros.rs
@@ -0,0 +1,100 @@
+//! Control-flow macros for early returns with 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.
+//!
+//! ```rust
+//! use masterror::{AppError, AppErrorKind, AppResult};
+//!
+//! fn guard(flag: bool) -> AppResult<()> {
+//! masterror::ensure!(flag, AppError::bad_request("flag must be true"));
+//! Ok(())
+//! }
+//!
+//! assert!(guard(true).is_ok());
+//! assert!(matches!(
+//! guard(false).unwrap_err().kind,
+//! AppErrorKind::BadRequest
+//! ));
+//! ```
+
+/// Abort the enclosing function with an error when a condition fails.
+///
+/// The macro takes either a bare condition and error expression, or the more
+/// explicit `cond = ..., else = ...` form. The error expression is evaluated
+/// lazily only when the condition is false.
+///
+/// # Examples
+///
+/// Short-circuit a typed error:
+///
+/// ```rust
+/// use masterror::{AppError, AppErrorKind, AppResult};
+///
+/// fn require(flag: bool) -> AppResult<()> {
+/// masterror::ensure!(flag, AppError::bad_request("flag required"));
+/// Ok(())
+/// }
+///
+/// assert!(matches!(
+/// require(false).unwrap_err().kind,
+/// AppErrorKind::BadRequest
+/// ));
+/// ```
+///
+/// Use the verbose syntax for clarity in complex conditions:
+///
+/// ```rust
+/// use masterror::{AppError, AppResult};
+///
+/// fn bounded(value: i32, max: i32) -> AppResult<()> {
+/// masterror::ensure!(
+/// cond = value <= max,
+/// else = AppError::service("value too large")
+/// );
+/// Ok(())
+/// }
+///
+/// assert!(bounded(2, 3).is_ok());
+/// assert!(bounded(5, 3).is_err());
+/// ```
+#[macro_export]
+macro_rules! ensure {
+ (cond = $cond:expr, else = $err:expr $(,)?) => {
+ $crate::ensure!($cond, $err)
+ };
+ ($cond:expr, $err:expr $(,)?) => {
+ if !$cond {
+ return Err($err);
+ }
+ };
+}
+
+/// Abort the enclosing function with the provided error.
+///
+/// This macro is a typed alternative to `anyhow::bail!`, delegating the
+/// decision of how to construct the error to the caller. It never performs
+/// formatting or allocations on the success path.
+///
+/// # Examples
+///
+/// ```rust
+/// use masterror::{AppError, AppErrorKind, AppResult};
+///
+/// fn reject() -> AppResult<()> {
+/// masterror::fail!(AppError::unauthorized("token expired"));
+/// }
+///
+/// let err = reject().unwrap_err();
+/// assert!(matches!(err.kind, AppErrorKind::Unauthorized));
+/// ```
+#[macro_export]
+macro_rules! fail {
+ ($err:expr $(,)?) => {
+ return Err($err);
+ };
+}
diff --git a/tests/ensure_fail.rs b/tests/ensure_fail.rs
new file mode 100644
index 0000000..992dffc
--- /dev/null
+++ b/tests/ensure_fail.rs
@@ -0,0 +1,78 @@
+use core::sync::atomic::{AtomicUsize, Ordering};
+
+use masterror::{AppError, AppErrorKind, AppResult};
+
+static CALLS: AtomicUsize = AtomicUsize::new(0);
+
+#[test]
+fn ensure_allows_success_path() {
+ fn run(flag: bool) -> AppResult<&'static str> {
+ masterror::ensure!(flag, AppError::bad_request("flag required"));
+ Ok("ok")
+ }
+
+ assert_eq!(run(true).unwrap(), "ok");
+}
+
+#[test]
+fn ensure_yields_error_once() {
+ fn build_error() -> AppError {
+ CALLS.fetch_add(1, Ordering::SeqCst);
+ AppError::service("bounded")
+ }
+
+ fn run(flag: bool) -> AppResult<()> {
+ masterror::ensure!(cond = flag, else = build_error());
+ Ok(())
+ }
+
+ CALLS.store(0, Ordering::SeqCst);
+ assert!(run(false).is_err());
+ assert_eq!(CALLS.load(Ordering::SeqCst), 1);
+
+ CALLS.store(0, Ordering::SeqCst);
+ assert!(run(true).is_ok());
+ assert_eq!(CALLS.load(Ordering::SeqCst), 0);
+}
+
+#[test]
+fn ensure_preserves_error_kind() {
+ fn run(flag: bool) -> AppResult<()> {
+ masterror::ensure!(flag, AppError::unauthorized("token expired"));
+ Ok(())
+ }
+
+ let err = run(false).unwrap_err();
+ assert!(matches!(err.kind, AppErrorKind::Unauthorized));
+}
+
+#[test]
+fn fail_returns_error() {
+ fn run() -> AppResult<()> {
+ masterror::fail!(AppError::forbidden("admin only"));
+ }
+
+ let err = run().unwrap_err();
+ assert!(matches!(err.kind, AppErrorKind::Forbidden));
+}
+
+#[derive(Debug, PartialEq, Eq)]
+struct CustomError(&'static str);
+
+type CustomResult = Result;
+
+#[test]
+fn macros_work_with_custom_error_types() {
+ fn guard(flag: bool) -> CustomResult<&'static str> {
+ masterror::ensure!(flag, CustomError("custom failure"));
+ Ok("ok")
+ }
+
+ fn bail() -> CustomResult<()> {
+ masterror::fail!(CustomError("fail"));
+ }
+
+ assert_eq!(guard(true).unwrap(), "ok");
+ assert_eq!(guard(false).unwrap_err(), CustomError("custom failure"));
+ assert_eq!(bail().unwrap_err(), CustomError("fail"));
+}