diff --git a/.clang-format b/.clang-format index 7b04a92..58e6bd7 100644 --- a/.clang-format +++ b/.clang-format @@ -5,9 +5,9 @@ AccessModifierOffset: -2 AlignAfterOpenBracket: BlockIndent -AlignConsecutiveMacros: false -AlignConsecutiveAssignments: false -AlignConsecutiveDeclarations: false +AlignConsecutiveMacros: Consecutive +AlignConsecutiveAssignments: Consecutive +AlignConsecutiveDeclarations: Consecutive AlignEscapedNewlines: Right AlignOperands: false AlignTrailingComments: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28e60ce..a0eccdb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,10 +32,6 @@ jobs: with: persist-credentials: false - - name: Format check - if: matrix.os == 'ubuntu-latest' - run: find src tests -name '*.hpp' -o -name '*.cpp' | xargs clang-format --dry-run --Werror - - name: Configure env: CXX_STD: "20" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ab7452..3f0e740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,38 +2,17 @@ All notable changes to this project. -## [0.1.1] - 2026-06-01 +## [0.1.0] - 2026-06-16 ### Added -- Docstrings on public types, members, and macros -- `Failure::context` vector for wrapping human-readable messages -- `Failure::push_context()` mutator for appending context messages -- `Failure::fmt()` renders context outermost-first followed by the root error; `operator<<` wraps it -- `Expected::context(msg)` and `Expected::with_context(fn)` for lazy context attachment (both `T` and `void` specializations) -- GTest suite: 31 tests across `Expected`, context, fmt, `ScopeGuard`, and `ANYHOW_TRY*` macros - -### Changed - -- Dropped redundant `[[nodiscard]]` from `map`, `and_then`, `fail` (return type already carries it) -- `Error` renamed to `ErrorInfo`, freeing `Error` for the future type-erased error type (Phase 5) -- Member ordering standardized across all headers: constants -> fields -> constructors -> mutators -> accessors -> methods -- `Expected` value constructors are now implicit, so `return value;` works directly from a fallible function (the error path stays explicit through `Unexpected`) -- Removed `Failure::message()` / `Failure::domain()` accessors; read `failure().error.message` / `failure().error.domain` directly (`Failure` is a transparent struct) - -### Fixed - -- `ScopeGuard` move-assign (`operator=(ScopeGuard&&)`) deleted to prevent accidental reassignment -- `i++` to `++i` in `Failure::push` ring-buffer loop - -## [0.1.0] - 2026-05-31 - -### Added - -- `Expected` result type with `Unexpected` wrapper for explicit error construction -- `Failure` with ring-buffer frame storage, configurable via `ANYHOW_MAX_FRAMES` -- `Frame` capture via `std::source_location` +- `Expected` / `Result` result type with `Unexpected` for explicit error construction +- `Failure` with ring-buffer frame trace, configurable via `ANYHOW_MAX_FRAMES` +- `fail(msg, domain)` and `fail_with(payload, msg, domain)` for typed payloads +- `Failure::downcast()`, `Failure::is()`, `Failure::chain()`, `Failure::root_cause()` +- `Expected::context(msg)` / `with_context(fn)` for wrapping errors with human-readable layers +- `ANYHOW_TRY`, `ANYHOW_TRY_ASSIGN`, `ANYHOW_TRY_CATCH` for error propagation with call-site capture +- `ANYHOW_BAIL`, `ANYHOW_ENSURE` for early return +- `ANYHOW_SHORT_MACROS` opt-in aliases: `TRY`, `TRY_ASSIGN`, `TRY_CATCH`, `BAIL`, `ENSURE` - `ScopeGuard` with move support and `release()` -- `ANYHOW_TRY`, `ANYHOW_TRY_CATCH`, `ANYHOW_TRY_ASSIGN` macros for error propagation -- Short aliases via `#define ANYHOW_SHORT_MACROS` -- CMake `anyhow::anyhow` target with `find_package` and `FetchContent` support +- CMake `anyhow::anyhow` target via `FetchContent` or `find_package` diff --git a/README.md b/README.md index d17870e..2f80e56 100644 --- a/README.md +++ b/README.md @@ -42,18 +42,20 @@ target_link_libraries(your-target PRIVATE anyhow::anyhow) Include everything at once with `anyhow.hpp`, or pull in individual headers as needed. -| Header | Provides | -| ----------------- | ----------------------------------------------------- | -| `anyhow.hpp` | Includes all headers below | -| `expected.hpp` | `Expected`, `Expected` | -| `failure.hpp` | `Failure`, `Unexpected`, `fail()` | -| `macros.hpp` | `ANYHOW_TRY`, `ANYHOW_TRY_ASSIGN`, `ANYHOW_TRY_CATCH` | -| `scope_guard.hpp` | `ScopeGuard` | +| Header | Provides | +| ----------------- | ------------------------------------------------------------------------------------- | +| `anyhow.hpp` | Includes all headers below + `Result` alias | +| `expected.hpp` | `Expected`, `Expected` | +| `failure.hpp` | `Failure`, `Unexpected`, `fail()`, `fail_with()`, `chain()`, `root_cause()` | +| `macros.hpp` | `ANYHOW_TRY`, `ANYHOW_TRY_ASSIGN`, `ANYHOW_TRY_CATCH`, `ANYHOW_BAIL`, `ANYHOW_ENSURE` | +| `scope_guard.hpp` | `ScopeGuard` | > [!NOTE] -> Define `ANYHOW_SHORT_MACROS` before including `macros.hpp` to enable the short aliases `TRY`, `TRY_ASSIGN`, `TRY_CATCH`. +> Define `ANYHOW_SHORT_MACROS` before including `macros.hpp` to enable the short aliases `TRY`, `TRY_ASSIGN`, `TRY_CATCH`, `BAIL`, `ENSURE`. -Use `Expected` as the return type of any fallible function. Return `anyhow::fail(message, domain)` on failure, or return the value directly on success. +`anyhow::Result` is an alias for `Expected`, matching Rust's naming convention. + +Use `Expected` (or `Result`) as the return type of any fallible function. Return `anyhow::fail(message, domain)` on failure, or return the value directly on success. ```cpp anyhow::Expected parse(std::string_view s) { @@ -65,6 +67,23 @@ anyhow::Expected parse(std::string_view s) { } ``` +### Early return + +`ANYHOW_BAIL` returns a failure immediately. `ANYHOW_ENSURE` does the same when a condition is false. + +```cpp +anyhow::Expected parse(std::string_view s) { + ANYHOW_ENSURE(!s.empty(), "empty input", "parse"); + // ... + return 42; +} + +anyhow::Expected validate(int n) { + if (n < 0) ANYHOW_BAIL("negative value"); + return {}; +} +``` + ### Propagation Use `ANYHOW_TRY_ASSIGN` to unwrap a value or propagate the failure up. Each macro captures the current call site via `std::source_location` and pushes it onto the frame buffer, building a trace as the error travels up the stack. @@ -81,13 +100,13 @@ anyhow::Expected process(std::string_view s) { ``` ```cpp -auto r = process(""); -if (r.failed()) { - auto& f = r.failure(); - std::cout << "error [" << f.error.domain << "]: " << f.error.message << '\n'; +auto result = process(""); +if (result.failed()) { + auto& fail = result.failure(); + std::cout << "error [" << fail.error.domain << "]: " << fail.error.message << '\n'; - for (size_t i = 0; i < f.count; i++) { - std::cout << " at " << f.frames[i].function << " (" << f.frames[i].file << ':' << f.frames[i].line << ")\n"; + for (size_t i = 0; i < fail.count; i++) { + std::cout << " at " << fail.frames[i].function << " (" << fail.frames[i].file << ':' << fail.frames[i].line << ")\n"; } } ``` @@ -118,6 +137,36 @@ failed to parse config unexpected token at line 42 [parse] ``` +### Downcasting + +Attach a typed payload with `fail_with()` and recover it with `Failure::downcast()`, which returns a pointer or null. + +```cpp +anyhow::Expected open(std::string_view path) { + return anyhow::fail_with(errno, "open failed", "io"); +} + +auto result = open("/missing"); +if (result.failed()) { + if (auto* code = result.failure().downcast()) { + std::cout << "errno: " << *code << '\n'; + } +} +``` + +### Inspection + +`chain()` iterates context layers outermost-first then the root error message. `root_cause()` returns the root `ErrorInfo` directly. + +```cpp +for (auto cause : failure.chain()) { + std::cerr << cause << '\n'; +} + +const auto& root = failure.root_cause(); +std::cerr << root.message << " [" << root.domain << "]\n"; +``` + ### Chaining `map`, `and_then`, and `value_or` are available for functional chaining on results. @@ -130,13 +179,17 @@ auto result = parse("21") }); ``` +### Scope guard + `ScopeGuard` runs a callable on scope exit. Call `release()` to cancel. ```cpp auto guard = anyhow::ScopeGuard{[&] { cleanup(); }}; ``` -Override the frame buffer depth at compile time (default: `16`). When full, oldest frames are evicted. Must be defined before any anyhow include. +### Frame buffer depth + +Override at compile time (default: `16`). When full, oldest frames are evicted. Must be defined before any anyhow include. ```cpp #define ANYHOW_MAX_FRAMES 32 diff --git a/docs/vs-rust.md b/docs/vs-rust.md index ac67f00..4faa8a9 100644 --- a/docs/vs-rust.md +++ b/docs/vs-rust.md @@ -6,8 +6,11 @@ A feature-by-feature comparison between [`anyhow`](https://github.com/dtolnay/an - [Result type](#result-type) - [Error construction](#error-construction) +- [Early return](#early-return) - [Propagation](#propagation) - [Context](#context) +- [Downcasting](#downcasting) +- [Inspection](#inspection) - [Backtraces](#backtraces) ## Result type @@ -26,8 +29,13 @@ anyhow::Expected load(std::string_view path) { } ``` -> [!NOTE] -> A `Result` alias matching Rust's naming is planned but not yet shipped. +`anyhow::Result` is available as an alias for `Expected`: + +```cpp +anyhow::Result load(std::string_view path) { + // ... +} +``` ## Error construction @@ -41,6 +49,26 @@ return Err(anyhow!("empty input")); return anyhow::fail("empty input", "parse"); ``` +## Early return + +Rust's `bail!` and `ensure!` macros construct an error and return early in one step. C++ mirrors them with `ANYHOW_BAIL` and `ANYHOW_ENSURE`. + +```rust +bail!("negative value"); +ensure!(!s.is_empty(), "empty input"); +``` + +```cpp +ANYHOW_BAIL("negative value"); +ANYHOW_ENSURE(!s.empty(), "empty input"); +``` + +Both accept an optional domain tag as a second argument in C++ (no direct Rust equivalent since Rust uses typed errors for that): + +```cpp +ANYHOW_ENSURE(!s.empty(), "empty input", "parse"); +``` + ## Propagation Rust's `?` operator unwraps on success or returns the error early, converting it through `From`. C++ has no equivalent operator; propagation goes through statement macros that expand to the unwrap-or-return pattern. Each macro captures the current `std::source_location` and pushes it onto the failure's frame buffer, so the trace builds as the error travels up. @@ -57,7 +85,7 @@ ANYHOW_TRY(validate(n)); ``` > [!NOTE] -> `ANYHOW_TRY_CATCH(expr, cleanup)` has no Rust analogue -- it runs a cleanup expression before returning on failure. Define `ANYHOW_SHORT_MACROS` for the unprefixed `TRY` / `TRY_ASSIGN` / `TRY_CATCH` aliases. +> `ANYHOW_TRY_CATCH(expr, cleanup)` has no Rust analogue -- it runs a cleanup expression before returning on failure. Define `ANYHOW_SHORT_MACROS` before including `macros.hpp` to enable unprefixed aliases for all macros: `TRY`, `TRY_ASSIGN`, `TRY_CATCH`, `BAIL`, `ENSURE`. ## Context @@ -77,6 +105,48 @@ parse_file(path) Both render context outermost-first, then the root error. +## Downcasting + +Rust erases the concrete error type behind `anyhow::Error` but lets you recover it with `downcast_ref::()` / `is::()`. + +```rust +if let Some(io_err) = err.downcast_ref::() { + // ... +} +``` + +C++ has no `std::error::Error` trait, so the approach differs: attach a typed payload via `fail_with()` and recover it with `Failure::downcast()`, which returns a pointer or null. + +```cpp +return anyhow::fail_with(IoError {errno}, "read failed", "io"); + +if (auto* io = failure.downcast()) { + // ... +} +``` + +The `domain` string tag remains available for cheaper string-based discrimination when a typed payload is not needed. + +## Inspection + +Rust walks the error's source chain via `Error::chain()` and reaches the deepest cause with `root_cause()`. + +```rust +for cause in err.chain() { + eprintln!("{cause}"); +} +let root = err.root_cause(); +``` + +C++ mirrors both on `Failure`. `chain()` returns context layers outermost-first followed by the root error message. `root_cause()` returns the root `ErrorInfo` directly. + +```cpp +for (auto cause : failure.chain()) { + std::cerr << cause << '\n'; +} +const auto& root = failure.root_cause(); +``` + ## Backtraces Rust captures a `std::backtrace::Backtrace` on error creation, gated behind the `RUST_BACKTRACE` environment variable and a nightly/stable feature boundary. diff --git a/src/anyhow.hpp b/src/anyhow.hpp index 9f6d7c3..fccbb65 100644 --- a/src/anyhow.hpp +++ b/src/anyhow.hpp @@ -2,11 +2,21 @@ /// anyhow-cpp: header-only C++20 result type with propagating stacktraced errors. /// -/// Return `Expected` from fallible functions; `anyhow::fail(msg, domain)` on -/// failure. Propagate with the `ANYHOW_TRY*` macros, which push the current call -/// site onto a frame trace. See the README for usage and examples. +/// Return `Expected` (or `Result`) from fallible functions. Construct errors +/// with `fail(msg, domain)` or `fail_with(payload, msg, domain)` for typed payloads +/// recoverable via `Failure::downcast()`. Propagate with `ANYHOW_TRY*` macros, +/// which capture `std::source_location` at each call site and push it onto the frame +/// trace. Return early unconditionally with `ANYHOW_BAIL` or conditionally with +/// `ANYHOW_ENSURE`. See the README for usage and examples. #include "expected.hpp" #include "failure.hpp" #include "macros.hpp" #include "scope_guard.hpp" + +namespace anyhow { + +template +using Result = Expected; + +} // namespace anyhow diff --git a/src/error.hpp b/src/error.hpp index 89d7ef1..43e2591 100644 --- a/src/error.hpp +++ b/src/error.hpp @@ -1,13 +1,17 @@ #pragma once +#include #include namespace anyhow { -/// Root error: a human-readable message and an optional domain tag (e.g. "io", "parse"). +/// Root error: a human-readable message, an optional domain tag, and an optional typed payload. +/// +/// `payload` is empty by default. Set it via `fail_with()` and recover with `Failure::downcast()`. struct ErrorInfo { - std::string message; - std::string domain; + std::string message; ///< Human-readable description of what went wrong. + std::string domain; ///< Optional category tag (e.g. "io", "parse"). Empty if unset. + std::any payload; ///< Optional typed value for programmatic inspection. Empty if unset. }; } // namespace anyhow diff --git a/src/expected.hpp b/src/expected.hpp index a111437..628782d 100644 --- a/src/expected.hpp +++ b/src/expected.hpp @@ -74,9 +74,11 @@ class [[nodiscard]] Expected { template auto map(Fn&& func) && -> Expected> { using U = std::invoke_result_t; + if (failed()) { return Unexpected {std::move(failure())}; } + return Expected {std::forward(func)(std::move(value()))}; } @@ -86,6 +88,7 @@ class [[nodiscard]] Expected { if (failed()) { return Unexpected {std::move(failure())}; } + return std::forward(func)(std::move(value())); } @@ -94,6 +97,7 @@ class [[nodiscard]] Expected { if (failed()) { return std::move(fallback); } + return std::move(value()); } @@ -102,6 +106,7 @@ class [[nodiscard]] Expected { if (failed()) { return Unexpected {std::move(failure()).push_context(std::move(msg))}; } + return std::move(*this); } @@ -111,6 +116,7 @@ class [[nodiscard]] Expected { if (failed()) { return Unexpected {std::move(failure()).push_context(std::forward(func)())}; } + return std::move(*this); } @@ -150,6 +156,7 @@ class [[nodiscard]] Expected { if (failed()) { return Unexpected {std::move(failure())}; } + return std::forward(func)(); } @@ -158,6 +165,7 @@ class [[nodiscard]] Expected { if (failed()) { return Unexpected {std::move(failure()).push_context(std::move(msg))}; } + return {}; } @@ -167,6 +175,7 @@ class [[nodiscard]] Expected { if (failed()) { return Unexpected {std::move(failure()).push_context(std::forward(func)())}; } + return {}; } diff --git a/src/failure.hpp b/src/failure.hpp index 233509b..d68f0fe 100644 --- a/src/failure.hpp +++ b/src/failure.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -25,10 +26,10 @@ namespace anyhow { struct [[nodiscard]] Failure { static constexpr std::size_t MAX_FRAMES = ANYHOW_MAX_FRAMES; - ErrorInfo error; - std::vector context; + ErrorInfo error; + std::vector context; std::array frames {}; - uint8_t count = 0; + uint8_t count = 0; Failure() = default; @@ -36,10 +37,10 @@ struct [[nodiscard]] Failure { frames[count++] = frame; } - Failure(const Failure&) = default; + Failure(const Failure&) = default; Failure& operator=(const Failure&) = default; - Failure(Failure&&) = default; - Failure& operator=(Failure&&) = default; + Failure(Failure&&) = default; + Failure& operator=(Failure&&) = default; /// Append a frame to the trace, evicting the oldest if the buffer is full. Failure&& push(Frame frame) && { @@ -49,6 +50,7 @@ struct [[nodiscard]] Failure { for (std::size_t i = 1; i < MAX_FRAMES; ++i) { frames[i - 1] = frames[i]; } + frames[MAX_FRAMES - 1] = frame; } @@ -61,6 +63,43 @@ struct [[nodiscard]] Failure { return std::move(*this); } + /// Return a pointer to the typed payload, or null if the type doesn't match or no payload is set. + template + [[nodiscard]] T* downcast() noexcept { + return std::any_cast(&error.payload); + } + + /// Return a const pointer to the typed payload, or null if the type doesn't match or no payload is set. + template + [[nodiscard]] const T* downcast() const noexcept { + return std::any_cast(&error.payload); + } + + /// Return true if the payload holds a value of type `T`. + template + [[nodiscard]] bool is() const noexcept { + return downcast() != nullptr; + } + + /// Return the root error directly, bypassing context layers. + [[nodiscard]] const ErrorInfo& root_cause() const noexcept { + return error; + } + + /// Iterate all causes outermost-first: context layers then the root error message. + [[nodiscard]] std::vector chain() const { + // upgrade to lazy range if std::views::concat lands (C++26) or allocation matters + std::vector out; + out.reserve(context.size() + 1); + + for (const auto& layer : std::views::reverse(context)) { + out.push_back(layer); + } + + out.push_back(error.message); + return out; + } + /// Render the failure as a human-readable string. /// /// Context layers are printed outermost-first, followed by the root error. @@ -84,6 +123,7 @@ struct [[nodiscard]] Failure { } }; +/// Write the failure to a stream using `fmt()`. inline std::ostream& operator<<(std::ostream& os, const Failure& failure) { return os << failure.fmt(); } @@ -93,14 +133,14 @@ inline std::ostream& operator<<(std::ostream& os, const Failure& failure) { struct [[nodiscard]] Unexpected { Failure failure; - explicit Unexpected(Failure payload) : failure(std::move(payload)) {} + explicit Unexpected(Failure fail) : failure(std::move(fail)) {} }; /// Construct a failure with `message` and optional `domain`, capturing the call site. inline Unexpected fail( - std::string message, - std::string domain = {}, - const std::source_location loc = std::source_location::current() + std::string message, + std::string domain = {}, + const std::source_location loc = std::source_location::current() ) { return Unexpected {Failure { ErrorInfo {.message = std::move(message), .domain = std::move(domain)}, @@ -108,4 +148,20 @@ inline Unexpected fail( }}; } +/// Construct a failure with a typed payload recoverable via `Failure::downcast()`. +template +inline Unexpected fail_with( + T payload, + const std::string& message, + const std::string& domain = {}, + const std::source_location loc = std::source_location::current() +) { + // TODO: message/domain are copied from const& + // revisit if string_view or forwarding becomes viable + return Unexpected {Failure { + ErrorInfo {.message = message, .domain = domain, .payload = std::move(payload)}, + Frame::current(loc) + }}; +} + } // namespace anyhow diff --git a/src/frame.hpp b/src/frame.hpp index 3d0eaaf..3b3d497 100644 --- a/src/frame.hpp +++ b/src/frame.hpp @@ -9,7 +9,7 @@ namespace anyhow { struct Frame { const char* function; const char* file; - uint32_t line; + uint32_t line; /// Capture the caller's location. Defaulted argument resolves at the call site. static Frame diff --git a/src/macros.hpp b/src/macros.hpp index 238fdd4..409a92c 100644 --- a/src/macros.hpp +++ b/src/macros.hpp @@ -35,14 +35,25 @@ (var_out) = std::move(_r_.value()); \ } while (0) +/// Early-return a failure. +#define ANYHOW_BAIL(msg, ...) return ::anyhow::fail(msg __VA_OPT__(, ) __VA_ARGS__) + +/// Early-return a failure if `cond` is false. +#define ANYHOW_ENSURE(cond, msg, ...) \ + do { \ + if (!(cond)) \ + ANYHOW_BAIL(msg __VA_OPT__(, ) __VA_ARGS__); \ + } while (0) + // opt-in short aliases // define ANYHOW_SHORT_MACROS before including to enable -// FAIL omitted (GTest defines FAIL() and conflicts) #ifdef ANYHOW_SHORT_MACROS - #define TRY(expr) ANYHOW_TRY(expr) - #define TRY_CATCH(expr, cleanup) ANYHOW_TRY_CATCH(expr, cleanup) + #define TRY(expr) ANYHOW_TRY(expr) + #define TRY_CATCH(expr, cleanup) ANYHOW_TRY_CATCH(expr, cleanup) #define TRY_ASSIGN(var_out, expr) ANYHOW_TRY_ASSIGN(var_out, expr) + #define BAIL(msg, ...) ANYHOW_BAIL(msg __VA_OPT__(, ) __VA_ARGS__) + #define ENSURE(cond, msg, ...) ANYHOW_ENSURE(cond, msg __VA_OPT__(, ) __VA_ARGS__) #endif diff --git a/src/scope_guard.hpp b/src/scope_guard.hpp index e2c38cf..2b7dd9f 100644 --- a/src/scope_guard.hpp +++ b/src/scope_guard.hpp @@ -14,9 +14,9 @@ struct ScopeGuard { other._active = false; } - ScopeGuard(const ScopeGuard&) = delete; + ScopeGuard(const ScopeGuard&) = delete; ScopeGuard& operator=(const ScopeGuard&) = delete; - ScopeGuard& operator=(ScopeGuard&&) = delete; + ScopeGuard& operator=(ScopeGuard&&) = delete; ~ScopeGuard() noexcept { if (_active) { @@ -31,7 +31,7 @@ struct ScopeGuard { private: std::decay_t _fn; - bool _active = true; + bool _active = true; }; template diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 03d53fe..fc00a92 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,23 +13,28 @@ set(BUILD_GMOCK CACHE BOOL "" FORCE) FetchContent_MakeAvailable(googletest) -# Unit tests +# Unit tests ### set(ANYHOW_TEST_SOURCES test_expected.cpp test_context.cpp test_fmt.cpp - test_scope_guard.cpp test_macros.cpp) + test_scope_guard.cpp test_macros.cpp test_failure.cpp) add_executable(anyhow-tests ${ANYHOW_TEST_SOURCES}) target_link_libraries(anyhow-tests PRIVATE anyhow::anyhow GTest::gtest_main) target_compile_features(anyhow-tests PRIVATE cxx_std_20) -target_compile_options(anyhow-tests PRIVATE -std=c++20) target_compile_definitions(anyhow-tests PRIVATE ANYHOW_SHORT_MACROS) + +# __VA_OPT__ is C++20 but MSVC requires an explicit conformance flag to enable +# it +if(MSVC) + target_compile_options(anyhow-tests PRIVATE /Zc:preprocessor) +endif() target_include_directories(anyhow-tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) include(GoogleTest) gtest_discover_tests(anyhow-tests) -# Fuzz target (clang libFuzzer only) +# Fuzz target (clang libFuzzer only) ### option(ANYHOW_BUILD_FUZZ "Build libFuzzer fuzz targets (requires clang + -fsanitize=fuzzer)" OFF) diff --git a/tests/helpers.hpp b/tests/helpers.hpp index d9cbe43..8a24dc1 100644 --- a/tests/helpers.hpp +++ b/tests/helpers.hpp @@ -3,7 +3,7 @@ #include "anyhow.hpp" inline anyhow::Expected ok_int(int val) { - return anyhow::Expected(val); + return {val}; } inline anyhow::Expected err_int(std::string msg, std::string domain = {}) { diff --git a/tests/test_expected.cpp b/tests/test_expected.cpp index b56cd33..c00b794 100644 --- a/tests/test_expected.cpp +++ b/tests/test_expected.cpp @@ -84,7 +84,7 @@ TEST(ExpectedVoid, FailureHoldsError) { TEST(ExpectedVoid, AndThenSuccess) { bool called = false; - auto res = std::move(ok_void()).and_then([&]() -> anyhow::Expected { + auto res = std::move(ok_void()).and_then([&]() -> anyhow::Expected { called = true; return {}; }); @@ -95,7 +95,7 @@ TEST(ExpectedVoid, AndThenSuccess) { TEST(ExpectedVoid, AndThenFailurePassthrough) { bool called = false; - auto res = std::move(err_void("bad")).and_then([&]() -> anyhow::Expected { + auto res = std::move(err_void("bad")).and_then([&]() -> anyhow::Expected { called = true; return {}; }); @@ -103,3 +103,17 @@ TEST(ExpectedVoid, AndThenFailurePassthrough) { EXPECT_TRUE(res.failed()); EXPECT_FALSE(called); } + +TEST(ResultAlias, OkWorks) { + anyhow::Result res = ok_int(42); + + ASSERT_FALSE(res.failed()); + EXPECT_EQ(res.value(), 42); +} + +TEST(ResultAlias, FailWorks) { + anyhow::Result res = anyhow::fail("nope"); + + ASSERT_TRUE(res.failed()); + EXPECT_EQ(res.failure().error.message, "nope"); +} diff --git a/tests/test_failure.cpp b/tests/test_failure.cpp new file mode 100644 index 0000000..85af6d2 --- /dev/null +++ b/tests/test_failure.cpp @@ -0,0 +1,131 @@ +#include + +#include "helpers.hpp" + +struct IoError { + int code; +}; + +static anyhow::Expected fail_typed() { + return anyhow::fail_with(IoError {42}, "io failure", "io"); +} + +static anyhow::Expected fail_plain() { + return anyhow::fail("plain failure"); +} + +TEST(Failure, DowncastHitsCorrectType) { + auto res = fail_typed(); + ASSERT_TRUE(res.failed()); + + const auto* io = res.failure().downcast(); + ASSERT_NE(io, nullptr); + EXPECT_EQ(io->code, 42); +} + +TEST(Failure, DowncastWrongTypeReturnsNull) { + auto res = fail_typed(); + + ASSERT_TRUE(res.failed()); + EXPECT_EQ(res.failure().downcast(), nullptr); +} + +TEST(Failure, DowncastNoPayloadReturnsNull) { + auto res = fail_plain(); + + ASSERT_TRUE(res.failed()); + EXPECT_EQ(res.failure().downcast(), nullptr); +} + +TEST(Failure, DowncastMutableAllowsWrite) { + auto res = fail_typed(); + ASSERT_TRUE(res.failed()); + + auto* io = res.failure().downcast(); + ASSERT_NE(io, nullptr); + + io->code = 99; + EXPECT_EQ(res.failure().downcast()->code, 99); +} + +TEST(Failure, PushFillsBuffer) { + anyhow::Failure failure; + failure.error.message = "root"; + + for (std::size_t i = 0; i < anyhow::Failure::MAX_FRAMES; ++i) { + failure = std::move(failure).push(anyhow::Frame::current()); + } + + EXPECT_EQ(failure.count, anyhow::Failure::MAX_FRAMES); +} + +TEST(Failure, PushEvictsOldestWhenFull) { + anyhow::Failure failure; + failure.error.message = "root"; + + for (std::size_t i = 0; i <= anyhow::Failure::MAX_FRAMES; ++i) { + failure = std::move(failure).push(anyhow::Frame::current()); + } + + EXPECT_EQ(failure.count, anyhow::Failure::MAX_FRAMES); +} + +TEST(Failure, IsMatchesPayloadType) { + auto res = fail_typed(); + + ASSERT_TRUE(res.failed()); + EXPECT_TRUE(res.failure().is()); + EXPECT_FALSE(res.failure().is()); +} + +TEST(Failure, IsReturnsFalseWithNoPayload) { + auto res = fail_plain(); + + ASSERT_TRUE(res.failed()); + EXPECT_FALSE(res.failure().is()); +} + +TEST(Failure, RootCauseReturnsError) { + auto res = fail_plain(); + + ASSERT_TRUE(res.failed()); + EXPECT_EQ(res.failure().root_cause().message, "plain failure"); +} + +TEST(Failure, ChainNoContextYieldsRootOnly) { + auto res = fail_plain(); + ASSERT_TRUE(res.failed()); + + auto links = res.failure().chain(); + ASSERT_EQ(links.size(), 1U); + EXPECT_EQ(links[0], "plain failure"); +} + +TEST(Failure, ChainSizeIncludesAllLayers) { + auto res = std::move(err_int("root")).context("mid").context("outer"); + + ASSERT_TRUE(res.failed()); + EXPECT_EQ(res.failure().chain().size(), 3U); +} + +TEST(Failure, ChainReversedIsRootFirst) { + auto res = std::move(err_int("root")).context("mid").context("outer"); + ASSERT_TRUE(res.failed()); + + auto links = res.failure().chain(); + ASSERT_EQ(links.size(), 3U); + EXPECT_EQ(links.front(), "outer"); + EXPECT_EQ(links.back(), "root"); + EXPECT_EQ(*std::next(links.rbegin()), "mid"); +} + +TEST(Failure, ChainWithContextOutermostFirst) { + auto res = std::move(err_int("root error")).context("middle").context("outer"); + ASSERT_TRUE(res.failed()); + + auto links = res.failure().chain(); + ASSERT_EQ(links.size(), 3U); + EXPECT_EQ(links[0], "outer"); + EXPECT_EQ(links[1], "middle"); + EXPECT_EQ(links[2], "root error"); +} diff --git a/tests/test_macros.cpp b/tests/test_macros.cpp index 20d194f..a1de26c 100644 --- a/tests/test_macros.cpp +++ b/tests/test_macros.cpp @@ -33,6 +33,26 @@ static anyhow::Expected try_catch_ok(bool& cleaned) { return {0}; } +static anyhow::Expected bail_always() { + ANYHOW_BAIL("bail triggered"); + return {0}; +} + +static anyhow::Expected bail_with_domain() { + ANYHOW_BAIL("oops", "io"); + return {0}; +} + +static anyhow::Expected ensure_passes(int val) { + ANYHOW_ENSURE(val > 0, "must be positive"); + return {val}; +} + +static anyhow::Expected ensure_fails(int val) { + ANYHOW_ENSURE(val > 0, "must be positive", "validation"); + return {val}; +} + TEST(Macros, TryPropagatesFailure) { auto res = try_propagate(); @@ -56,7 +76,7 @@ TEST(Macros, TryAssignPropagatesFailure) { TEST(Macros, TryCatchRunsCleanupOnFailure) { bool cleaned = false; - auto res = try_catch_fail(cleaned); + auto res = try_catch_fail(cleaned); ASSERT_TRUE(res.failed()); EXPECT_TRUE(cleaned); @@ -64,8 +84,33 @@ TEST(Macros, TryCatchRunsCleanupOnFailure) { TEST(Macros, TryCatchSkipsCleanupOnSuccess) { bool cleaned = false; - auto res = try_catch_ok(cleaned); + auto res = try_catch_ok(cleaned); EXPECT_FALSE(res.failed()); EXPECT_FALSE(cleaned); } + +TEST(Macros, BailReturnsFailure) { + auto res = bail_always(); + ASSERT_TRUE(res.failed()); + EXPECT_EQ(res.failure().error.message, "bail triggered"); +} + +TEST(Macros, BailForwardsDomain) { + auto res = bail_with_domain(); + ASSERT_TRUE(res.failed()); + EXPECT_EQ(res.failure().error.domain, "io"); +} + +TEST(Macros, EnsurePassesWhenCondTrue) { + auto res = ensure_passes(1); + ASSERT_FALSE(res.failed()); + EXPECT_EQ(res.value(), 1); +} + +TEST(Macros, EnsureFailsWhenCondFalse) { + auto res = ensure_fails(-1); + ASSERT_TRUE(res.failed()); + EXPECT_EQ(res.failure().error.message, "must be positive"); + EXPECT_EQ(res.failure().error.domain, "validation"); +}