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")); +}