diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 0000000..0ad2654 --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,5 @@ +[advisories] +ignore = ["RUSTSEC-2023-0071"] # rsa / Marvin Attack; тянется опционально через sqlx-mysql, мы mysql не используем +severity_threshold = "low" +informational_warnings = ["unmaintained"] # опционально + diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..5257701 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[patch.crates-io] +masterror-derive = { path = "masterror-derive" } +masterror-template = { path = "masterror-template" } diff --git a/.github/actions/cargo-deny/action.yml b/.github/actions/cargo-deny/action.yml new file mode 100644 index 0000000..2b26f5d --- /dev/null +++ b/.github/actions/cargo-deny/action.yml @@ -0,0 +1,41 @@ +name: "Cargo Deny Check" +description: "Install and run cargo-deny against the workspace" +inputs: + version: + description: "cargo-deny crate version to install" + required: false + default: "0.18.4" + checks: + description: "Space-separated list of cargo deny check types (leave empty to run all)" + required: false + default: "advisories bans licenses sources" +runs: + using: "composite" + steps: + - name: Ensure cargo-deny + shell: bash + env: + CARGO_DENY_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + current_version="" + if command -v cargo-deny >/dev/null 2>&1; then + current_version="$(cargo-deny --version | awk '{print $2}')" + fi + if [ "$current_version" = "$CARGO_DENY_VERSION" ]; then + echo "cargo-deny $CARGO_DENY_VERSION already installed" + exit 0 + fi + echo "Installing cargo-deny $CARGO_DENY_VERSION" + cargo install cargo-deny --locked --force --version "$CARGO_DENY_VERSION" + - name: Run cargo-deny + shell: bash + env: + CHECKS: ${{ inputs.checks }} + run: | + set -euo pipefail + if [ -z "${CHECKS// }" ]; then + cargo-deny check + else + cargo-deny check ${CHECKS} + fi diff --git a/.github/workflows/reusable-ci.yml b/.github/workflows/reusable-ci.yml index 2db7d49..6bef884 100644 --- a/.github/workflows/reusable-ci.yml +++ b/.github/workflows/reusable-ci.yml @@ -182,6 +182,9 @@ jobs: cargo +${{ steps.msrv.outputs.msrv }} clippy --workspace --all-targets -- -D warnings fi + - name: Cargo deny + uses: ./.github/actions/cargo-deny + - name: Tests (MSRV) shell: bash run: | @@ -192,6 +195,12 @@ jobs: cargo +${{ steps.msrv.outputs.msrv }} test --workspace --no-fail-fast fi + - name: Install cargo-audit + run: cargo install --locked cargo-audit + + - name: Security audit + run: cargo audit --deny warnings + - name: Auto-commit README changes (any branch) if: always() run: | diff --git a/.hooks/pre-commit b/.hooks/pre-commit index 3f40663..2efd075 100755 --- a/.hooks/pre-commit +++ b/.hooks/pre-commit @@ -16,6 +16,12 @@ cargo clippy --workspace --all-targets --all-features -- -D warnings echo "🧪 Running tests (all features)..." cargo test --workspace --all-features +echo "🛡️ Running cargo audit..." +if ! command -v cargo-audit >/dev/null 2>&1; then + cargo install --locked cargo-audit >/dev/null +fi +cargo audit + # Uncomment if you want to validate SQLx offline data # echo "📦 Validating SQLx prepare..." # cargo sqlx prepare --check --workspace diff --git a/CHANGELOG.md b/CHANGELOG.md index 75aa5b7..b8c1fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,394 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -_No changes yet._ +### Documentation +- Described `#[provide]` telemetry providers and `#[app_error]` conversions with + end-to-end examples in the derive guide ([README](README.md#structured-telemetry-providers-and-apperror-mappings), + [README.ru](README.ru.md#%D0%B0%D1%82%D1%80%D0%B8%D0%B1%D1%83%D1%82%D1%8B-provide-%D0%B8-apperror)). + +## [0.10.4] - 2025-09-20 + +### Fixed +- Ensured `cargo package --locked` passes by switching workspace dependencies on + `masterror-derive` / `masterror-template` to registry entries and overriding + them locally through `.cargo/config`, keeping CI dry runs green without + breaking local development. + +## [0.10.2] - 2025-10-23 + +### Added +- Forward dynamic width and precision specifiers by emitting every declared + format argument into the generated `write!` call, so placeholders like + `{value:>width$}` and `{value:.precision$}` remain valid when deriving + `Display`. + +### Changed +- `FormatArgumentsEnv` now surfaces tokens for all named, positional and + implicit bindings—even when they are only referenced from format specs—so + width/precision values reach the formatting engine. +- `render_template`/`build_template_arguments` combine the resolved + placeholders with the full format argument list, ensuring the macro invocation + always receives the required bindings. + +### Tests +- Added UI fixtures and integration assertions covering dynamic width and + precision formatting to guard against regressions. + +### Documentation +- Documented the dynamic width/precision support alongside the formatting + guidance (including the Russian translation). + +## [0.10.1] - 2025-10-22 + +### Changed +- Relaxed template formatter parsing so only typed formatters treat `#` as the + alternate flag, allowing display placeholders such as `{value:#>4}` to round- + trip without spurious `TemplateError::InvalidFormatter` errors. + +### Tests +- Extended formatter unit tests and UI derive coverage to exercise hash-filled + display specs and ensure they parse correctly. + +### Documentation +- Documented the broader display formatter support (including `#` as a fill + character) in the templating README section. + +## [0.10.0] - 2025-10-21 + +### Added +- Preserved the raw format fragment for display-only placeholders, exposing it + through `TemplateFormatter::display_spec()`/`format_fragment()` so derived + implementations can forward `:>8`, `:.3`, and similar specifiers to + `write!`. + +### Changed +- `TemplateFormatter` now owns display specs and `TemplatePlaceholder::formatter` + returns a reference to reflect the richer formatter representation. + +### Tests +- Added a trybuild pass case and runtime assertions covering display alignment, + precision, and fill specifiers to prevent regressions. + +### Documentation +- Documented the new display formatter support in the README (including the + Russian translation) with examples showing how to recover preserved specs. + +## [0.9.0] - 2025-10-20 + +### Added +- Parsed dot-prefixed display shorthands into a projection AST so `.limits.lo`, + `.0.data`, and chained method calls like `.suggestion.as_ref().map_or_else(...)` + resolve against struct fields and variant bindings. +- Extended the `error_derive` integration suite and trybuild fixtures with + regressions covering nested projections for named and tuple variants. + +### Changed +- Shorthand resolution now builds expressions from the projection AST, preserving + raw identifiers, tuple indices, and method invocations when generating code. + +### Documentation +- Documented the richer shorthand projection support in the README and template + so downstream users know complex field/method chains are available. + +## [0.8.0] - 2025-10-14 + +### Added +- Recognised `#[provide(ref = ..., value = ...)]` on struct and enum fields, + allowing derived errors to surface domain telemetry through + `std::error::Request` alongside backtraces. + +### Changed +- `masterror-derive` now generates `provide` implementations whenever custom + telemetry is requested, forwarding `Request` values to sources and invoking + `provide_ref`/`provide_value` with proper `Option` handling. + +### Tests +- Extended the `error_derive` integration suite with regressions covering + telemetry provided by structs, tuple variants and optional fields, including + both reference and owned payloads. + +### Documentation +- Documented the `#[provide(...)]` attribute in the README with examples showing + reference and owned telemetry as well as optional fields. + +## [0.7.0] - 2025-10-13 + +### Added +- Recognised `#[app_error(...)]` on derived structs and enum variants, capturing + the mapped `AppErrorKind`, optional `AppCode` and whether the formatted + `Display` output should become the public message. +- Generated `From` implementations that construct `masterror::AppError` + (and, when requested, `AppCode`) by matching on enum variants and invoking + `AppError::with`/`AppError::bare`. + +### Tests +- Introduced trybuild fixtures covering successful struct/enum conversions and + compile failures for missing metadata, including message propagation checks in + the passing cases. + +### Documentation +- Documented the `#[app_error(...)]` attribute in the README, outlining the + struct and enum mapping patterns and the `message` flag behaviour. + +## [0.6.5] - 2025-10-12 + +### Added +- Accepted `.field` and `.0` shorthand expressions in `#[error("...")]` format + argument lists, resolving them against struct and variant fields without + moving the original values. + +### Changed +- The format argument resolver now tracks whether it operates on a struct or a + destructured enum variant, allowing field shorthands to reuse local bindings + and honour pointer formatting requirements. + +### Tests +- Added trybuild pass cases covering named, positional and implicit arguments, + formatter path handlers and the new field shorthand expressions. +- Introduced compile-fail fixtures for duplicate argument names, mixing + implicit placeholders after explicitly indexed ones and combining + `transparent` with `fmt` handlers. +- Extended the runtime `error_derive` suite with assertions exercising the + shorthand field accessors. + +## [0.6.4] - 2025-10-11 + +### Added +- Exposed an internal `provide` shim that mirrors `thiserror`'s + `ThiserrorProvide`, enabling derived errors to forward + `core::error::Request` values to their sources. + +### Changed +- Allow `#[backtrace]` to be paired with `#[source]`/`#[from]` fields when the + field type implements `Error`, while retaining diagnostics for incompatible + non-source fields. +- Track whether backtrace detection is explicit or inferred so generated + implementations avoid providing the same backtrace twice when delegating to + sources. +- Update the generated `provide` methods to call `thiserror_provide` on source + fields before exposing the stored backtrace, ensuring delegated traces reach + callers. + +### Tests +- Added regression tests covering direct and optional sources annotated with + `#[backtrace]`, validating delegated backtrace propagation and `None` + handling. + +## [0.6.3] - 2025-10-10 + +### Added +- Invoke custom `#[error(fmt = )]` handlers for structs and enum variants, + borrowing fields and forwarding the formatter reference just like `thiserror`. + +### Changed +- Ensure duplicate `fmt` attributes report a single diagnostic without + suppressing the derived display implementation. + +### Tests +- Extend the formatter trybuild suite with success cases covering struct and + enum formatter paths. + +## [0.6.2] - 2025-10-09 + +### Added +- Resolve `#[error("...")]` format arguments when generating `Display` + implementations, supporting named bindings, explicit indices and implicit + placeholders via a shared argument environment. + +### Changed +- Detect additional format arguments, implicit placeholders and non-`Display` + formatters in `render_template`, delegating complex cases to a single + `write!` invocation while retaining the lightweight `f.write_str` path for + literal-only templates. The helper that assembles format arguments now keeps + positional/implicit bindings ahead of named ones to satisfy the formatting + macro contract. + +### Tests +- Cover named format argument expressions, implicit placeholder ordering and + enum variants using format arguments. + +## [0.6.0] - 2025-10-08 + +### Added +- Recognised empty placeholder bodies (`{}` / `{:?}`) as implicit positional + identifiers, numbering them by appearance and exposing the new + `TemplateIdentifier::Implicit` variant in the template API. +- Propagated the implicit identifier metadata through + `template_support::TemplateIdentifierSpec`, ensuring derive-generated display + implementations resolve tuple fields in placeholder order. + +### Fixed +- Preserved `TemplateError::EmptyPlaceholder` diagnostics for whitespace-only + placeholders, matching previous error reporting for invalid bodies. + +### Tests +- Added parser regressions covering implicit placeholder sequencing and the + whitespace-only error path. + +## [0.5.15] - 2025-10-07 + +### Added +- Parse `#[error("...")]` attribute arguments into structured `FormatArg` + entries, tracking named bindings and positional indices for future + `format_args!` integration. +- Recognise `#[error(fmt = )]` handlers, capturing the formatter path and + associated arguments while guarding against duplicate `fmt` specifications. + +### Fixed +- Produce dedicated diagnostics when unsupported combinations are used, such as + providing format arguments alongside `#[error(transparent)]`. + +### Tests +- Extend the `trybuild` suite with regression cases covering duplicate `fmt` + handlers and transparent attributes that erroneously include arguments. + +## [0.5.14] - 2025-10-06 + +### Added +- Prepared the derive input structures for future `format_args!` support by + introducing display specification variants for templates with arguments and + `fmt = ` handlers, along with `FormatArgsSpec`/`FormatArg` metadata + scaffolding. + +## [0.5.13] - 2025-10-05 + +### Documentation +- Documented the formatter trait helpers (`TemplateFormatter::is_alternate`, + `TemplateFormatter::from_kind`, and `TemplateFormatterKind::specifier`/`supports_alternate`) + across README variants and crate docs, including guidance on the extended + formatter table and compatibility with `thiserror` v2. + +## [0.5.12] - 2025-10-04 + +### Tests +- Added runtime assertions covering every derive formatter variant and + validating lowercase versus uppercase rendering differences during error + formatting. +- Expanded the formatter `trybuild` suite with per-formatter success cases and + new compile-fail fixtures for unsupported uppercase specifiers to guarantee + diagnostics remain descriptive. + +## [0.5.11] - 2025-10-03 + +### Changed +- Aligned the derive display generator with `TemplateFormatterKind`, invoking the + appropriate `core::fmt` trait for every placeholder variant and preserving the + default `Display` path when no formatter is provided, mirroring `thiserror`'s + behaviour. + +## [0.5.10] - 2025-10-02 + +### Changed +- Template parser now recognises formatter traits even when alignment, sign or + width flags precede the type specifier, constructing the matching + `TemplateFormatter` variant and keeping alternate (`#`) detection aligned with + `thiserror`. + +### Tests +- Extended parser unit tests to cover complex formatter specifiers and + additional malformed cases to guard diagnostic accuracy. + +## [0.5.9] - 2025-10-01 + +### Added +- `TemplateFormatterKind` enumerating the formatter traits supported by + `#[error("...")]`, plus `TemplateFormatter::from_kind`/`kind()` helpers for + constructing and inspecting placeholders programmatically. + +### Changed +- Formatter parsing now routes through `TemplateFormatterKind`, ensuring lookup + tables, `is_alternate` handling and downstream derives share the same + canonical representation. + +### Documentation +- Documented `TemplateFormatterKind` usage and the new inspection helpers + across README variants. + +## [0.5.8] - 2025-09-30 + +### Changed +- `masterror::Error` now infers sources named `source` and backtrace fields of + type `std::backtrace::Backtrace`/`Option` even + without explicit attributes, matching `thiserror`'s ergonomics. + +### Tests +- Expanded derive tests to cover implicit `source`/`backtrace` detection across + structs and enums. + +## [0.5.7] - 2025-09-29 + +### Added +- `masterror::error::template` module providing a parsed representation of + `#[error("...")]` strings and a formatter hook for future custom derives. +- Internal `masterror-derive` crate powering the native `masterror::Error` + derive macro. +- Template placeholders now accept the same formatter traits as `thiserror` + (`:?`, `:x`, `:X`, `:p`, `:b`, `:o`, `:e`, `:E`) so existing derives keep + compiling when hexadecimal, binary, pointer or exponential formatting is + requested. + +### Changed +- `masterror::Error` now uses the in-tree derive, removing the dependency on + `thiserror` while keeping the same runtime behaviour and diagnostics. + +### Documentation +- Documented formatter trait usage across README.md, README.ru.md and the + `masterror::error` module, noting compatibility with `thiserror` v2 and + demonstrating programmatic `TemplateFormatter` inspection. + +## [0.5.6] - 2025-09-28 + +### Tests +- Added runtime coverage exercising every derive formatter variant (including + case-sensitive formatters) and asserted the rendered output. +- Added `trybuild` suites that compile successful formatter usage and verify the + emitted diagnostics for unsupported specifiers. + +## [0.5.5] - 2025-09-27 + +### Fixed +- Derive formatter generation now matches on every `TemplateFormatter` + variant and calls the corresponding `::core::fmt` trait (including the + default `Display` path), mirroring `thiserror`'s placeholder handling. + +## [0.5.4] - 2025-09-26 + +### Fixed +- Template parser mirrors `thiserror`'s formatter trait detection, ensuring + `:?`, `:x`, `:X`, `:p`, `:b`, `:o`, `:e` and `:E` specifiers resolve to the + appropriate `TemplateFormatter` variant while still flagging unsupported + flags precisely. + +### Tests +- Added parser-level unit tests that cover every supported formatter specifier + and assert graceful failures for malformed format strings. + +## [0.5.2] - 2025-09-25 + +### Fixed +- Added a workspace `deny.toml` allow-list for MIT, Apache-2.0 and Unicode-3.0 + licenses so `cargo deny` accepts existing dependencies. +- Declared SPDX license expressions for the internal `masterror-derive` and + `masterror-template` crates to avoid unlicensed warnings. + +## [0.5.1] - 2025-09-24 + +### Changed +- Replaced the optional `sqlx` dependency with `sqlx-core` so enabling the + feature no longer pulls in `rsa` via the MySQL driver, fixing the + `RUSTSEC-2023-0071` advisory reported by `cargo audit`. + +### Security +- Added `cargo audit` to the pre-commit hook and CI workflow; published a + README badge to surface the audit status. + +### Added +- Composite GitHub Action (`.github/actions/cargo-deny`) that installs and runs + `cargo-deny` checks for reuse across workflows. +- `cargo deny` step in the reusable CI pipeline to catch advisories, bans, + license and source issues automatically. +- README badges surfacing the Cargo Deny status so consumers can quickly verify + supply-chain checks. ## [0.5.0] - 2025-09-23 @@ -142,6 +529,8 @@ _No changes yet._ - **MSRV:** 1.89 - **No unsafe:** the crate forbids `unsafe`. +[0.5.2]: https://github.com/RAprogramm/masterror/releases/tag/v0.5.2 +[0.5.1]: https://github.com/RAprogramm/masterror/releases/tag/v0.5.1 [0.5.0]: https://github.com/RAprogramm/masterror/releases/tag/v0.5.0 [0.4.0]: https://github.com/RAprogramm/masterror/releases/tag/v0.4.0 [0.3.5]: https://github.com/RAprogramm/masterror/releases/tag/v0.3.5 diff --git a/Cargo.lock b/Cargo.lock index c367eb4..6fd7dc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -389,9 +389,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.37" +version = "1.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" +checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9" dependencies = [ "find-msvc-tools", "shlex", @@ -449,7 +449,7 @@ dependencies = [ "serde-untagged", "serde_core", "serde_json", - "toml 0.9.6", + "toml 0.9.7", "winnow", "yaml-rust2", ] @@ -583,8 +583,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] @@ -601,13 +611,38 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", "quote", "syn", ] @@ -834,9 +869,9 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "find-msvc-tools" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" +checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" [[package]] name = "flume" @@ -1038,6 +1073,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + [[package]] name = "hashlink" version = "0.10.0" @@ -1350,12 +1391,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.11.3" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92119844f513ffa41556430369ab02c295a3578af21cf945caa3e9e0c2481ac3" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.15.5", + "hashbrown 0.16.0", "serde", "serde_core", ] @@ -1395,9 +1436,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.79" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6247da8b8658ad4e73a186e747fcc5fc2a29f979d6fe6269127fdb5fd08298d0" +checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" dependencies = [ "once_cell", "wasm-bindgen", @@ -1521,25 +1562,27 @@ dependencies = [ [[package]] name = "masterror" -version = "0.5.0" +version = "0.10.4" dependencies = [ "actix-web", "axum", "config", "http 1.3.1", "js-sys", + "masterror-derive", + "masterror-template", "redis", "reqwest", "serde", "serde-wasm-bindgen", "serde_json", "sqlx", + "sqlx-core", "telegram-webapp-sdk", "teloxide-core", "tempfile", - "thiserror", "tokio", - "toml 0.9.6", + "toml 0.9.7", "tracing", "trybuild", "utoipa", @@ -1547,6 +1590,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "masterror-derive" +version = "0.6.0" +dependencies = [ + "masterror-template", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "masterror-template" +version = "0.3.1" + [[package]] name = "matchit" version = "0.8.4" @@ -2341,9 +2398,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2789234a13a53fc4be1b51ea1bab45a3c338bdb884862a257d10e5a74ae009e6" +checksum = "5417783452c2be558477e104686f7de5dae53dba813c28435e0e70f82d9b04ee" dependencies = [ "serde_core", ] @@ -2362,15 +2419,15 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.14.0" +version = "3.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" +checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e" dependencies = [ "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.11.3", + "indexmap 2.11.4", "schemars 0.9.0", "schemars 1.0.4", "serde", @@ -2382,11 +2439,11 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.14.0" +version = "3.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" +checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", "syn", @@ -2524,7 +2581,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap 2.11.3", + "indexmap 2.11.4", "log", "memchr", "once_cell", @@ -2865,11 +2922,12 @@ dependencies = [ [[package]] name = "time" -version = "0.3.43" +version = "0.3.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde", @@ -2985,14 +3043,14 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.6" +version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae2a4cf385da23d1d53bc15cdfa5c2109e93d8d362393c801e87da2f72f0e201" +checksum = "00e5e5d9bf2475ac9d4f0d9edab68cc573dc2fd644b0dba36b0c30a92dd9eaa0" dependencies = [ - "indexmap 2.11.3", + "indexmap 2.11.4", "serde_core", - "serde_spanned 1.0.1", - "toml_datetime 0.7.1", + "serde_spanned 1.0.2", + "toml_datetime 0.7.2", "toml_parser", "toml_writer", "winnow", @@ -3009,9 +3067,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a197c0ec7d131bfc6f7e82c8442ba1595aeab35da7adbf05b6b73cd06a16b6be" +checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" dependencies = [ "serde_core", ] @@ -3022,7 +3080,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.11.3", + "indexmap 2.11.4", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -3032,9 +3090,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" +checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" dependencies = [ "winnow", ] @@ -3047,9 +3105,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" +checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109" [[package]] name = "tower" @@ -3146,7 +3204,7 @@ dependencies = [ "serde_json", "target-triple", "termcolor", - "toml 0.9.6", + "toml 0.9.7", ] [[package]] @@ -3236,7 +3294,7 @@ version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" dependencies = [ - "indexmap 2.11.3", + "indexmap 2.11.4", "serde", "serde_json", "utoipa-gen", @@ -3286,7 +3344,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" dependencies = [ - "darling", + "darling 0.20.11", "once_cell", "proc-macro-error2", "proc-macro2", @@ -3347,9 +3405,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.102" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ad224d2776649cfb4f4471124f8176e54c1cca67a88108e30a0cd98b90e7ad3" +checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" dependencies = [ "cfg-if", "once_cell", @@ -3360,9 +3418,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.102" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1364104bdcd3c03f22b16a3b1c9620891469f5e9f09bc38b2db121e593e732" +checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" dependencies = [ "bumpalo", "log", @@ -3374,9 +3432,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.52" +version = "0.4.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c0a08ecf5d99d5604a6666a70b3cde6ab7cc6142f5e641a8ef48fc744ce8854" +checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" dependencies = [ "cfg-if", "js-sys", @@ -3387,9 +3445,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.102" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d7ab4ca3e367bb1ed84ddbd83cc6e41e115f8337ed047239578210214e36c76" +checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3397,9 +3455,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.102" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a518014843a19e2dbbd0ed5dfb6b99b23fb886b14e6192a00803a3e14c552b0" +checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" dependencies = [ "proc-macro2", "quote", @@ -3410,9 +3468,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.102" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "255eb0aa4cc2eea3662a00c2bbd66e93911b7361d5e0fcd62385acfd7e15dcee" +checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" dependencies = [ "unicode-ident", ] @@ -3432,9 +3490,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.79" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50462a022f46851b81d5441d1a6f5bac0b21a1d72d64bd4906fbdd4bf7230ec7" +checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index ecaf8ac..fbd06cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,27 +1,66 @@ [package] name = "masterror" -version = "0.5.0" -rust-version = "1.89" +version = "0.10.4" +rust-version = "1.90" edition = "2024" -description = "Application error types and response mapping" license = "MIT OR Apache-2.0" -documentation = "https://docs.rs/masterror" repository = "https://github.com/RAprogramm/masterror" readme = "README.md" +description = "Application error types and response mapping" +documentation = "https://docs.rs/masterror" build = "build.rs" categories = ["rust-patterns", "web-programming"] keywords = ["error", "api", "framework"] +include = [ + "Cargo.toml", + "Cargo.lock", + "build.rs", + "src/**", + "tests/**", + "README.md", + "README.ru.md", + "README.template.md", + "CHANGELOG.md", + "LICENSE-APACHE", + "LICENSE-MIT", + "Makefile.toml", + "deny.toml", + "idea.md", + "target.md", + "build/**", + "masterror-derive/**", + "masterror-template/**", + ".cargo/audit.toml", + ".cargo/config.toml" +] + +[workspace] +members = ["masterror-derive", "masterror-template"] + +resolver = "3" + +# Defaults for members (root объявлен строками выше, потому что build.rs парсит его как строки) +[workspace.package] +edition = "2024" +rust-version = "1.90" +license = "MIT OR Apache-2.0" +repository = "https://github.com/RAprogramm/masterror" +readme = "README.md" [features] default = [] -axum = ["dep:axum", "dep:serde_json"] # IntoResponse + JSON body +axum = ["dep:axum", "dep:serde_json"] actix = ["dep:actix-web", "dep:serde_json"] -sqlx = ["dep:sqlx"] + +# Разделили: лёгкая обработка ошибок (sqlx-core) и опциональные миграции (полный sqlx) +sqlx = ["dep:sqlx-core"] # maps sqlx_core::Error +sqlx-migrate = ["dep:sqlx"] # maps sqlx::migrate::MigrateError + redis = ["dep:redis"] validator = ["dep:validator"] serde_json = ["dep:serde_json"] -config = ["dep:config"] # config::ConfigError -> AppError +config = ["dep:config"] multipart = ["axum"] tokio = ["dep:tokio"] reqwest = ["dep:reqwest"] @@ -29,19 +68,22 @@ teloxide = ["dep:teloxide-core"] telegram-webapp-sdk = ["dep:telegram-webapp-sdk"] frontend = ["dep:wasm-bindgen", "dep:js-sys", "dep:serde-wasm-bindgen"] turnkey = [] - openapi = ["dep:utoipa"] +[workspace.dependencies] +masterror-derive = { version = "0.6.0" } +masterror-template = { version = "0.3.1" } + [dependencies] -thiserror = "2" +masterror-derive = { workspace = true } +masterror-template = { workspace = true } tracing = "0.1" serde = { version = "1", features = ["derive"] } serde_json = { version = "1", optional = true } - http = "1" -# опциональные интеграции +# optional integrations axum = { version = "0.8", optional = true, default-features = false, features = [ "json", "multipart", @@ -49,9 +91,14 @@ axum = { version = "0.8", optional = true, default-features = false, features = actix-web = { version = "4", optional = true, default-features = false, features = [ "macros", ] } + +# Lean core only; no drivers, no TLS, no macros +sqlx-core = { version = "0.8", optional = true, default-features = false } +# Full sqlx ONLY for migrate mapping; still no default features sqlx = { version = "0.8", optional = true, default-features = false, features = [ "migrate", ] } + redis = { version = "0.32", optional = true, default-features = false } validator = { version = "0.20", optional = true, features = ["derive"] } config = { version = "0.15", optional = true } @@ -73,7 +120,6 @@ tokio = { version = "1", features = [ "time", ], default-features = false } trybuild = "1" - toml = "0.9" tempfile = "3" @@ -88,6 +134,7 @@ feature_order = [ "openapi", "serde_json", "sqlx", + "sqlx-migrate", "reqwest", "redis", "validator", @@ -127,7 +174,13 @@ description = "Generate utoipa OpenAPI schema for ErrorResponse" description = "Attach structured JSON details to AppError" [package.metadata.masterror.readme.features.sqlx] -description = "Classify sqlx::Error variants into AppError kinds" +description = "Classify sqlx_core::Error variants into AppError kinds" + +[package.metadata.masterror.readme.features.sqlx-migrate] +description = "Map sqlx::migrate::MigrateError into AppError (Database)" + +[package.metadata.masterror.readme.features.reqwest] +description = "Classify reqwest::Error as timeout/network/external API" [package.metadata.masterror.readme.features.redis] description = "Map redis::RedisError into cache-aware AppError" @@ -144,9 +197,6 @@ description = "Handle axum multipart extraction errors" [package.metadata.masterror.readme.features.tokio] description = "Classify tokio::time::error::Elapsed as timeout" -[package.metadata.masterror.readme.features.reqwest] -description = "Classify reqwest::Error as timeout/network/external API" - [package.metadata.masterror.readme.features.teloxide] description = "Convert teloxide_core::RequestError into domain errors" diff --git a/README.md b/README.md index 894889e..6555662 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,11 @@ [![Crates.io](https://img.shields.io/crates/v/masterror)](https://crates.io/crates/masterror) [![docs.rs](https://img.shields.io/docsrs/masterror)](https://docs.rs/masterror) [![Downloads](https://img.shields.io/crates/d/masterror)](https://crates.io/crates/masterror) -![MSRV](https://img.shields.io/badge/MSRV-1.89-blue) +![MSRV](https://img.shields.io/badge/MSRV-1.90-blue) ![License](https://img.shields.io/badge/License-MIT%20or%20Apache--2.0-informational) [![CI](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) +[![Security audit](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml/badge.svg?branch=main&label=Security%20audit)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) +[![Cargo Deny](https://img.shields.io/github/actions/workflow/status/RAprogramm/masterror/ci.yml?branch=main&label=Cargo%20Deny)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) > 🇷🇺 Читайте README на [русском языке](README.ru.md). @@ -27,13 +29,13 @@ Stable categories, conservative HTTP mapping, no `unsafe`. ~~~toml [dependencies] -masterror = { version = "0.5.0", default-features = false } +masterror = { version = "0.10.4", default-features = false } # or with features: -# masterror = { version = "0.5.0", features = [ +# masterror = { version = "0.10.4", features = [ # "axum", "actix", "openapi", "serde_json", -# "sqlx", "reqwest", "redis", "validator", -# "config", "tokio", "multipart", "teloxide", -# "telegram-webapp-sdk", "frontend", "turnkey" +# "sqlx", "sqlx-migrate", "reqwest", "redis", +# "validator", "config", "tokio", "multipart", +# "teloxide", "telegram-webapp-sdk", "frontend", "turnkey" # ] } ~~~ @@ -52,8 +54,8 @@ masterror = { version = "0.5.0", default-features = false } - **Clean wire contract.** `ErrorResponse { status, code, message, details?, retry?, www_authenticate? }`. - **One log at boundary.** Log once with `tracing`. - **Less boilerplate.** Built-in conversions, compact prelude, and the - `masterror::Error` re-export of `thiserror::Error` with `#[from]` / - `#[error(transparent)]` support. + native `masterror::Error` derive with `#[from]` / `#[error(transparent)]` + support. - **Consistent workspace.** Same error surface across crates. @@ -64,18 +66,18 @@ masterror = { version = "0.5.0", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.5.0", default-features = false } +masterror = { version = "0.10.4", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.5.0", features = [ +# masterror = { version = "0.10.4", features = [ # "axum", "actix", "openapi", "serde_json", -# "sqlx", "reqwest", "redis", "validator", -# "config", "tokio", "multipart", "teloxide", -# "telegram-webapp-sdk", "frontend", "turnkey" +# "sqlx", "sqlx-migrate", "reqwest", "redis", +# "validator", "config", "tokio", "multipart", +# "teloxide", "telegram-webapp-sdk", "frontend", "turnkey" # ] } ~~~ -**MSRV:** 1.89 +**MSRV:** 1.90 **No unsafe:** forbidden by crate. @@ -142,11 +144,388 @@ let wrapped = WrappedDomainError::from(err); assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); ~~~ -- `use masterror::Error;` re-exports `thiserror::Error`. +- `use masterror::Error;` brings the crate's derive macro into scope. - `#[from]` automatically implements `From<...>` while ensuring wrapper shapes are valid. - `#[error(transparent)]` enforces single-field wrappers that forward `Display`/`source` to the inner error. +- `#[app_error(kind = AppErrorKind::..., code = AppCode::..., message)]` maps the + derived error into `AppError`/`AppCode`. The optional `code = ...` arm emits an + `AppCode` conversion, while the `message` flag forwards the derived + `Display` output as the public message instead of producing a bare error. +- `masterror::error::template::ErrorTemplate` parses `#[error("...")]` + strings, exposing literal and placeholder segments so custom derives can be + implemented without relying on `thiserror`. +- `TemplateFormatter` mirrors `thiserror`'s formatter detection so existing + derives that relied on hexadecimal, pointer or exponential renderers keep + compiling. +- Display placeholders preserve their raw format specs via + `TemplateFormatter::display_spec()` and `TemplateFormatter::format_fragment()`, + so derived code can forward `:>8`, `:.3` and other display-only options + without reconstructing the original string. +- `TemplateFormatterKind` exposes the formatter trait requested by a + placeholder, making it easy to branch on the requested rendering behaviour + without manually matching every enum variant. + +#### Display shorthand projections + +`#[error("...")]` supports the same shorthand syntax as `thiserror` for +referencing fields with `.field` or `.0`. The derive now understands chained +segments, so projections like `.limits.lo`, `.0.data` or +`.suggestion.as_ref().map_or_else(...)` keep compiling unchanged. Raw +identifiers and tuple indices are preserved, ensuring keywords such as +`r#type` and tuple fields continue to work even when you call methods on the +projected value. + +~~~rust +use masterror::Error; + +#[derive(Debug)] +struct Limits { + lo: i32, + hi: i32, +} + +#[derive(Debug, Error)] +#[error( + "range {lo}-{hi} suggestion {suggestion}", + lo = .limits.lo, + hi = .limits.hi, + suggestion = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) +)] +struct RangeError { + limits: Limits, + suggestion: Option, +} + +#[derive(Debug)] +struct Payload { + data: &'static str, +} + +#[derive(Debug, Error)] +enum UiError { + #[error("tuple data {data}", data = .0.data)] + Tuple(Payload), + #[error( + "named suggestion {value}", + value = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) + )] + Named { suggestion: Option }, +} +~~~ + +#### AppError conversions + +Annotating structs or enum variants with `#[app_error(...)]` captures the +metadata required to convert the domain error into `AppError` (and optionally +`AppCode`). Every variant in an enum must provide the mapping when any variant +requests it. + +~~~rust +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +#[error("missing flag: {name}")] +#[app_error(kind = AppErrorKind::BadRequest, code = AppCode::BadRequest, message)] +struct MissingFlag { + name: &'static str, +} + +let app: AppError = MissingFlag { name: "feature" }.into(); +assert!(matches!(app.kind, AppErrorKind::BadRequest)); +assert_eq!(app.message.as_deref(), Some("missing flag: feature")); + +let code: AppCode = MissingFlag { name: "feature" }.into(); +assert!(matches!(code, AppCode::BadRequest)); +~~~ + +For enums, each variant specifies the mapping while the derive generates a +single `From` implementation that matches every variant: + +~~~rust +#[derive(Debug, Error)] +enum ApiError { + #[error("missing resource {id}")] + #[app_error( + kind = AppErrorKind::NotFound, + code = AppCode::NotFound, + message + )] + Missing { id: u64 }, + #[error("backend unavailable")] + #[app_error(kind = AppErrorKind::Service, code = AppCode::Service)] + Backend, +} + +let missing = ApiError::Missing { id: 7 }; +let as_app: AppError = missing.into(); +assert_eq!(as_app.message.as_deref(), Some("missing resource 7")); +~~~ + +#### Structured telemetry providers and AppError mappings + +`#[provide(...)]` exposes typed context through `std::error::Request`, while +`#[app_error(...)]` records how your domain error translates into `AppError` +and `AppCode`. The derive mirrors `thiserror`'s syntax and extends it with +optional telemetry propagation and direct conversions into the `masterror` +runtime types. + +~~~rust +use std::error::request_ref; + +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct TelemetrySnapshot { + name: &'static str, + value: u64, +} + +#[derive(Debug, Error)] +#[error("structured telemetry {snapshot:?}")] +#[app_error(kind = AppErrorKind::Service, code = AppCode::Service)] +struct StructuredTelemetryError { + #[provide(ref = TelemetrySnapshot, value = TelemetrySnapshot)] + snapshot: TelemetrySnapshot, +} + +let err = StructuredTelemetryError { + snapshot: TelemetrySnapshot { + name: "db.query", + value: 42, + }, +}; + +let snapshot = request_ref::(&err).expect("telemetry"); +assert_eq!(snapshot.value, 42); + +let app: AppError = err.into(); +let via_app = request_ref::(&app).expect("telemetry"); +assert_eq!(via_app.name, "db.query"); +~~~ + +Optional telemetry only surfaces when present, so `None` does not register a +provider. Owned snapshots can still be provided as values when the caller +requests ownership: + +~~~rust +use masterror::{AppCode, AppErrorKind, Error}; + +#[derive(Debug, Error)] +#[error("optional telemetry {telemetry:?}")] +#[app_error(kind = AppErrorKind::Internal, code = AppCode::Internal)] +struct OptionalTelemetryError { + #[provide(ref = TelemetrySnapshot, value = TelemetrySnapshot)] + telemetry: Option, +} + +let noisy = OptionalTelemetryError { + telemetry: Some(TelemetrySnapshot { + name: "queue.depth", + value: 17, + }), +}; +let silent = OptionalTelemetryError { telemetry: None }; + +assert!(request_ref::(&noisy).is_some()); +assert!(request_ref::(&silent).is_none()); +~~~ + +Enums support per-variant telemetry and conversion metadata. Each variant chooses +its own `AppErrorKind`/`AppCode` mapping while the derive generates a single +`From` implementation: + +~~~rust +#[derive(Debug, Error)] +enum EnumTelemetryError { + #[error("named {label}")] + #[app_error(kind = AppErrorKind::NotFound, code = AppCode::NotFound)] + Named { + label: &'static str, + #[provide(ref = TelemetrySnapshot)] + snapshot: TelemetrySnapshot, + }, + #[error("optional tuple")] + #[app_error(kind = AppErrorKind::Timeout, code = AppCode::Timeout)] + Optional(#[provide(ref = TelemetrySnapshot)] Option), + #[error("owned tuple")] + #[app_error(kind = AppErrorKind::Service, code = AppCode::Service)] + Owned(#[provide(value = TelemetrySnapshot)] TelemetrySnapshot), +} + +let owned = EnumTelemetryError::Owned(TelemetrySnapshot { + name: "redis.latency", + value: 3, +}); +let app: AppError = owned.into(); +assert!(matches!(app.kind, AppErrorKind::Service)); +~~~ + +Compared to `thiserror`, you retain the familiar deriving surface while gaining +structured telemetry (`#[provide]`) and first-class conversions into +`AppError`/`AppCode` without writing manual `From` implementations. + +#### Formatter traits + +Placeholders default to `Display` (`{value}`) but can opt into richer +formatters via the same specifiers supported by `thiserror` v2. +`TemplateFormatter::is_alternate()` tracks the `#` flag, while +`TemplateFormatterKind` exposes the underlying `core::fmt` trait so derived +code can branch on the requested renderer without manual pattern matching. +Unsupported formatters surface a compile error that mirrors `thiserror`'s +diagnostics. + +| Specifier | `core::fmt` trait | Example output | Notes | +|------------------|----------------------------|------------------------|-------| +| _default_ | `core::fmt::Display` | `value` | User-facing strings; `#` has no effect. | +| `:?` / `:#?` | `core::fmt::Debug` | `Struct { .. }` / multi-line | Mirrors `Debug`; `#` pretty-prints structs. | +| `:x` / `:#x` | `core::fmt::LowerHex` | `0x2a` | Hexadecimal; `#` prepends `0x`. | +| `:X` / `:#X` | `core::fmt::UpperHex` | `0x2A` | Uppercase hex; `#` prepends `0x`. | +| `:p` / `:#p` | `core::fmt::Pointer` | `0x1f00` / `0x1f00` | Raw pointers; `#` is accepted for compatibility. | +| `:b` / `:#b` | `core::fmt::Binary` | `101010` / `0b101010` | Binary; `#` prepends `0b`. | +| `:o` / `:#o` | `core::fmt::Octal` | `52` / `0o52` | Octal; `#` prepends `0o`. | +| `:e` / `:#e` | `core::fmt::LowerExp` | `1.5e-2` | Scientific notation; `#` forces the decimal point. | +| `:E` / `:#E` | `core::fmt::UpperExp` | `1.5E-2` | Uppercase scientific; `#` forces the decimal point. | + +- `TemplateFormatterKind::supports_alternate()` reports whether the `#` flag is + meaningful for the requested trait (pointer accepts it even though the output + matches the non-alternate form). +- `TemplateFormatterKind::specifier()` returns the canonical format specifier + character when one exists, enabling custom derives to re-render placeholders + in their original style. +- `TemplateFormatter::from_kind(kind, alternate)` reconstructs a formatter from + the lightweight `TemplateFormatterKind`, making it easy to toggle the + alternate flag in generated code. + +~~~rust +use core::ptr; + +use masterror::Error; + +#[derive(Debug, Error)] +#[error( + "debug={payload:?}, hex={id:#x}, ptr={ptr:p}, bin={mask:#b}, \ + oct={mask:o}, lower={ratio:e}, upper={ratio:E}" +)] +struct FormattedError { + id: u32, + payload: String, + ptr: *const u8, + mask: u8, + ratio: f32, +} + +let err = FormattedError { + id: 0x2a, + payload: "hello".into(), + ptr: ptr::null(), + mask: 0b1010_0001, + ratio: 0.15625, +}; + +let rendered = err.to_string(); +assert!(rendered.contains("debug=\"hello\"")); +assert!(rendered.contains("hex=0x2a")); +assert!(rendered.contains("ptr=0x0")); +assert!(rendered.contains("bin=0b10100001")); +assert!(rendered.contains("oct=241")); +assert!(rendered.contains("lower=1.5625e-1")); +assert!(rendered.contains("upper=1.5625E-1")); +~~~ + +~~~rust +use masterror::error::template::{ + ErrorTemplate, TemplateFormatter, TemplateFormatterKind +}; + +let template = ErrorTemplate::parse("{code:#x} → {payload:?}").expect("parse"); +let mut placeholders = template.placeholders(); + +let code = placeholders.next().expect("code placeholder"); +let code_formatter = code.formatter(); +assert!(matches!( + code_formatter, + TemplateFormatter::LowerHex { alternate: true } +)); +let code_kind = code_formatter.kind(); +assert_eq!(code_kind, TemplateFormatterKind::LowerHex); +assert!(code_formatter.is_alternate()); +assert_eq!(code_kind.specifier(), Some('x')); +assert!(code_kind.supports_alternate()); +let lowered = TemplateFormatter::from_kind(code_kind, false); +assert!(matches!( + lowered, + TemplateFormatter::LowerHex { alternate: false } +)); + +let payload = placeholders.next().expect("payload placeholder"); +let payload_formatter = payload.formatter(); +assert_eq!( + payload_formatter, + &TemplateFormatter::Debug { alternate: false } +); +let payload_kind = payload_formatter.kind(); +assert_eq!(payload_kind, TemplateFormatterKind::Debug); +assert_eq!(payload_kind.specifier(), Some('?')); +assert!(payload_kind.supports_alternate()); +let pretty_debug = TemplateFormatter::from_kind(payload_kind, true); +assert!(matches!( + pretty_debug, + TemplateFormatter::Debug { alternate: true } +)); +assert!(pretty_debug.is_alternate()); +~~~ + +Display-only format specs (alignment, precision, fill — including `#` as a fill +character) are preserved so you can forward them to `write!` without rebuilding +the fragment: + +~~~rust +use masterror::error::template::ErrorTemplate; + +let aligned = ErrorTemplate::parse("{value:>8}").expect("parse"); +let display = aligned.placeholders().next().expect("display placeholder"); +assert_eq!(display.formatter().display_spec(), Some(">8")); +assert_eq!( + display + .formatter() + .format_fragment() + .as_deref(), + Some(">8") +); + +let hashed = ErrorTemplate::parse("{value:#>4}").expect("parse"); +let hash_placeholder = hashed + .placeholders() + .next() + .expect("hash-fill display placeholder"); +assert_eq!(hash_placeholder.formatter().display_spec(), Some("#>4")); +assert_eq!( + hash_placeholder + .formatter() + .format_fragment() + .as_deref(), + Some("#>4") +); +~~~ + +> **Compatibility with `thiserror` v2:** the derive understands the extended +> formatter set introduced in `thiserror` 2.x and reports identical diagnostics +> for unsupported specifiers, so migrating existing derives is drop-in. + +```rust +use masterror::error::template::{ErrorTemplate, TemplateIdentifier}; + +let template = ErrorTemplate::parse("{code}: {message}").expect("parse"); +let display = template.display_with(|placeholder, f| match placeholder.identifier() { + TemplateIdentifier::Named("code") => write!(f, "{}", 404), + TemplateIdentifier::Named("message") => f.write_str("Not Found"), + _ => Ok(()), +}); + +assert_eq!(display.to_string(), "404: Not Found"); +``` @@ -206,7 +585,8 @@ assert_eq!(resp.status, 401); - `actix` — Actix Web ResponseError and Responder implementations - `openapi` — Generate utoipa OpenAPI schema for ErrorResponse - `serde_json` — Attach structured JSON details to AppError -- `sqlx` — Classify sqlx::Error variants into AppError kinds +- `sqlx` — Classify sqlx_core::Error variants into AppError kinds +- `sqlx-migrate` — Map sqlx::migrate::MigrateError into AppError (Database) - `reqwest` — Classify reqwest::Error as timeout/network/external API - `redis` — Map redis::RedisError into cache-aware AppError - `validator` — Convert validator::ValidationErrors into validation failures @@ -243,13 +623,13 @@ assert_eq!(resp.status, 401); Minimal core: ~~~toml -masterror = { version = "0.5.0", default-features = false } +masterror = { version = "0.10.4", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.5.0", features = [ +masterror = { version = "0.10.4", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -258,7 +638,7 @@ masterror = { version = "0.5.0", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.5.0", features = [ +masterror = { version = "0.10.4", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -299,7 +679,7 @@ assert_eq!(app.kind, AppErrorKind::RateLimited); Versioning & MSRV Semantic versioning. Breaking API/wire contract → major bump. -MSRV = 1.89 (may raise in minor, never in patch). +MSRV = 1.90 (may raise in minor, never in patch). @@ -329,4 +709,3 @@ MSRV = 1.89 (may raise in minor, never in patch). Apache-2.0 OR MIT, at your option. - diff --git a/README.ru.md b/README.ru.md index 416a896..7e3011f 100644 --- a/README.ru.md +++ b/README.ru.md @@ -5,9 +5,11 @@ [![Crates.io](https://img.shields.io/crates/v/masterror)](https://crates.io/crates/masterror) [![docs.rs](https://img.shields.io/docsrs/masterror)](https://docs.rs/masterror) [![Downloads](https://img.shields.io/crates/d/masterror)](https://crates.io/crates/masterror) -![MSRV](https://img.shields.io/badge/MSRV-1.89-blue) +![MSRV](https://img.shields.io/badge/MSRV-1.90-blue) ![License](https://img.shields.io/badge/License-MIT%20or%20Apache--2.0-informational) [![CI](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) +[![Security audit](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml/badge.svg?branch=main&label=Security%20audit)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) +[![Cargo Deny](https://img.shields.io/github/actions/workflow/status/RAprogramm/masterror/ci.yml?branch=main&label=Cargo%20Deny)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) Небольшая прагматичная модель ошибок для Rust-сервисов с выраженным API. Основной крейт не зависит от веб-фреймворков, а расширения включаются через фичи. Таксономия ошибок стабильна, соответствие HTTP-кодам консервативно, `unsafe` запрещён. @@ -25,17 +27,18 @@ ~~~toml [dependencies] -masterror = { version = "0.5.0", default-features = false } +# минимальное ядро +masterror = { version = "0.10.4", default-features = false } # или с нужными интеграциями -# masterror = { version = "0.5.0", features = [ +# masterror = { version = "0.10.4", features = [ # "axum", "actix", "openapi", "serde_json", -# "sqlx", "reqwest", "redis", "validator", -# "config", "tokio", "multipart", "teloxide", -# "telegram-webapp-sdk", "frontend", "turnkey" +# "sqlx", "sqlx-migrate", "reqwest", "redis", +# "validator", "config", "tokio", "multipart", +# "teloxide", "telegram-webapp-sdk", "frontend", "turnkey" # ] } ~~~ -**MSRV:** 1.89 +**MSRV:** 1.90 ## Быстрый старт @@ -64,15 +67,292 @@ fn do_work(flag: bool) -> AppResult<()> { ## Дополнительные интеграции - `sqlx` — классификация `sqlx::Error` по видам ошибок. +- `sqlx-migrate` — обработка `sqlx::migrate::MigrateError` как базы данных. - `reqwest` — перевод сетевых/HTTP-сбоев в доменные категории. - `redis` — корректная обработка ошибок кеша. - `validator` — преобразование `ValidationErrors` в валидационные ошибки API. - `config` — типизированные ошибки конфигурации. - `tokio` — маппинг таймаутов (`tokio::time::error::Elapsed`). - `multipart` — обработка ошибок извлечения multipart в Axum. +- `teloxide` — маппинг `teloxide_core::RequestError` в доменные категории. +- `telegram-webapp-sdk` — обработка ошибок валидации данных Telegram WebApp. - `frontend` — логирование в браузере и преобразование в `JsValue` для WASM. - `turnkey` — расширение таксономии для Turnkey SDK. +## Атрибуты `#[provide]` и `#[app_error]` + +Атрибут `#[provide(...)]` позволяет передавать структурированную телеметрию через +`std::error::Request`, а `#[app_error(...)]` описывает прямой маппинг доменной +ошибки в `AppError` и `AppCode`. Дерив сохраняет синтаксис `thiserror`, но +дополняет его провайдерами телеметрии и готовыми конверсиями в типы `masterror`. + +~~~rust +use std::error::request_ref; + +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct TelemetrySnapshot { + name: &'static str, + value: u64, +} + +#[derive(Debug, Error)] +#[error("structured telemetry {snapshot:?}")] +#[app_error(kind = AppErrorKind::Service, code = AppCode::Service)] +struct StructuredTelemetryError { + #[provide(ref = TelemetrySnapshot, value = TelemetrySnapshot)] + snapshot: TelemetrySnapshot, +} + +let err = StructuredTelemetryError { + snapshot: TelemetrySnapshot { + name: "db.query", + value: 42, + }, +}; + +let snapshot = request_ref::(&err).expect("telemetry"); +assert_eq!(snapshot.value, 42); + +let app: AppError = err.into(); +let via_app = request_ref::(&app).expect("telemetry"); +assert_eq!(via_app.name, "db.query"); +~~~ + +Опциональные поля автоматически пропускаются, если значения нет. При запросе +значения `Option` можно вернуть как по ссылке, так и передать владение: + +~~~rust +use masterror::{AppCode, AppErrorKind, Error}; + +#[derive(Debug, Error)] +#[error("optional telemetry {telemetry:?}")] +#[app_error(kind = AppErrorKind::Internal, code = AppCode::Internal)] +struct OptionalTelemetryError { + #[provide(ref = TelemetrySnapshot, value = TelemetrySnapshot)] + telemetry: Option, +} + +let noisy = OptionalTelemetryError { + telemetry: Some(TelemetrySnapshot { + name: "queue.depth", + value: 17, + }), +}; +let silent = OptionalTelemetryError { telemetry: None }; + +assert!(request_ref::(&noisy).is_some()); +assert!(request_ref::(&silent).is_none()); +~~~ + +Для перечислений каждая ветка может задавать собственную телеметрию и +конверсию. Дерив сгенерирует единый `From` для `AppError`/`AppCode`: + +~~~rust +#[derive(Debug, Error)] +enum EnumTelemetryError { + #[error("named {label}")] + #[app_error(kind = AppErrorKind::NotFound, code = AppCode::NotFound)] + Named { + label: &'static str, + #[provide(ref = TelemetrySnapshot)] + snapshot: TelemetrySnapshot, + }, + #[error("optional tuple")] + #[app_error(kind = AppErrorKind::Timeout, code = AppCode::Timeout)] + Optional(#[provide(ref = TelemetrySnapshot)] Option), + #[error("owned tuple")] + #[app_error(kind = AppErrorKind::Service, code = AppCode::Service)] + Owned(#[provide(value = TelemetrySnapshot)] TelemetrySnapshot), +} + +let owned = EnumTelemetryError::Owned(TelemetrySnapshot { + name: "redis.latency", + value: 3, +}); +let app: AppError = owned.into(); +assert!(matches!(app.kind, AppErrorKind::Service)); +~~~ + +В отличие от `thiserror`, вы получаете дополнительную структурированную +информацию и прямой маппинг в `AppError`/`AppCode` без ручных реализаций +`From`. + +## Форматирование шаблонов `#[error]` + +Шаблон `#[error("...")]` по умолчанию использует `Display`, но любая +подстановка может запросить другой форматтер. +`TemplateFormatter::is_alternate()` фиксирует флаг `#`, а `TemplateFormatterKind` +сообщает, какой трейт `core::fmt` нужен, поэтому порождённый код может +переключаться между вариантами без ручного `match`. Неподдержанные спецификаторы +приводят к диагностике на этапе компиляции, совпадающей с `thiserror`. + +| Спецификатор | Трейт | Пример результата | Примечания | +|------------------|-------------------------|--------------------------|------------| +| _по умолчанию_ | `core::fmt::Display` | `value` | Пользовательские сообщения; `#` игнорируется. | +| `:?` / `:#?` | `core::fmt::Debug` | `Struct { .. }` / многострочный | Поведение `Debug`; `#` включает pretty-print. | +| `:x` / `:#x` | `core::fmt::LowerHex` | `0x2a` | Шестнадцатеричный вывод; `#` добавляет `0x`. | +| `:X` / `:#X` | `core::fmt::UpperHex` | `0x2A` | Верхний регистр; `#` добавляет `0x`. | +| `:p` / `:#p` | `core::fmt::Pointer` | `0x1f00` / `0x1f00` | Сырые указатели; `#` поддерживается для совместимости. | +| `:b` / `:#b` | `core::fmt::Binary` | `101010` / `0b101010` | Двоичный вывод; `#` добавляет `0b`. | +| `:o` / `:#o` | `core::fmt::Octal` | `52` / `0o52` | Восьмеричный вывод; `#` добавляет `0o`. | +| `:e` / `:#e` | `core::fmt::LowerExp` | `1.5e-2` | Научная запись; `#` заставляет выводить десятичную точку. | +| `:E` / `:#E` | `core::fmt::UpperExp` | `1.5E-2` | Верхний регистр научной записи; `#` заставляет выводить точку. | + +- `TemplateFormatterKind::supports_alternate()` сообщает, имеет ли смысл `#` для + выбранного трейта (для указателей вывод совпадает с обычным). +- `TemplateFormatterKind::specifier()` возвращает канонический символ + спецификатора, что упрощает повторный рендеринг плейсхолдеров. +- `TemplateFormatter::from_kind(kind, alternate)` собирает форматтер из + `TemplateFormatterKind`, позволяя программно переключать флаг `#`. +- Display-плейсхолдеры сохраняют исходные параметры форматирования: + методы `TemplateFormatter::display_spec()` и + `TemplateFormatter::format_fragment()` возвращают `:>8`, `:.3` и другие + варианты без необходимости собирать строку вручную. + +~~~rust +use core::ptr; + +use masterror::Error; + +#[derive(Debug, Error)] +#[error( + "debug={payload:?}, hex={id:#x}, ptr={ptr:p}, bin={mask:#b}, \ + oct={mask:o}, lower={ratio:e}, upper={ratio:E}" +)] +struct FormatterDemo { + id: u32, + payload: String, + ptr: *const u8, + mask: u8, + ratio: f32, +} + +let err = FormatterDemo { + id: 0x2a, + payload: "hello".into(), + ptr: ptr::null(), + mask: 0b1010_0001, + ratio: 0.15625, +}; + +let rendered = err.to_string(); +assert!(rendered.contains("debug=\"hello\"")); +assert!(rendered.contains("hex=0x2a")); +assert!(rendered.contains("ptr=0x0")); +assert!(rendered.contains("bin=0b10100001")); +assert!(rendered.contains("oct=241")); +assert!(rendered.contains("lower=1.5625e-1")); +assert!(rendered.contains("upper=1.5625E-1")); +~~~ + +`masterror::error::template::ErrorTemplate` позволяет разобрать шаблон и +программно проверить запрошенные форматтеры; перечисление +`TemplateFormatterKind` возвращает название трейта для каждого плейсхолдера: + +~~~rust +use masterror::error::template::{ + ErrorTemplate, TemplateFormatter, TemplateFormatterKind +}; + +let template = ErrorTemplate::parse("{code:#x} → {payload:?}").expect("parse"); +let mut placeholders = template.placeholders(); + +let code = placeholders.next().expect("code placeholder"); +let code_formatter = code.formatter(); +assert!(matches!( + code_formatter, + TemplateFormatter::LowerHex { alternate: true } +)); +let code_kind = code_formatter.kind(); +assert_eq!(code_kind, TemplateFormatterKind::LowerHex); +assert!(code_formatter.is_alternate()); +assert_eq!(code_kind.specifier(), Some('x')); +assert!(code_kind.supports_alternate()); +let lowered = TemplateFormatter::from_kind(code_kind, false); +assert!(matches!( + lowered, + TemplateFormatter::LowerHex { alternate: false } +)); + +let payload = placeholders.next().expect("payload placeholder"); +let payload_formatter = payload.formatter(); +assert_eq!( + payload_formatter, + &TemplateFormatter::Debug { alternate: false } +); +let payload_kind = payload_formatter.kind(); +assert_eq!(payload_kind, TemplateFormatterKind::Debug); +assert_eq!(payload_kind.specifier(), Some('?')); +assert!(payload_kind.supports_alternate()); +let pretty_debug = TemplateFormatter::from_kind(payload_kind, true); +assert!(matches!( + pretty_debug, + TemplateFormatter::Debug { alternate: true } +)); +assert!(pretty_debug.is_alternate()); +~~~ + +Опции выравнивания, точности и заполнения для `Display` сохраняются и доступны +для прямой передачи в `write!`: + +~~~rust +use masterror::error::template::ErrorTemplate; + +let aligned = ErrorTemplate::parse("{value:>8}").expect("parse"); +let display = aligned.placeholders().next().expect("display placeholder"); +assert_eq!(display.formatter().display_spec(), Some(">8")); +assert_eq!( + display + .formatter() + .format_fragment() + .as_deref(), + Some(">8") +); +~~~ + +Динамические ширина и точность (`{value:>width$}`, `{value:.precision$}`) +тоже доходят до вызова `write!`, если объявить соответствующие аргументы в +атрибуте `#[error(...)]`: + +~~~rust +use masterror::Error; + +#[derive(Debug, Error)] +#[error("{value:>width$}", value = .value, width = .width)] +struct DynamicWidth { + value: &'static str, + width: usize, +} + +#[derive(Debug, Error)] +#[error("{value:.precision$}", value = .value, precision = .precision)] +struct DynamicPrecision { + value: f64, + precision: usize, +} + +let width = DynamicWidth { + value: "x", + width: 5, +}; +let precision = DynamicPrecision { + value: 123.456_f64, + precision: 4, +}; + +assert_eq!(width.to_string(), format!("{value:>width$}", value = "x", width = 5)); +assert_eq!( + precision.to_string(), + format!("{value:.precision$}", value = 123.456_f64, precision = 4) +); +~~~ + +> **Совместимость с `thiserror` v2.** Доступные спецификаторы, сообщения об +> ошибках и поведение совпадают с `thiserror` 2.x, поэтому миграция с +> `thiserror::Error` на `masterror::Error` не требует переписывать шаблоны. + ## Лицензия Проект распространяется по лицензии Apache-2.0 или MIT на ваш выбор. diff --git a/README.template.md b/README.template.md index 074c8fc..47e35f4 100644 --- a/README.template.md +++ b/README.template.md @@ -9,6 +9,8 @@ ![MSRV](https://img.shields.io/badge/MSRV-{{MSRV}}-blue) ![License](https://img.shields.io/badge/License-MIT%20or%20Apache--2.0-informational) [![CI](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) +[![Security audit](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml/badge.svg?branch=main&label=Security%20audit)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) +[![Cargo Deny](https://img.shields.io/github/actions/workflow/status/RAprogramm/masterror/ci.yml?branch=main&label=Cargo%20Deny)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) > 🇷🇺 Читайте README на [русском языке](README.ru.md). @@ -49,8 +51,8 @@ masterror = { version = "{{CRATE_VERSION}}", default-features = false } - **Clean wire contract.** `ErrorResponse { status, code, message, details?, retry?, www_authenticate? }`. - **One log at boundary.** Log once with `tracing`. - **Less boilerplate.** Built-in conversions, compact prelude, and the - `masterror::Error` re-export of `thiserror::Error` with `#[from]` / - `#[error(transparent)]` support. + native `masterror::Error` derive with `#[from]` / `#[error(transparent)]` + support. - **Consistent workspace.** Same error surface across crates. @@ -136,11 +138,388 @@ let wrapped = WrappedDomainError::from(err); assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); ~~~ -- `use masterror::Error;` re-exports `thiserror::Error`. +- `use masterror::Error;` brings the crate's derive macro into scope. - `#[from]` automatically implements `From<...>` while ensuring wrapper shapes are valid. - `#[error(transparent)]` enforces single-field wrappers that forward `Display`/`source` to the inner error. +- `#[app_error(kind = AppErrorKind::..., code = AppCode::..., message)]` maps the + derived error into `AppError`/`AppCode`. The optional `code = ...` arm emits an + `AppCode` conversion, while the `message` flag forwards the derived + `Display` output as the public message instead of producing a bare error. +- `masterror::error::template::ErrorTemplate` parses `#[error("...")]` + strings, exposing literal and placeholder segments so custom derives can be + implemented without relying on `thiserror`. +- `TemplateFormatter` mirrors `thiserror`'s formatter detection so existing + derives that relied on hexadecimal, pointer or exponential renderers keep + compiling. +- Display placeholders preserve their raw format specs via + `TemplateFormatter::display_spec()` and `TemplateFormatter::format_fragment()`, + so derived code can forward `:>8`, `:.3` and other display-only options + without reconstructing the original string. +- `TemplateFormatterKind` exposes the formatter trait requested by a + placeholder, making it easy to branch on the requested rendering behaviour + without manually matching every enum variant. + +#### Display shorthand projections + +`#[error("...")]` supports the same shorthand syntax as `thiserror` for +referencing fields with `.field` or `.0`. The derive now understands chained +segments, so projections like `.limits.lo`, `.0.data` or +`.suggestion.as_ref().map_or_else(...)` keep compiling unchanged. Raw +identifiers and tuple indices are preserved, ensuring keywords such as +`r#type` and tuple fields continue to work even when you call methods on the +projected value. + +~~~rust +use masterror::Error; + +#[derive(Debug)] +struct Limits { + lo: i32, + hi: i32, +} + +#[derive(Debug, Error)] +#[error( + "range {lo}-{hi} suggestion {suggestion}", + lo = .limits.lo, + hi = .limits.hi, + suggestion = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) +)] +struct RangeError { + limits: Limits, + suggestion: Option, +} + +#[derive(Debug)] +struct Payload { + data: &'static str, +} + +#[derive(Debug, Error)] +enum UiError { + #[error("tuple data {data}", data = .0.data)] + Tuple(Payload), + #[error( + "named suggestion {value}", + value = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) + )] + Named { suggestion: Option }, +} +~~~ + +#### AppError conversions + +Annotating structs or enum variants with `#[app_error(...)]` captures the +metadata required to convert the domain error into `AppError` (and optionally +`AppCode`). Every variant in an enum must provide the mapping when any variant +requests it. + +~~~rust +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +#[error("missing flag: {name}")] +#[app_error(kind = AppErrorKind::BadRequest, code = AppCode::BadRequest, message)] +struct MissingFlag { + name: &'static str, +} + +let app: AppError = MissingFlag { name: "feature" }.into(); +assert!(matches!(app.kind, AppErrorKind::BadRequest)); +assert_eq!(app.message.as_deref(), Some("missing flag: feature")); + +let code: AppCode = MissingFlag { name: "feature" }.into(); +assert!(matches!(code, AppCode::BadRequest)); +~~~ + +For enums, each variant specifies the mapping while the derive generates a +single `From` implementation that matches every variant: + +~~~rust +#[derive(Debug, Error)] +enum ApiError { + #[error("missing resource {id}")] + #[app_error( + kind = AppErrorKind::NotFound, + code = AppCode::NotFound, + message + )] + Missing { id: u64 }, + #[error("backend unavailable")] + #[app_error(kind = AppErrorKind::Service, code = AppCode::Service)] + Backend, +} + +let missing = ApiError::Missing { id: 7 }; +let as_app: AppError = missing.into(); +assert_eq!(as_app.message.as_deref(), Some("missing resource 7")); +~~~ + +#### Structured telemetry providers and AppError mappings + +`#[provide(...)]` exposes typed context through `std::error::Request`, while +`#[app_error(...)]` records how your domain error translates into `AppError` +and `AppCode`. The derive mirrors `thiserror`'s syntax and extends it with +optional telemetry propagation and direct conversions into the `masterror` +runtime types. + +~~~rust +use std::error::request_ref; + +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct TelemetrySnapshot { + name: &'static str, + value: u64, +} + +#[derive(Debug, Error)] +#[error("structured telemetry {snapshot:?}")] +#[app_error(kind = AppErrorKind::Service, code = AppCode::Service)] +struct StructuredTelemetryError { + #[provide(ref = TelemetrySnapshot, value = TelemetrySnapshot)] + snapshot: TelemetrySnapshot, +} + +let err = StructuredTelemetryError { + snapshot: TelemetrySnapshot { + name: "db.query", + value: 42, + }, +}; + +let snapshot = request_ref::(&err).expect("telemetry"); +assert_eq!(snapshot.value, 42); + +let app: AppError = err.into(); +let via_app = request_ref::(&app).expect("telemetry"); +assert_eq!(via_app.name, "db.query"); +~~~ + +Optional telemetry only surfaces when present, so `None` does not register a +provider. Owned snapshots can still be provided as values when the caller +requests ownership: + +~~~rust +use masterror::{AppCode, AppErrorKind, Error}; + +#[derive(Debug, Error)] +#[error("optional telemetry {telemetry:?}")] +#[app_error(kind = AppErrorKind::Internal, code = AppCode::Internal)] +struct OptionalTelemetryError { + #[provide(ref = TelemetrySnapshot, value = TelemetrySnapshot)] + telemetry: Option, +} + +let noisy = OptionalTelemetryError { + telemetry: Some(TelemetrySnapshot { + name: "queue.depth", + value: 17, + }), +}; +let silent = OptionalTelemetryError { telemetry: None }; + +assert!(request_ref::(&noisy).is_some()); +assert!(request_ref::(&silent).is_none()); +~~~ + +Enums support per-variant telemetry and conversion metadata. Each variant chooses +its own `AppErrorKind`/`AppCode` mapping while the derive generates a single +`From` implementation: + +~~~rust +#[derive(Debug, Error)] +enum EnumTelemetryError { + #[error("named {label}")] + #[app_error(kind = AppErrorKind::NotFound, code = AppCode::NotFound)] + Named { + label: &'static str, + #[provide(ref = TelemetrySnapshot)] + snapshot: TelemetrySnapshot, + }, + #[error("optional tuple")] + #[app_error(kind = AppErrorKind::Timeout, code = AppCode::Timeout)] + Optional(#[provide(ref = TelemetrySnapshot)] Option), + #[error("owned tuple")] + #[app_error(kind = AppErrorKind::Service, code = AppCode::Service)] + Owned(#[provide(value = TelemetrySnapshot)] TelemetrySnapshot), +} + +let owned = EnumTelemetryError::Owned(TelemetrySnapshot { + name: "redis.latency", + value: 3, +}); +let app: AppError = owned.into(); +assert!(matches!(app.kind, AppErrorKind::Service)); +~~~ + +Compared to `thiserror`, you retain the familiar deriving surface while gaining +structured telemetry (`#[provide]`) and first-class conversions into +`AppError`/`AppCode` without writing manual `From` implementations. + +#### Formatter traits + +Placeholders default to `Display` (`{value}`) but can opt into richer +formatters via the same specifiers supported by `thiserror` v2. +`TemplateFormatter::is_alternate()` tracks the `#` flag, while +`TemplateFormatterKind` exposes the underlying `core::fmt` trait so derived +code can branch on the requested renderer without manual pattern matching. +Unsupported formatters surface a compile error that mirrors `thiserror`'s +diagnostics. + +| Specifier | `core::fmt` trait | Example output | Notes | +|------------------|----------------------------|------------------------|-------| +| _default_ | `core::fmt::Display` | `value` | User-facing strings; `#` has no effect. | +| `:?` / `:#?` | `core::fmt::Debug` | `Struct { .. }` / multi-line | Mirrors `Debug`; `#` pretty-prints structs. | +| `:x` / `:#x` | `core::fmt::LowerHex` | `0x2a` | Hexadecimal; `#` prepends `0x`. | +| `:X` / `:#X` | `core::fmt::UpperHex` | `0x2A` | Uppercase hex; `#` prepends `0x`. | +| `:p` / `:#p` | `core::fmt::Pointer` | `0x1f00` / `0x1f00` | Raw pointers; `#` is accepted for compatibility. | +| `:b` / `:#b` | `core::fmt::Binary` | `101010` / `0b101010` | Binary; `#` prepends `0b`. | +| `:o` / `:#o` | `core::fmt::Octal` | `52` / `0o52` | Octal; `#` prepends `0o`. | +| `:e` / `:#e` | `core::fmt::LowerExp` | `1.5e-2` | Scientific notation; `#` forces the decimal point. | +| `:E` / `:#E` | `core::fmt::UpperExp` | `1.5E-2` | Uppercase scientific; `#` forces the decimal point. | + +- `TemplateFormatterKind::supports_alternate()` reports whether the `#` flag is + meaningful for the requested trait (pointer accepts it even though the output + matches the non-alternate form). +- `TemplateFormatterKind::specifier()` returns the canonical format specifier + character when one exists, enabling custom derives to re-render placeholders + in their original style. +- `TemplateFormatter::from_kind(kind, alternate)` reconstructs a formatter from + the lightweight `TemplateFormatterKind`, making it easy to toggle the + alternate flag in generated code. + +~~~rust +use core::ptr; + +use masterror::Error; + +#[derive(Debug, Error)] +#[error( + "debug={payload:?}, hex={id:#x}, ptr={ptr:p}, bin={mask:#b}, \ + oct={mask:o}, lower={ratio:e}, upper={ratio:E}" +)] +struct FormattedError { + id: u32, + payload: String, + ptr: *const u8, + mask: u8, + ratio: f32, +} + +let err = FormattedError { + id: 0x2a, + payload: "hello".into(), + ptr: ptr::null(), + mask: 0b1010_0001, + ratio: 0.15625, +}; + +let rendered = err.to_string(); +assert!(rendered.contains("debug=\"hello\"")); +assert!(rendered.contains("hex=0x2a")); +assert!(rendered.contains("ptr=0x0")); +assert!(rendered.contains("bin=0b10100001")); +assert!(rendered.contains("oct=241")); +assert!(rendered.contains("lower=1.5625e-1")); +assert!(rendered.contains("upper=1.5625E-1")); +~~~ + +~~~rust +use masterror::error::template::{ + ErrorTemplate, TemplateFormatter, TemplateFormatterKind +}; + +let template = ErrorTemplate::parse("{code:#x} → {payload:?}").expect("parse"); +let mut placeholders = template.placeholders(); + +let code = placeholders.next().expect("code placeholder"); +let code_formatter = code.formatter(); +assert!(matches!( + code_formatter, + TemplateFormatter::LowerHex { alternate: true } +)); +let code_kind = code_formatter.kind(); +assert_eq!(code_kind, TemplateFormatterKind::LowerHex); +assert!(code_formatter.is_alternate()); +assert_eq!(code_kind.specifier(), Some('x')); +assert!(code_kind.supports_alternate()); +let lowered = TemplateFormatter::from_kind(code_kind, false); +assert!(matches!( + lowered, + TemplateFormatter::LowerHex { alternate: false } +)); + +let payload = placeholders.next().expect("payload placeholder"); +let payload_formatter = payload.formatter(); +assert_eq!( + payload_formatter, + &TemplateFormatter::Debug { alternate: false } +); +let payload_kind = payload_formatter.kind(); +assert_eq!(payload_kind, TemplateFormatterKind::Debug); +assert_eq!(payload_kind.specifier(), Some('?')); +assert!(payload_kind.supports_alternate()); +let pretty_debug = TemplateFormatter::from_kind(payload_kind, true); +assert!(matches!( + pretty_debug, + TemplateFormatter::Debug { alternate: true } +)); +assert!(pretty_debug.is_alternate()); +~~~ + +Display-only format specs (alignment, precision, fill — including `#` as a fill +character) are preserved so you can forward them to `write!` without rebuilding +the fragment: + +~~~rust +use masterror::error::template::ErrorTemplate; + +let aligned = ErrorTemplate::parse("{value:>8}").expect("parse"); +let display = aligned.placeholders().next().expect("display placeholder"); +assert_eq!(display.formatter().display_spec(), Some(">8")); +assert_eq!( + display + .formatter() + .format_fragment() + .as_deref(), + Some(">8") +); + +let hashed = ErrorTemplate::parse("{value:#>4}").expect("parse"); +let hash_placeholder = hashed + .placeholders() + .next() + .expect("hash-fill display placeholder"); +assert_eq!(hash_placeholder.formatter().display_spec(), Some("#>4")); +assert_eq!( + hash_placeholder + .formatter() + .format_fragment() + .as_deref(), + Some("#>4") +); +~~~ + +> **Compatibility with `thiserror` v2:** the derive understands the extended +> formatter set introduced in `thiserror` 2.x and reports identical diagnostics +> for unsupported specifiers, so migrating existing derives is drop-in. + +```rust +use masterror::error::template::{ErrorTemplate, TemplateIdentifier}; + +let template = ErrorTemplate::parse("{code}: {message}").expect("parse"); +let display = template.display_with(|placeholder, f| match placeholder.identifier() { + TemplateIdentifier::Named("code") => write!(f, "{}", 404), + TemplateIdentifier::Named("message") => f.write_str("Not Found"), + _ => Ok(()), +}); + +assert_eq!(display.to_string(), "404: Not Found"); +``` diff --git a/build.rs b/build.rs index 40e16de..92a32ca 100644 --- a/build.rs +++ b/build.rs @@ -17,6 +17,7 @@ fn main() { } fn run() -> Result<(), Box> { + println!("cargo:rustc-check-cfg=cfg(error_generic_member_access)"); println!("cargo:rerun-if-changed=Cargo.toml"); println!("cargo:rerun-if-changed=README.template.md"); println!("cargo:rerun-if-changed=build/readme.rs"); diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..6f7e1ff --- /dev/null +++ b/deny.toml @@ -0,0 +1,27 @@ +[advisories] +ignore = [] +git-fetch-with-cli = true + +[licenses] +allow = [ + "Apache-2.0", + "MIT", +] +confidence-threshold = 0.8 + +[[licenses.exceptions]] +crate = "unicode-ident" +allow = ["Apache-2.0", "MIT", "Unicode-3.0"] + +[licenses.private] +ignore = false + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-git = [] + diff --git a/idea.md b/idea.md new file mode 100644 index 0000000..d5531dc --- /dev/null +++ b/idea.md @@ -0,0 +1,191 @@ +## 1) Декларативный DSL-макрос `errors!` + +Макрос сгенерирует enum, `Display`, `Error`, `MasterError`, `From`, маппинг категорий на HTTP, и билдер-конструкторы. + +```rust +use masterror::prelude::*; + +// comments: English +masterror::errors! { + // Defaults for the whole enum (can be overridden per-variant) + enum OrderError @domain("minty.api") { + // code, category, message, fields, From/Source flags + UnsupportedPair { + code = 1001, + category = InvalidInput, + msg = "pair {pair} is not supported", + fields = { pair: String } + } + + RateLimited { + code = 1002, + category = Unavailable, + msg = "rate limit exceeded", + fields = { retry_after_ms: u64, #[context] ctx: Option } + } + + IoTransparent { + code = 2001, + category = Internal, + transparent, // delegates Display to inner + from, source, // impl From + fields = { inner: std::io::Error } + } + } +} +``` + +Что генерится: + +* `pub enum OrderError { UnsupportedPair { pair: String }, ... }` +* `impl Display + Error + MasterError` с `code/domain/category/context`. +* `impl From for OrderError>` для `IoTransparent`. +* **Фабрики**: `OrderError::unsupported_pair(pair)`, `OrderError::rate_limited(retry_after_ms)`, `.with_ctx(...)`. +* `impl IntoResponse` по фиче `axum` через таблицу категорий → HTTP-статус. + +### Почему это проще, чем derive построчно + +* Нет ручных `#[error]`, `#[from]`, `#[source]` на каждом варианте. +* Одинаковая форма записи, меньше шансов ошибиться. +* Видны коды, категории и сообщения в одном месте как спецификация. + +## 2) Конструкторы и билдер с автодополнением + +```rust +let err = OrderError::unsupported_pair("BTC-FOO".to_string()) + .with_ctx(|c| c.with("req_id", "a1b2").with("user", "42")); +``` + +Реализация идёт через trait-расширение: + +```rust +pub trait ErrorBuildExt: Sized { + fn with_ctx Context>(self, f: F) -> Self; +} +``` + +## 3) Типобезопасные `ensure!`/`fail!` без «магии» + +```rust +use masterror::prelude::*; + +masterror::ensure!( + cond = pair_is_supported(&pair), + else = OrderError::unsupported_pair(pair.clone()) +); + +masterror::fail!(OrderError::rate_limited(1200)); +``` + +Внутри это раскрывается в `return Err(...)` без аллокаций и без скрытых трюков. Никаких `panic!`, никаких глобалов. + +## 4) Автонумерация кодов — но детерминированно + +Макрос присвоит коды по порядку появления и зафиксирует их в сгенерированном `const`: + +```rust +#[cfg(feature = "auto_codes")] +pub const ORDER_ERROR_CODES: &[(&str, u32)] = &[ + ("UnsupportedPair", 1001), + ("RateLimited", 1002), + ("IoTransparent", 2001), +]; +``` + +Плюсы: + +* кратко добавлять варианты, +* легко док-ген и проверка миграций. + +Минус: менять порядок — меняешь коды. Лечится явной простановкой `code = ...` там, где критично. + +## 5) Линтеры прямо из макроса + +* пропущен `code` при выключенной `auto_codes`; +* `transparent` вместе с `msg` запрещены; +* более одного `from`/`source` в варианте; +* конфликт имён фабрик. + +Сообщения макроса должны быть человекочитаемыми и точными. + +## 6) Готовые мапперы для веб и телеметрии + +* `axum`/`actix`: `impl IntoResponse` по `category()`, JSON-тело `{code, domain, message, context}`. +* `tracing`: helper `log_err(&E)` который пишет `error.code/domain/category` и цепочку причин. +* `utoipa`/OpenAPI: схема ошибки как `oneOf` по категориям. + +Всё это подключается автоматически, если включены соответствующие фичи, кода в приложении ноль. + +## 7) Шорткаты в прелюде + +```rust +pub use masterror::prelude::{MasterError, Context, ErrorCategory, ensure, fail}; +``` + +В каждом крейте приложения достаточно `use my_errors::prelude::*;`. + +## 8) Интеграция с `?` и `From` + +В `errors!` `from` у конкретного варианта — и только он получает `impl From`. Это убирает сюрпризы и ускоряет компиляцию. + +```rust +// comments: English +#[tokio::main] +async fn handler() -> Result<(), OrderError> { + let data = std::fs::read("foo")?; // goes via IoTransparent + Ok(()) +} +``` + +## 9) Генерация тестов и чек-лист уникальности + +Макрос сам добавляет `#[cfg(test)]` тест: + +* коды уникальны, +* все `transparent`-варианты имеют ровно одно поле-source, +* `Display` не падает на формат-строках. + +## 10) Миграция с thiserror за минуты + +* Cуществующие enum’ы в `errors!` блоки 1:1. +* Сообщения переносятся в `msg = "... {field} ..."`. +* Для бывших `#[from]/#[source]` ставишь флаги. +* Если хочется ещё короче — включаешь `auto_codes` и не паришься до стабилизации схемы. + +--- + +### Минимальный рабочий пример (как будет выглядеть код приложения) + +```rust +use axum::{routing::get, Router}; +use masterror::prelude::*; + +masterror::errors! { + enum ApiError @domain("minty.api") { + BadParam { category = InvalidInput, msg = "bad param: {name}", fields = { name: String }, code = 1001 } + IoTransparent { category = Internal, transparent, from, source, fields = { inner: std::io::Error }, code = 2001 } + } +} + +async fn endpoint() -> Result<&'static str, ApiError> { + let name = std::env::var("NAME").map_err(ApiError::from)?; // IoTransparent via From? — не даём auto From, предпочтительнее явный вариант + masterror::ensure!(cond = !name.is_empty(), else = ApiError::bad_param(name)); + Ok("ok") +} + +#[tokio::main] +async fn main() { + let app = Router::new().route("/", get(endpoint)); + // run... +} +``` + +--- + +## Компромиссы и почему так + +* Табличный `errors!` снимает 80% рутины и делает ошибки видимой спецификацией. +* `auto_codes` удобен на старте, но фиксируй `code = ...` перед релизом. +* Макрос генерирует фабрики и билдер контекста, что убирает «лишний» конструкторный код и reduce ошибки при вызове. +* Никаких глобальных реестров, никакой «магии» с `anyhow`. Всё явно и проверяется компилятором. + diff --git a/masterror-derive/Cargo.toml b/masterror-derive/Cargo.toml new file mode 100644 index 0000000..1687e51 --- /dev/null +++ b/masterror-derive/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "masterror-derive" +rust-version = "1.90" +version = "0.6.0" +edition = "2024" +license = "MIT OR Apache-2.0" +repository = "https://github.com/RAprogramm/masterror" +readme = "README.md" +description = "Derive macros for masterror" +publish = false + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full", "extra-traits"] } +masterror-template = { workspace = true } diff --git a/masterror-derive/src/app_error_impl.rs b/masterror-derive/src/app_error_impl.rs new file mode 100644 index 0000000..2e06e2b --- /dev/null +++ b/masterror-derive/src/app_error_impl.rs @@ -0,0 +1,197 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::Error; + +use crate::input::{AppErrorSpec, ErrorData, ErrorInput, Fields, StructData, VariantData}; + +pub fn expand(input: &ErrorInput) -> Result, Error> { + match &input.data { + ErrorData::Struct(data) => expand_struct(input, data), + ErrorData::Enum(variants) => expand_enum(input, variants) + } +} + +fn expand_struct(input: &ErrorInput, data: &StructData) -> Result, Error> { + let mut impls = Vec::new(); + + if let Some(spec) = &data.app_error { + impls.push(struct_app_error_impl(input, spec)); + if spec.code.is_some() { + impls.push(struct_app_code_impl(input, spec)); + } + } + + Ok(impls) +} + +fn expand_enum(input: &ErrorInput, variants: &[VariantData]) -> Result, Error> { + let mut impls = Vec::new(); + + if variants.iter().any(|variant| variant.app_error.is_some()) { + ensure_all_have_app_error(variants)?; + impls.push(enum_app_error_impl(input, variants)); + } + + if variants.iter().any(|variant| { + variant + .app_error + .as_ref() + .is_some_and(|spec| spec.code.is_some()) + }) { + ensure_all_have_app_code(variants)?; + impls.push(enum_app_code_impl(input, variants)); + } + + Ok(impls) +} + +fn ensure_all_have_app_error(variants: &[VariantData]) -> Result<(), Error> { + for variant in variants { + if variant.app_error.is_none() { + return Err(Error::new( + variant.span, + "all variants must use #[app_error(...)] to derive AppError conversion" + )); + } + } + Ok(()) +} + +fn ensure_all_have_app_code(variants: &[VariantData]) -> Result<(), Error> { + for variant in variants { + match &variant.app_error { + Some(spec) if spec.code.is_some() => {} + Some(spec) => { + return Err(Error::new( + spec.attribute_span, + "AppCode conversion requires `code = ...` in #[app_error(...)]" + )); + } + None => { + return Err(Error::new( + variant.span, + "all variants must use #[app_error(...)] with `code = ...` to derive AppCode conversion" + )); + } + } + } + Ok(()) +} + +fn struct_app_error_impl(input: &ErrorInput, spec: &AppErrorSpec) -> TokenStream { + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + let kind = &spec.kind; + + let body = if spec.expose_message { + quote! { + masterror::AppError::with(#kind, std::string::ToString::to_string(&value)) + } + } else { + quote! { + { + let _ = value; + masterror::AppError::bare(#kind) + } + } + }; + + quote! { + impl #impl_generics core::convert::From<#ident #ty_generics> for masterror::AppError #where_clause { + fn from(value: #ident #ty_generics) -> Self { + #body + } + } + } +} + +fn struct_app_code_impl(input: &ErrorInput, spec: &AppErrorSpec) -> TokenStream { + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + let code = spec.code.as_ref().expect("code presence checked"); + + quote! { + impl #impl_generics core::convert::From<#ident #ty_generics> for masterror::AppCode #where_clause { + fn from(value: #ident #ty_generics) -> Self { + let _ = value; + #code + } + } + } +} + +fn enum_app_error_impl(input: &ErrorInput, variants: &[VariantData]) -> TokenStream { + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let mut arms = Vec::new(); + for variant in variants { + let spec = variant.app_error.as_ref().expect("presence checked"); + let kind = &spec.kind; + let pattern = variant_app_error_pattern(ident, variant); + let body = if spec.expose_message { + quote! { + masterror::AppError::with(#kind, std::string::ToString::to_string(&err)) + } + } else { + quote! { + { + let _ = err; + masterror::AppError::bare(#kind) + } + } + }; + arms.push(quote! { #pattern => #body }); + } + + quote! { + impl #impl_generics core::convert::From<#ident #ty_generics> for masterror::AppError #where_clause { + fn from(value: #ident #ty_generics) -> Self { + match value { + #(#arms),* + } + } + } + } +} + +fn enum_app_code_impl(input: &ErrorInput, variants: &[VariantData]) -> TokenStream { + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let mut arms = Vec::new(); + for variant in variants { + let spec = variant.app_error.as_ref().expect("presence checked"); + let pattern = variant_app_code_pattern(ident, variant); + let code = spec.code.as_ref().expect("code presence checked"); + arms.push(quote! { #pattern => #code }); + } + + quote! { + impl #impl_generics core::convert::From<#ident #ty_generics> for masterror::AppCode #where_clause { + fn from(value: #ident #ty_generics) -> Self { + match value { + #(#arms),* + } + } + } + } +} + +fn variant_app_error_pattern(enum_ident: &syn::Ident, variant: &VariantData) -> TokenStream { + let ident = &variant.ident; + match &variant.fields { + Fields::Unit => quote! { err @ #enum_ident::#ident }, + Fields::Named(_) => quote! { err @ #enum_ident::#ident { .. } }, + Fields::Unnamed(_) => quote! { err @ #enum_ident::#ident(..) } + } +} + +fn variant_app_code_pattern(enum_ident: &syn::Ident, variant: &VariantData) -> TokenStream { + let ident = &variant.ident; + match &variant.fields { + Fields::Unit => quote! { #enum_ident::#ident }, + Fields::Named(_) => quote! { #enum_ident::#ident { .. } }, + Fields::Unnamed(_) => quote! { #enum_ident::#ident(..) } + } +} diff --git a/masterror-derive/src/display.rs b/masterror-derive/src/display.rs new file mode 100644 index 0000000..21c3a90 --- /dev/null +++ b/masterror-derive/src/display.rs @@ -0,0 +1,1497 @@ +use std::{borrow::Cow, collections::HashMap}; + +use masterror_template::template::{TemplateFormatter, TemplateFormatterKind}; +use proc_macro2::{Ident, Literal, Span, TokenStream}; +use quote::{format_ident, quote, quote_spanned}; +use syn::{Error, Index}; + +use crate::{ + input::{ + DisplaySpec, ErrorData, ErrorInput, Field, Fields, FormatArg, FormatArgProjection, + FormatArgProjectionMethodCall, FormatArgProjectionSegment, FormatArgShorthand, + FormatArgValue, FormatArgsSpec, FormatBindingKind, StructData, VariantData, + placeholder_error + }, + template_support::{ + DisplayTemplate, TemplateIdentifierSpec, TemplatePlaceholderSpec, TemplateSegmentSpec + } +}; + +pub fn expand(input: &ErrorInput) -> Result { + match &input.data { + ErrorData::Struct(data) => expand_struct(input, data), + ErrorData::Enum(variants) => expand_enum(input, variants) + } +} + +fn expand_struct(input: &ErrorInput, data: &StructData) -> Result { + let body = match &data.display { + DisplaySpec::Transparent { + .. + } => render_struct_transparent(&data.fields), + DisplaySpec::Template(template) => { + render_template(template, Vec::new(), Vec::new(), |placeholder| { + struct_placeholder_expr(&data.fields, placeholder, None) + })? + } + DisplaySpec::TemplateWithArgs { + template, + args + } => { + let mut env = FormatArgumentsEnv::new_struct(args, &data.fields); + let preludes = env.prelude_tokens(); + let format_arguments = env.argument_tokens()?; + render_template(template, preludes, format_arguments, |placeholder| { + struct_placeholder_expr(&data.fields, placeholder, Some(&mut env)) + })? + } + DisplaySpec::FormatterPath { + path, .. + } => render_struct_formatter_path(&data.fields, path) + }; + + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + Ok(quote! { + impl #impl_generics core::fmt::Display for #ident #ty_generics #where_clause { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + #body + } + } + }) +} + +fn expand_enum(input: &ErrorInput, variants: &[VariantData]) -> Result { + let mut arms = Vec::new(); + + for variant in variants { + arms.push(render_variant(variant)?); + } + + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + Ok(quote! { + impl #impl_generics core::fmt::Display for #ident #ty_generics #where_clause { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + #(#arms),* + } + } + } + }) +} + +fn render_struct_transparent(fields: &Fields) -> TokenStream { + if let Some(field) = fields.iter().next() { + let member = &field.member; + quote! { + core::fmt::Display::fmt(&self.#member, f) + } + } else { + quote! { + Ok(()) + } + } +} + +fn struct_formatter_arguments(fields: &Fields) -> Vec { + match fields { + Fields::Unit => Vec::new(), + Fields::Named(fields) | Fields::Unnamed(fields) => fields + .iter() + .map(|field| { + let member = &field.member; + quote!(&self.#member) + }) + .collect() + } +} + +fn formatter_path_call(path: &syn::ExprPath, mut args: Vec) -> TokenStream { + args.push(quote!(f)); + quote! { + #path(#(#args),*) + } +} + +fn render_variant(variant: &VariantData) -> Result { + match &variant.display { + DisplaySpec::Transparent { + .. + } => render_variant_transparent(variant), + DisplaySpec::Template(template) => render_variant_template(variant, template, None), + DisplaySpec::TemplateWithArgs { + template, + args + } => render_variant_template(variant, template, Some(args)), + DisplaySpec::FormatterPath { + path, .. + } => render_variant_formatter_path(variant, path) + } +} + +fn render_struct_formatter_path(fields: &Fields, path: &syn::ExprPath) -> TokenStream { + let args = struct_formatter_arguments(fields); + formatter_path_call(path, args) +} + +#[derive(Debug)] +struct ResolvedPlaceholderExpr { + expr: TokenStream, + pointer_value: bool +} + +impl ResolvedPlaceholderExpr { + fn new(expr: TokenStream) -> Self { + Self::with(expr, false) + } + + fn pointer(expr: TokenStream) -> Self { + Self::with(expr, true) + } + + fn with(expr: TokenStream, pointer_value: bool) -> Self { + Self { + expr, + pointer_value + } + } + + fn expr_tokens(&self) -> TokenStream { + self.expr.clone() + } +} + +#[derive(Debug)] +enum RenderedSegment { + Literal(String), + Placeholder(PlaceholderRender) +} + +#[derive(Debug)] +struct PlaceholderRender { + identifier: TemplateIdentifierSpec, + formatter: TemplateFormatter, + span: Span, + resolved: ResolvedPlaceholderExpr +} + +#[derive(Debug)] +struct ResolvedFormatArgument { + kind: ResolvedFormatArgumentKind, + expr: TokenStream +} + +#[derive(Debug)] +enum ResolvedFormatArgumentKind { + Named(Ident), + Positional(usize), + Implicit(usize) +} + +#[derive(Debug)] +struct FormatArgumentsEnv<'a> { + context: FormatArgContext<'a>, + args: Vec>, + named: HashMap, + positional: HashMap, + implicit: Vec> +} + +#[derive(Debug)] +enum FormatArgContext<'a> { + Struct(&'a Fields), + Variant { + fields: &'a Fields, + bindings: Vec + } +} + +#[derive(Debug)] +struct EnvFormatArg<'a> { + binding: Option, + arg: &'a FormatArg +} + +impl<'a> FormatArgumentsEnv<'a> { + fn new_struct(spec: &'a FormatArgsSpec, fields: &'a Fields) -> Self { + Self::new_with_context(spec, FormatArgContext::Struct(fields)) + } + + fn new_variant(spec: &'a FormatArgsSpec, fields: &'a Fields, bindings: &[Ident]) -> Self { + Self::new_with_context( + spec, + FormatArgContext::Variant { + fields, + bindings: bindings.to_vec() + } + ) + } + + fn new_with_context(spec: &'a FormatArgsSpec, context: FormatArgContext<'a>) -> Self { + let mut env = Self { + context, + args: Vec::new(), + named: HashMap::new(), + positional: HashMap::new(), + implicit: Vec::new() + }; + + for (index, arg) in spec.args.iter().enumerate() { + let binding = match &arg.value { + FormatArgValue::Expr(_) => Some(format_ident!("__masterror_format_arg_{}", index)), + FormatArgValue::Shorthand(_) => None + }; + + let arg_index = env.args.len(); + env.args.push(EnvFormatArg { + binding: binding.clone(), + arg + }); + + match &arg.kind { + FormatBindingKind::Named(ident) => { + env.named.insert(ident.to_string(), arg_index); + } + FormatBindingKind::Positional(pos_index) => { + env.positional.insert(*pos_index, arg_index); + env.implicit.push(Some(arg_index)); + } + FormatBindingKind::Implicit(implicit_index) => { + env.register_implicit(*implicit_index, arg_index); + } + } + } + + env + } + + fn prelude_tokens(&self) -> Vec { + self.args.iter().map(EnvFormatArg::prelude_tokens).collect() + } + + fn argument_tokens(&self) -> Result, Error> { + self.args + .iter() + .map(|arg| arg.argument_tokens(self)) + .collect() + } + + fn resolve_placeholder( + &mut self, + placeholder: &TemplatePlaceholderSpec + ) -> Result, Error> { + let arg_index = match &placeholder.identifier { + TemplateIdentifierSpec::Named(name) => self.named.get(name).copied(), + TemplateIdentifierSpec::Positional(index) => self.positional.get(index).copied(), + TemplateIdentifierSpec::Implicit(index) => { + self.implicit.get(*index).and_then(|slot| *slot) + } + }; + + let index = match arg_index { + Some(index) => index, + None => return Ok(None) + }; + + let resolved = self.args[index].resolved_expr(self, placeholder)?; + Ok(Some(resolved)) + } + + fn register_implicit(&mut self, index: usize, arg_index: usize) { + if self.implicit.len() <= index { + self.implicit.resize(index + 1, None); + } + self.implicit[index] = Some(arg_index); + } + + fn resolve_shorthand( + &self, + shorthand: &FormatArgShorthand, + placeholder: &TemplatePlaceholderSpec + ) -> Result { + match &self.context { + FormatArgContext::Struct(fields) => { + resolve_struct_shorthand(fields, shorthand, placeholder) + } + FormatArgContext::Variant { + fields, + bindings + } => resolve_variant_shorthand(fields, bindings, shorthand, placeholder) + } + } + + fn resolve_shorthand_argument( + &self, + shorthand: &FormatArgShorthand + ) -> Result { + match &self.context { + FormatArgContext::Struct(fields) => { + resolve_struct_shorthand_argument(fields, shorthand) + } + FormatArgContext::Variant { + fields, + bindings + } => resolve_variant_shorthand_argument(fields, bindings, shorthand) + } + } +} + +impl<'a> EnvFormatArg<'a> { + fn prelude_tokens(&self) -> TokenStream { + match (&self.binding, &self.arg.value) { + (Some(binding), FormatArgValue::Expr(expr)) => { + quote! { let #binding = #expr; } + } + _ => TokenStream::new() + } + } + + fn resolved_expr( + &self, + env: &FormatArgumentsEnv<'_>, + placeholder: &TemplatePlaceholderSpec + ) -> Result { + match (&self.binding, &self.arg.value) { + (Some(binding), FormatArgValue::Expr(_)) => { + if needs_pointer_value(&placeholder.formatter) { + Ok(ResolvedPlaceholderExpr::with(quote!(#binding), true)) + } else { + Ok(ResolvedPlaceholderExpr::new(quote!(&#binding))) + } + } + (_, FormatArgValue::Shorthand(shorthand)) => { + env.resolve_shorthand(shorthand, placeholder) + } + _ => unreachable!() + } + } + + fn argument_tokens( + &self, + env: &FormatArgumentsEnv<'_> + ) -> Result { + let expr = match (&self.binding, &self.arg.value) { + (Some(binding), FormatArgValue::Expr(_)) => Ok(quote!(#binding)), + (_, FormatArgValue::Shorthand(shorthand)) => env.resolve_shorthand_argument(shorthand), + _ => Err(Error::new( + self.arg.span, + "format argument expression binding was not generated" + )) + }?; + + let kind = match &self.arg.kind { + FormatBindingKind::Named(ident) => ResolvedFormatArgumentKind::Named(ident.clone()), + FormatBindingKind::Positional(index) => ResolvedFormatArgumentKind::Positional(*index), + FormatBindingKind::Implicit(index) => ResolvedFormatArgumentKind::Implicit(*index) + }; + + Ok(ResolvedFormatArgument { + kind, + expr + }) + } +} + +fn render_variant_transparent(variant: &VariantData) -> Result { + let variant_ident = &variant.ident; + + match &variant.fields { + Fields::Unit => Err(Error::new( + variant.span, + "#[error(transparent)] requires exactly one field" + )), + Fields::Named(fields) | Fields::Unnamed(fields) => { + if fields.len() != 1 { + return Err(Error::new( + variant.span, + "#[error(transparent)] requires exactly one field" + )); + } + + let binding = binding_ident(&fields[0]); + let pattern = match &variant.fields { + Fields::Named(_) => { + let field_ident = fields[0].ident.clone().expect("named field"); + quote!(Self::#variant_ident { #field_ident: #binding }) + } + Fields::Unnamed(_) => { + quote!(Self::#variant_ident(#binding)) + } + Fields::Unit => unreachable!() + }; + + Ok(quote! { + #pattern => core::fmt::Display::fmt(#binding, f) + }) + } + } +} + +fn resolve_struct_shorthand( + fields: &Fields, + shorthand: &FormatArgShorthand, + placeholder: &TemplatePlaceholderSpec +) -> Result { + let FormatArgShorthand::Projection(projection) = shorthand; + + let (expr, first_field, has_tail) = struct_projection_expr(fields, projection)?; + + if !has_tail && let Some(field) = first_field { + return Ok(struct_field_expr(field, &placeholder.formatter)); + } + + if needs_pointer_value(&placeholder.formatter) { + Ok(ResolvedPlaceholderExpr::with(expr, false)) + } else { + Ok(ResolvedPlaceholderExpr::new(quote!(&(#expr)))) + } +} + +fn resolve_variant_shorthand( + fields: &Fields, + bindings: &[Ident], + shorthand: &FormatArgShorthand, + placeholder: &TemplatePlaceholderSpec +) -> Result { + let FormatArgShorthand::Projection(projection) = shorthand; + + let Some(first_segment) = projection.segments.first() else { + return Err(Error::new( + projection.span, + "empty shorthand projection is not supported" + )); + }; + + match first_segment { + FormatArgProjectionSegment::Field(ident) => { + let Fields::Named(named_fields) = fields else { + return Err(Error::new( + ident.span(), + format!( + "named field `{}` is not available for tuple variants", + ident + ) + )); + }; + + let position = named_fields.iter().position(|field| { + field + .ident + .as_ref() + .is_some_and(|field_ident| field_ident == ident) + }); + + let index = position.ok_or_else(|| { + Error::new( + ident.span(), + format!("unknown field `{}` in format arguments", ident) + ) + })?; + + let binding = bindings.get(index).ok_or_else(|| { + Error::new( + ident.span(), + format!("field `{}` is not available in format arguments", ident) + ) + })?; + + let expr = if projection.segments.len() == 1 { + quote!(#binding) + } else { + append_projection_segments(quote!(#binding), &projection.segments[1..]) + }; + + if projection.segments.len() == 1 { + Ok(ResolvedPlaceholderExpr::with( + expr, + needs_pointer_value(&placeholder.formatter) + )) + } else if needs_pointer_value(&placeholder.formatter) { + Ok(ResolvedPlaceholderExpr::with(expr, false)) + } else { + Ok(ResolvedPlaceholderExpr::new(quote!(&(#expr)))) + } + } + FormatArgProjectionSegment::Index { + index, + span + } => { + let Fields::Unnamed(_) = fields else { + return Err(Error::new( + *span, + "positional fields are not available for struct variants" + )); + }; + + let binding = bindings.get(*index).ok_or_else(|| { + Error::new( + *span, + format!("field `{}` is not available in format arguments", index) + ) + })?; + + let expr = if projection.segments.len() == 1 { + quote!(#binding) + } else { + append_projection_segments(quote!(#binding), &projection.segments[1..]) + }; + + if projection.segments.len() == 1 { + Ok(ResolvedPlaceholderExpr::with( + expr, + needs_pointer_value(&placeholder.formatter) + )) + } else if needs_pointer_value(&placeholder.formatter) { + Ok(ResolvedPlaceholderExpr::with(expr, false)) + } else { + Ok(ResolvedPlaceholderExpr::new(quote!(&(#expr)))) + } + } + FormatArgProjectionSegment::MethodCall(call) => Err(Error::new( + call.span, + "variant format projections must start with a field or index" + )) + } +} + +fn resolve_struct_shorthand_argument( + fields: &Fields, + shorthand: &FormatArgShorthand +) -> Result { + let FormatArgShorthand::Projection(projection) = shorthand; + let (expr, ..) = struct_projection_expr(fields, projection)?; + Ok(expr) +} + +fn resolve_variant_shorthand_argument( + fields: &Fields, + bindings: &[Ident], + shorthand: &FormatArgShorthand +) -> Result { + let FormatArgShorthand::Projection(projection) = shorthand; + + let Some(first_segment) = projection.segments.first() else { + return Err(Error::new( + projection.span, + "empty shorthand projection is not supported" + )); + }; + + match first_segment { + FormatArgProjectionSegment::Field(ident) => { + let Fields::Named(named_fields) = fields else { + return Err(Error::new( + ident.span(), + format!( + "named field `{}` is not available for tuple variants", + ident + ) + )); + }; + + let position = named_fields.iter().position(|field| { + field + .ident + .as_ref() + .is_some_and(|field_ident| field_ident == ident) + }); + + let index = position.ok_or_else(|| { + Error::new( + ident.span(), + format!("unknown field `{}` in format arguments", ident) + ) + })?; + + let binding = bindings.get(index).ok_or_else(|| { + Error::new( + ident.span(), + format!("field `{}` is not available in format arguments", ident) + ) + })?; + + if projection.segments.len() == 1 { + Ok(quote!(#binding)) + } else { + Ok(append_projection_segments( + quote!(#binding), + &projection.segments[1..] + )) + } + } + FormatArgProjectionSegment::Index { + index, + span + } => { + let Fields::Unnamed(_) = fields else { + return Err(Error::new( + *span, + "positional fields are not available for struct variants" + )); + }; + + let binding = bindings.get(*index).ok_or_else(|| { + Error::new( + *span, + format!("field `{}` is not available in format arguments", index) + ) + })?; + + if projection.segments.len() == 1 { + Ok(quote!(#binding)) + } else { + Ok(append_projection_segments( + quote!(#binding), + &projection.segments[1..] + )) + } + } + FormatArgProjectionSegment::MethodCall(call) => Err(Error::new( + call.span, + "variant format projections must start with a field or index" + )) + } +} + +fn struct_projection_expr<'a>( + fields: &'a Fields, + projection: &'a FormatArgProjection +) -> Result<(TokenStream, Option<&'a Field>, bool), Error> { + let Some(first) = projection.segments.first() else { + return Err(Error::new( + projection.span, + "empty shorthand projection is not supported" + )); + }; + + let mut first_field = None; + let mut expr = match first { + FormatArgProjectionSegment::Field(ident) => { + let field = fields.get_named(&ident.to_string()).ok_or_else(|| { + Error::new( + ident.span(), + format!("unknown field `{}` in format arguments", ident) + ) + })?; + first_field = Some(field); + let member = &field.member; + quote!(self.#member) + } + FormatArgProjectionSegment::Index { + index, + span + } => { + let field = fields.get_positional(*index).ok_or_else(|| { + Error::new( + *span, + format!("field `{}` is not available in format arguments", index) + ) + })?; + first_field = Some(field); + let member = &field.member; + quote!(self.#member) + } + FormatArgProjectionSegment::MethodCall(call) => append_method_call(quote!(self), call) + }; + + if projection.segments.len() > 1 { + expr = append_projection_segments(expr, &projection.segments[1..]); + } + + Ok((expr, first_field, projection.segments.len() > 1)) +} + +fn append_projection_segments( + mut expr: TokenStream, + segments: &[FormatArgProjectionSegment] +) -> TokenStream { + for segment in segments { + expr = append_projection_segment(expr, segment); + } + expr +} + +fn append_projection_segment( + expr: TokenStream, + segment: &FormatArgProjectionSegment +) -> TokenStream { + match segment { + FormatArgProjectionSegment::Field(ident) => quote!((#expr).#ident), + FormatArgProjectionSegment::Index { + index, + span + } => { + let index_token = Index { + index: *index as u32, + span: *span + }; + quote!((#expr).#index_token) + } + FormatArgProjectionSegment::MethodCall(call) => append_method_call(expr, call) + } +} + +fn append_method_call(expr: TokenStream, call: &FormatArgProjectionMethodCall) -> TokenStream { + let method = &call.method; + let args = &call.args; + if let Some(turbofish) = &call.turbofish { + let colon2 = turbofish.colon2_token; + let generics = &turbofish.generics; + quote!((#expr).#method #colon2 #generics (#args)) + } else { + quote!((#expr).#method(#args)) + } +} + +fn render_variant_formatter_path( + variant: &VariantData, + path: &syn::ExprPath +) -> Result { + let variant_ident = &variant.ident; + match &variant.fields { + Fields::Unit => { + let call = formatter_path_call(path, Vec::new()); + Ok(quote! { + Self::#variant_ident => { + #call + } + }) + } + Fields::Unnamed(fields) => { + let bindings: Vec<_> = fields.iter().map(binding_ident).collect(); + let pattern = quote!(Self::#variant_ident(#(#bindings),*)); + let call = formatter_path_call(path, variant_formatter_arguments(&bindings)); + Ok(quote! { + #pattern => { + #call + } + }) + } + Fields::Named(fields) => { + let bindings: Vec<_> = fields.iter().map(binding_ident).collect(); + let pattern = quote!(Self::#variant_ident { #(#bindings),* }); + let call = formatter_path_call(path, variant_formatter_arguments(&bindings)); + Ok(quote! { + #pattern => { + #call + } + }) + } + } +} + +fn variant_formatter_arguments(bindings: &[Ident]) -> Vec { + bindings.iter().map(|binding| quote!(#binding)).collect() +} + +fn render_variant_template( + variant: &VariantData, + template: &DisplayTemplate, + format_args: Option<&FormatArgsSpec> +) -> Result { + let variant_ident = &variant.ident; + match &variant.fields { + Fields::Unit => { + let mut env = format_args + .map(|args| FormatArgumentsEnv::new_variant(args, &variant.fields, &[])); + let preludes = env + .as_ref() + .map(|env| env.prelude_tokens()) + .unwrap_or_default(); + let format_arguments = if let Some(env) = env.as_ref() { + env.argument_tokens()? + } else { + Vec::new() + }; + let span = variant.span; + let body = render_template(template, preludes, format_arguments, |placeholder| { + if let Some(env) = env.as_mut() + && let Some(resolved) = env.resolve_placeholder(placeholder)? + { + return Ok(resolved); + } + Err(Error::new(span, "unit variants cannot reference fields")) + })?; + Ok(quote! { + Self::#variant_ident => { + #body + } + }) + } + Fields::Unnamed(fields) => { + let bindings: Vec<_> = fields.iter().map(binding_ident).collect(); + let mut env = format_args + .map(|args| FormatArgumentsEnv::new_variant(args, &variant.fields, &bindings)); + let pattern = quote!(Self::#variant_ident(#(#bindings),*)); + let preludes = env + .as_ref() + .map(|env| env.prelude_tokens()) + .unwrap_or_default(); + let format_arguments = if let Some(env) = env.as_ref() { + env.argument_tokens()? + } else { + Vec::new() + }; + let body = render_template(template, preludes, format_arguments, |placeholder| { + variant_tuple_placeholder(&bindings, placeholder, env.as_mut()) + })?; + Ok(quote! { + #pattern => { + #body + } + }) + } + Fields::Named(fields) => { + let bindings: Vec<_> = fields + .iter() + .map(|field| field.ident.clone().expect("named field")) + .collect(); + let mut env = format_args + .map(|args| FormatArgumentsEnv::new_variant(args, &variant.fields, &bindings)); + let pattern = quote!(Self::#variant_ident { #(#bindings),* }); + let preludes = env + .as_ref() + .map(|env| env.prelude_tokens()) + .unwrap_or_default(); + let format_arguments = if let Some(env) = env.as_ref() { + env.argument_tokens()? + } else { + Vec::new() + }; + let body = render_template(template, preludes, format_arguments, |placeholder| { + variant_named_placeholder(fields, &bindings, placeholder, env.as_mut()) + })?; + Ok(quote! { + #pattern => { + #body + } + }) + } + } +} + +fn render_template( + template: &DisplayTemplate, + preludes: Vec, + format_args: Vec, + mut resolver: F +) -> Result +where + F: FnMut(&TemplatePlaceholderSpec) -> Result +{ + let mut segments = Vec::new(); + let mut literal_buffer = String::new(); + let mut format_buffer = String::new(); + let mut has_placeholder = false; + let mut has_implicit_placeholders = false; + let mut requires_format_engine = false; + + for segment in &template.segments { + match segment { + TemplateSegmentSpec::Literal(text) => { + literal_buffer.push_str(text); + push_literal_fragment(&mut format_buffer, text); + segments.push(RenderedSegment::Literal(text.clone())); + } + TemplateSegmentSpec::Placeholder(placeholder) => { + has_placeholder = true; + if matches!(placeholder.identifier, TemplateIdentifierSpec::Implicit(_)) { + has_implicit_placeholders = true; + } + if placeholder_requires_format_engine(&placeholder.formatter) { + requires_format_engine = true; + } + + let resolved = resolver(placeholder)?; + format_buffer.push_str(&placeholder_format_fragment(placeholder)); + segments.push(RenderedSegment::Placeholder(PlaceholderRender { + identifier: placeholder.identifier.clone(), + formatter: placeholder.formatter.clone(), + span: placeholder.span, + resolved + })); + } + } + } + + let has_additional_arguments = !preludes.is_empty() || !format_args.is_empty(); + + if !has_placeholder && !has_additional_arguments { + let literal = Literal::string(&literal_buffer); + return Ok(quote! { + #(#preludes)* + f.write_str(#literal) + }); + } + + if has_additional_arguments || has_implicit_placeholders || requires_format_engine { + let format_literal = Literal::string(&format_buffer); + let args = build_template_arguments(&segments, format_args); + return Ok(quote! { + #(#preludes)* + ::core::write!(f, #format_literal #(, #args)*) + }); + } + + let mut pieces = preludes; + for segment in segments { + match segment { + RenderedSegment::Literal(text) => { + pieces.push(quote! { f.write_str(#text)?; }); + } + RenderedSegment::Placeholder(placeholder) => { + pieces.push(format_placeholder( + placeholder.resolved, + placeholder.formatter + )); + } + } + } + pieces.push(quote! { Ok(()) }); + + Ok(quote! { + #(#pieces)* + }) +} + +#[derive(Debug)] +struct NamedArgument { + name: String, + span: Span, + expr: TokenStream +} + +#[derive(Debug)] +struct IndexedArgument { + index: usize, + expr: TokenStream +} + +fn build_template_arguments( + segments: &[RenderedSegment], + format_args: Vec +) -> Vec { + let mut named = Vec::new(); + let mut positional = Vec::new(); + let mut implicit = Vec::new(); + + for segment in segments { + let RenderedSegment::Placeholder(placeholder) = segment else { + continue; + }; + + match &placeholder.identifier { + TemplateIdentifierSpec::Named(name) => { + if named + .iter() + .any(|argument: &NamedArgument| argument.name == *name) + { + continue; + } + + named.push(NamedArgument { + name: name.clone(), + span: placeholder.span, + expr: placeholder.resolved.expr_tokens() + }); + } + TemplateIdentifierSpec::Positional(index) => { + if positional + .iter() + .any(|argument: &IndexedArgument| argument.index == *index) + { + continue; + } + + positional.push(IndexedArgument { + index: *index, + expr: placeholder.resolved.expr_tokens() + }); + } + TemplateIdentifierSpec::Implicit(index) => { + if implicit + .iter() + .any(|argument: &IndexedArgument| argument.index == *index) + { + continue; + } + + implicit.push(IndexedArgument { + index: *index, + expr: placeholder.resolved.expr_tokens() + }); + } + } + } + + for argument in format_args { + match argument.kind { + ResolvedFormatArgumentKind::Named(ident) => { + let name = ident.to_string(); + if named + .iter() + .any(|existing: &NamedArgument| existing.name == name) + { + continue; + } + + let span = ident.span(); + named.push(NamedArgument { + name, + span, + expr: argument.expr + }); + } + ResolvedFormatArgumentKind::Positional(index) => { + if positional + .iter() + .any(|existing: &IndexedArgument| existing.index == index) + || implicit + .iter() + .any(|existing: &IndexedArgument| existing.index == index) + { + continue; + } + + positional.push(IndexedArgument { + index, + expr: argument.expr + }); + } + ResolvedFormatArgumentKind::Implicit(index) => { + if implicit + .iter() + .any(|existing: &IndexedArgument| existing.index == index) + { + continue; + } + + implicit.push(IndexedArgument { + index, + expr: argument.expr + }); + } + } + } + + positional.sort_by_key(|argument| argument.index); + implicit.sort_by_key(|argument| argument.index); + + let mut arguments = Vec::with_capacity(named.len() + positional.len() + implicit.len()); + for IndexedArgument { + expr, .. + } in positional + { + arguments.push(expr); + } + for IndexedArgument { + expr, .. + } in implicit + { + arguments.push(expr); + } + for NamedArgument { + name, + span, + expr + } in named + { + let ident = format_ident!("{}", name, span = span); + arguments.push(quote_spanned!(span => #ident = #expr)); + } + + arguments +} + +fn placeholder_requires_format_engine(formatter: &TemplateFormatter) -> bool { + formatter.kind() != TemplateFormatterKind::Display || formatter.has_display_spec() +} + +fn push_literal_fragment(buffer: &mut String, literal: &str) { + for ch in literal.chars() { + match ch { + '{' => buffer.push_str("{{"), + '}' => buffer.push_str("}}"), + _ => buffer.push(ch) + } + } +} + +fn placeholder_format_fragment(placeholder: &TemplatePlaceholderSpec) -> String { + let mut fragment = String::from("{"); + + match &placeholder.identifier { + TemplateIdentifierSpec::Named(name) => fragment.push_str(name), + TemplateIdentifierSpec::Positional(index) => fragment.push_str(&index.to_string()), + TemplateIdentifierSpec::Implicit(_) => {} + } + + if let Some(spec) = formatter_format_fragment(&placeholder.formatter) { + fragment.push(':'); + fragment.push_str(spec.as_ref()); + } + + fragment.push('}'); + fragment +} + +fn formatter_format_fragment<'a>(formatter: &'a TemplateFormatter) -> Option> { + formatter.format_fragment() +} + +fn struct_placeholder_expr( + fields: &Fields, + placeholder: &TemplatePlaceholderSpec, + env: Option<&mut FormatArgumentsEnv<'_>> +) -> Result { + if matches!( + &placeholder.identifier, + TemplateIdentifierSpec::Named(name) if name == "self" + ) { + return Ok(ResolvedPlaceholderExpr::with( + quote!(self), + needs_pointer_value(&placeholder.formatter) + )); + } + + if let Some(env) = env + && let Some(resolved) = env.resolve_placeholder(placeholder)? + { + return Ok(resolved); + } + + match &placeholder.identifier { + TemplateIdentifierSpec::Named(name) => { + if let Some(field) = fields.get_named(name) { + Ok(struct_field_expr(field, &placeholder.formatter)) + } else { + Err(placeholder_error(placeholder.span, &placeholder.identifier)) + } + } + TemplateIdentifierSpec::Positional(index) => fields + .get_positional(*index) + .map(|field| struct_field_expr(field, &placeholder.formatter)) + .ok_or_else(|| placeholder_error(placeholder.span, &placeholder.identifier)), + TemplateIdentifierSpec::Implicit(index) => fields + .get_positional(*index) + .map(|field| struct_field_expr(field, &placeholder.formatter)) + .ok_or_else(|| placeholder_error(placeholder.span, &placeholder.identifier)) + } +} + +fn struct_field_expr(field: &Field, formatter: &TemplateFormatter) -> ResolvedPlaceholderExpr { + let member = &field.member; + + if needs_pointer_value(formatter) && pointer_prefers_value(&field.ty) { + ResolvedPlaceholderExpr::pointer(quote!(self.#member)) + } else { + ResolvedPlaceholderExpr::new(quote!(&self.#member)) + } +} + +fn needs_pointer_value(formatter: &TemplateFormatter) -> bool { + formatter.kind() == TemplateFormatterKind::Pointer +} + +fn pointer_prefers_value(ty: &syn::Type) -> bool { + match ty { + syn::Type::Ptr(_) => true, + syn::Type::Reference(reference) => reference.mutability.is_none(), + syn::Type::Path(path) => path + .path + .segments + .last() + .map(|segment| segment.ident == "NonNull") + .unwrap_or(false), + _ => false + } +} + +fn variant_tuple_placeholder( + bindings: &[Ident], + placeholder: &TemplatePlaceholderSpec, + env: Option<&mut FormatArgumentsEnv<'_>> +) -> Result { + if matches!( + &placeholder.identifier, + TemplateIdentifierSpec::Named(name) if name == "self" + ) { + return Ok(ResolvedPlaceholderExpr::with( + quote!(self), + needs_pointer_value(&placeholder.formatter) + )); + } + + if let Some(env) = env + && let Some(resolved) = env.resolve_placeholder(placeholder)? + { + return Ok(resolved); + } + + match &placeholder.identifier { + TemplateIdentifierSpec::Named(_) => { + Err(placeholder_error(placeholder.span, &placeholder.identifier)) + } + TemplateIdentifierSpec::Positional(index) => bindings + .get(*index) + .map(|binding| { + ResolvedPlaceholderExpr::with( + quote!(#binding), + needs_pointer_value(&placeholder.formatter) + ) + }) + .ok_or_else(|| placeholder_error(placeholder.span, &placeholder.identifier)), + TemplateIdentifierSpec::Implicit(index) => bindings + .get(*index) + .map(|binding| { + ResolvedPlaceholderExpr::with( + quote!(#binding), + needs_pointer_value(&placeholder.formatter) + ) + }) + .ok_or_else(|| placeholder_error(placeholder.span, &placeholder.identifier)) + } +} + +fn variant_named_placeholder( + fields: &[Field], + bindings: &[Ident], + placeholder: &TemplatePlaceholderSpec, + env: Option<&mut FormatArgumentsEnv<'_>> +) -> Result { + if matches!( + &placeholder.identifier, + TemplateIdentifierSpec::Named(name) if name == "self" + ) { + return Ok(ResolvedPlaceholderExpr::with( + quote!(self), + needs_pointer_value(&placeholder.formatter) + )); + } + + if let Some(env) = env + && let Some(resolved) = env.resolve_placeholder(placeholder)? + { + return Ok(resolved); + } + + match &placeholder.identifier { + TemplateIdentifierSpec::Named(name) => { + if let Some(index) = fields + .iter() + .position(|field| field.ident.as_ref().is_some_and(|ident| ident == name)) + { + let binding = &bindings[index]; + Ok(ResolvedPlaceholderExpr::with( + quote!(#binding), + needs_pointer_value(&placeholder.formatter) + )) + } else { + Err(placeholder_error(placeholder.span, &placeholder.identifier)) + } + } + TemplateIdentifierSpec::Positional(index) => Err(placeholder_error( + placeholder.span, + &TemplateIdentifierSpec::Positional(*index) + )), + TemplateIdentifierSpec::Implicit(index) => Err(placeholder_error( + placeholder.span, + &TemplateIdentifierSpec::Implicit(*index) + )) + } +} + +fn format_placeholder( + resolved: ResolvedPlaceholderExpr, + formatter: TemplateFormatter +) -> TokenStream { + let ResolvedPlaceholderExpr { + expr, + pointer_value + } = resolved; + + match formatter { + TemplateFormatter::Display { + spec: Some(spec) + } => { + let format_literal = Literal::string(&format!("{{:{spec}}}")); + quote! { + ::core::write!(f, #format_literal, #expr)?; + } + } + TemplateFormatter::Display { + spec: None + } => { + format_with_formatter_kind(expr, pointer_value, TemplateFormatterKind::Display, false) + } + TemplateFormatter::Debug { + alternate + } => format_with_formatter_kind( + expr, + pointer_value, + TemplateFormatterKind::Debug, + alternate + ), + TemplateFormatter::LowerHex { + alternate + } => format_with_formatter_kind( + expr, + pointer_value, + TemplateFormatterKind::LowerHex, + alternate + ), + TemplateFormatter::UpperHex { + alternate + } => format_with_formatter_kind( + expr, + pointer_value, + TemplateFormatterKind::UpperHex, + alternate + ), + TemplateFormatter::Pointer { + alternate + } => format_with_formatter_kind( + expr, + pointer_value, + TemplateFormatterKind::Pointer, + alternate + ), + TemplateFormatter::Binary { + alternate + } => format_with_formatter_kind( + expr, + pointer_value, + TemplateFormatterKind::Binary, + alternate + ), + TemplateFormatter::Octal { + alternate + } => format_with_formatter_kind( + expr, + pointer_value, + TemplateFormatterKind::Octal, + alternate + ), + TemplateFormatter::LowerExp { + alternate + } => format_with_formatter_kind( + expr, + pointer_value, + TemplateFormatterKind::LowerExp, + alternate + ), + TemplateFormatter::UpperExp { + alternate + } => format_with_formatter_kind( + expr, + pointer_value, + TemplateFormatterKind::UpperExp, + alternate + ) + } +} + +fn format_with_formatter_kind( + expr: TokenStream, + pointer_value: bool, + kind: TemplateFormatterKind, + alternate: bool +) -> TokenStream { + let trait_name = formatter_trait_name(kind); + match kind { + TemplateFormatterKind::Display => format_with_trait(expr, trait_name), + TemplateFormatterKind::Pointer => { + format_pointer(expr, pointer_value, alternate, trait_name) + } + _ => { + if let Some(specifier) = formatter_specifier(kind) { + format_with_optional_alternate(expr, trait_name, specifier, alternate) + } else { + format_with_trait(expr, trait_name) + } + } + } +} + +fn formatter_trait_name(kind: TemplateFormatterKind) -> &'static str { + match kind { + TemplateFormatterKind::Display => "Display", + TemplateFormatterKind::Debug => "Debug", + TemplateFormatterKind::LowerHex => "LowerHex", + TemplateFormatterKind::UpperHex => "UpperHex", + TemplateFormatterKind::Pointer => "Pointer", + TemplateFormatterKind::Binary => "Binary", + TemplateFormatterKind::Octal => "Octal", + TemplateFormatterKind::LowerExp => "LowerExp", + TemplateFormatterKind::UpperExp => "UpperExp" + } +} + +fn formatter_specifier(kind: TemplateFormatterKind) -> Option { + match kind { + TemplateFormatterKind::Display | TemplateFormatterKind::Pointer => None, + TemplateFormatterKind::Debug => Some('?'), + TemplateFormatterKind::LowerHex => Some('x'), + TemplateFormatterKind::UpperHex => Some('X'), + TemplateFormatterKind::Binary => Some('b'), + TemplateFormatterKind::Octal => Some('o'), + TemplateFormatterKind::LowerExp => Some('e'), + TemplateFormatterKind::UpperExp => Some('E') + } +} + +fn format_with_trait(expr: TokenStream, trait_name: &str) -> TokenStream { + let trait_ident = format_ident!("{}", trait_name); + quote! { + ::core::fmt::#trait_ident::fmt(#expr, f)?; + } +} + +fn format_with_optional_alternate( + expr: TokenStream, + trait_name: &str, + specifier: char, + alternate: bool +) -> TokenStream { + if alternate { + format_with_alternate(expr, specifier) + } else { + format_with_trait(expr, trait_name) + } +} + +fn format_with_alternate(expr: TokenStream, specifier: char) -> TokenStream { + let format_string = format!("{{:#{}}}", specifier); + quote! { + ::core::write!(f, #format_string, #expr)?; + } +} + +fn format_pointer( + expr: TokenStream, + pointer_value: bool, + alternate: bool, + trait_name: &str +) -> TokenStream { + if alternate { + format_with_alternate(expr, 'p') + } else if pointer_value { + let trait_ident = format_ident!("{}", trait_name); + quote! {{ + let value = #expr; + ::core::fmt::#trait_ident::fmt(&value, f)?; + }} + } else { + format_with_trait(expr, trait_name) + } +} + +fn binding_ident(field: &Field) -> Ident { + field + .ident + .clone() + .unwrap_or_else(|| format_ident!("__field{}", field.index, span = field.span)) +} diff --git a/masterror-derive/src/error_trait.rs b/masterror-derive/src/error_trait.rs new file mode 100644 index 0000000..0b5f7ad --- /dev/null +++ b/masterror-derive/src/error_trait.rs @@ -0,0 +1,749 @@ +use proc_macro2::{Ident, TokenStream}; +use quote::{format_ident, quote}; +use syn::{Error, TypePath}; + +use crate::input::{ + BacktraceField, BacktraceFieldKind, DisplaySpec, ErrorData, ErrorInput, Field, Fields, + ProvideSpec, StructData, VariantData, is_backtrace_storage, is_option_type +}; + +pub fn expand(input: &ErrorInput) -> Result { + match &input.data { + ErrorData::Struct(data) => expand_struct(input, data), + ErrorData::Enum(variants) => expand_enum(input, variants) + } +} + +fn expand_struct(input: &ErrorInput, data: &StructData) -> Result { + let body = struct_source_body(&data.fields, &data.display); + let backtrace_method = struct_backtrace_method(&data.fields); + let provide_method = struct_provide_method(&data.fields); + let backtrace_method = backtrace_method.unwrap_or_default(); + let provide_method = provide_method.unwrap_or_default(); + + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + Ok(quote! { + impl #impl_generics std::error::Error for #ident #ty_generics #where_clause { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + #body + } + #backtrace_method + #provide_method + } + }) +} + +fn expand_enum(input: &ErrorInput, variants: &[VariantData]) -> Result { + let mut arms = Vec::new(); + for variant in variants { + arms.push(variant_source_arm(variant)); + } + + let backtrace_method = enum_backtrace_method(variants); + let provide_method = enum_provide_method(variants); + let backtrace_method = backtrace_method.unwrap_or_default(); + let provide_method = provide_method.unwrap_or_default(); + + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + Ok(quote! { + impl #impl_generics std::error::Error for #ident #ty_generics #where_clause { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + #(#arms),* + } + } + #backtrace_method + #provide_method + } + }) +} + +fn struct_source_body(fields: &Fields, display: &DisplaySpec) -> TokenStream { + match display { + DisplaySpec::Transparent { + .. + } => { + if let Some(field) = fields.iter().next() { + let member = &field.member; + quote! { std::error::Error::source(&self.#member) } + } else { + quote! { None } + } + } + DisplaySpec::Template(_) + | DisplaySpec::TemplateWithArgs { + .. + } + | DisplaySpec::FormatterPath { + .. + } => { + if let Some(field) = fields.iter().find(|field| field.attrs.has_source()) { + let member = &field.member; + field_source_expr(quote!(self.#member), quote!(&self.#member), &field.ty) + } else { + quote! { None } + } + } + } +} + +fn variant_source_arm(variant: &VariantData) -> TokenStream { + match &variant.display { + DisplaySpec::Transparent { + .. + } => variant_transparent_source(variant), + DisplaySpec::Template(_) + | DisplaySpec::TemplateWithArgs { + .. + } + | DisplaySpec::FormatterPath { + .. + } => variant_template_source(variant) + } +} + +fn variant_transparent_source(variant: &VariantData) -> TokenStream { + let variant_ident = &variant.ident; + match &variant.fields { + Fields::Unit => quote! { Self::#variant_ident => None }, + Fields::Named(fields) => { + let binding = fields[0].ident.clone().expect("named field"); + let pattern = if fields.len() == 1 { + quote!(Self::#variant_ident { #binding }) + } else { + quote!(Self::#variant_ident { #binding, .. }) + }; + quote! { + #pattern => std::error::Error::source(#binding) + } + } + Fields::Unnamed(fields) => { + let binding = binding_ident(&fields[0]); + let mut patterns = Vec::new(); + for (index, _) in fields.iter().enumerate() { + if index == 0 { + patterns.push(quote!(#binding)); + } else { + patterns.push(quote!(_)); + } + } + quote! { + Self::#variant_ident(#(#patterns),*) => std::error::Error::source(#binding) + } + } + } +} + +fn variant_template_source(variant: &VariantData) -> TokenStream { + let variant_ident = &variant.ident; + let source_field = variant.fields.iter().find(|field| field.attrs.has_source()); + + match (&variant.fields, source_field) { + (Fields::Unit, _) => quote! { Self::#variant_ident => None }, + (_, None) => match &variant.fields { + Fields::Named(_) => quote! { Self::#variant_ident { .. } => None }, + Fields::Unnamed(fields) if fields.is_empty() => { + quote! { Self::#variant_ident() => None } + } + Fields::Unnamed(fields) => { + let placeholders = vec![quote!(_); fields.len()]; + quote! { Self::#variant_ident(#(#placeholders),*) => None } + } + Fields::Unit => quote! { Self::#variant_ident => None } + }, + (Fields::Named(fields), Some(field)) => { + let field_ident = field.ident.clone().expect("named field"); + let binding = binding_ident(field); + let pattern = if fields.len() == 1 { + quote!(Self::#variant_ident { #field_ident: #binding }) + } else { + quote!(Self::#variant_ident { #field_ident: #binding, .. }) + }; + let body = field_source_expr(quote!(#binding), quote!(#binding), &field.ty); + quote! { + #pattern => { #body } + } + } + (Fields::Unnamed(fields), Some(field)) => { + let index = field.index; + let binding = binding_ident(field); + let pattern_elements: Vec<_> = fields + .iter() + .enumerate() + .map(|(idx, _)| { + if idx == index { + quote!(#binding) + } else { + quote!(_) + } + }) + .collect(); + let body = field_source_expr(quote!(#binding), quote!(#binding), &field.ty); + quote! { + Self::#variant_ident(#(#pattern_elements),*) => { #body } + } + } + } +} + +fn field_source_expr( + owned_expr: TokenStream, + referenced_expr: TokenStream, + ty: &syn::Type +) -> TokenStream { + if is_option_type(ty) { + quote! { #owned_expr.as_ref().map(|source| source as &(dyn std::error::Error + 'static)) } + } else { + quote! { Some(#referenced_expr as &(dyn std::error::Error + 'static)) } + } +} + +fn struct_backtrace_method(fields: &Fields) -> Option { + let backtrace = fields.backtrace_field()?; + let field = backtrace.field(); + let member = &field.member; + let body = field_backtrace_expr(quote!(self.#member), quote!(&self.#member), field); + Some(quote! { + #[cfg(error_generic_member_access)] + fn backtrace(&self) -> Option<&std::backtrace::Backtrace> { + #body + } + }) +} + +fn enum_backtrace_method(variants: &[VariantData]) -> Option { + let mut has_backtrace = false; + let mut arms = Vec::new(); + for variant in variants { + if variant.fields.backtrace_field().is_some() { + has_backtrace = true; + } + arms.push(variant_backtrace_arm(variant)); + } + + if has_backtrace { + Some(quote! { + #[cfg(error_generic_member_access)] + fn backtrace(&self) -> Option<&std::backtrace::Backtrace> { + match self { + #(#arms),* + } + } + }) + } else { + None + } +} + +fn variant_backtrace_arm(variant: &VariantData) -> TokenStream { + let variant_ident = &variant.ident; + let backtrace_field = variant.fields.backtrace_field(); + + match (&variant.fields, backtrace_field) { + (Fields::Unit, _) => quote! { Self::#variant_ident => None }, + (Fields::Named(fields), Some(backtrace)) => { + let field = backtrace.field(); + let field_ident = field.ident.clone().expect("named field"); + let binding = binding_ident(field); + let pattern = if fields.len() == 1 { + quote!(Self::#variant_ident { #field_ident: #binding }) + } else { + quote!(Self::#variant_ident { #field_ident: #binding, .. }) + }; + let body = field_backtrace_expr(quote!(#binding), quote!(#binding), field); + quote! { + #pattern => { #body } + } + } + (Fields::Unnamed(fields), Some(backtrace)) => { + let field = backtrace.field(); + let index = field.index; + let binding = binding_ident(field); + let pattern_elements: Vec<_> = fields + .iter() + .enumerate() + .map(|(idx, _)| { + if idx == index { + quote!(#binding) + } else { + quote!(_) + } + }) + .collect(); + let body = field_backtrace_expr(quote!(#binding), quote!(#binding), field); + quote! { + Self::#variant_ident(#(#pattern_elements),*) => { #body } + } + } + (Fields::Named(_), None) => quote! { Self::#variant_ident { .. } => None }, + (Fields::Unnamed(fields), None) => { + if fields.is_empty() { + quote! { Self::#variant_ident() => None } + } else { + let placeholders = vec![quote!(_); fields.len()]; + quote! { Self::#variant_ident(#(#placeholders),*) => None } + } + } + } +} + +fn field_backtrace_expr( + owned_expr: TokenStream, + referenced_expr: TokenStream, + field: &Field +) -> TokenStream { + let ty = &field.ty; + if is_backtrace_storage(ty) { + if is_option_type(ty) { + quote! { #owned_expr.as_ref() } + } else { + quote! { Some(#referenced_expr) } + } + } else if field.attrs.has_source() { + if is_option_type(ty) { + quote! { #owned_expr.as_ref().and_then(std::error::Error::backtrace) } + } else { + quote! { std::error::Error::backtrace(#referenced_expr) } + } + } else { + quote! { None } + } +} + +fn struct_provide_method(fields: &Fields) -> Option { + let backtrace = fields.backtrace_field(); + let source_field = fields.iter().find(|candidate| candidate.attrs.has_source()); + let request = quote!(request); + let delegates_to_source = backtrace.is_some_and(|backtrace| { + matches!(backtrace.kind(), BacktraceFieldKind::Explicit) && !backtrace.stores_backtrace() + }); + let mut statements = Vec::new(); + let mut needs_trait_import = false; + + if let Some(source_field) = source_field { + needs_trait_import = true; + let member = &source_field.member; + statements.push(provide_source_tokens( + quote!(self.#member), + source_field, + &request + )); + } + + if let Some(backtrace) = backtrace + && backtrace.stores_backtrace() + && source_field.is_none_or(|source| source.index != backtrace.index()) + && !delegates_to_source + { + let member = &backtrace.field().member; + statements.push(provide_backtrace_tokens( + quote!(self.#member), + backtrace.field(), + &request + )); + } + + for field in fields.iter() { + if field.attrs.provides.is_empty() { + continue; + } + let member = &field.member; + let expr = quote!(self.#member); + for spec in &field.attrs.provides { + statements.extend(provide_custom_tokens(expr.clone(), field, spec, &request)); + } + } + + if statements.is_empty() { + return None; + } + + let trait_import = if needs_trait_import { + quote! { use masterror::provide::ThiserrorProvide as _; } + } else { + TokenStream::new() + }; + + Some(quote! { + #[cfg(error_generic_member_access)] + fn provide<'a>(&'a self, #request: &mut core::error::Request<'a>) { + #trait_import + #(#statements)* + } + }) +} + +fn enum_provide_method(variants: &[VariantData]) -> Option { + let mut has_backtrace = false; + let mut has_custom_provides = false; + let mut needs_trait_import = false; + let mut arms = Vec::new(); + let request = quote!(request); + + for variant in variants { + if variant.fields.backtrace_field().is_some() { + has_backtrace = true; + } + if variant + .fields + .iter() + .any(|field| !field.attrs.provides.is_empty()) + { + has_custom_provides = true; + } + arms.push(variant_provide_arm_tokens( + variant, + &request, + &mut needs_trait_import + )); + } + + if !has_backtrace && !has_custom_provides { + return None; + } + + let trait_import = if needs_trait_import { + quote! { use masterror::provide::ThiserrorProvide as _; } + } else { + TokenStream::new() + }; + + Some(quote! { + #[cfg(error_generic_member_access)] + fn provide<'a>(&'a self, #request: &mut core::error::Request<'a>) { + #trait_import + #[allow(deprecated)] + match self { + #(#arms),* + } + } + }) +} + +fn variant_provide_arm_tokens( + variant: &VariantData, + request: &TokenStream, + needs_trait_import: &mut bool +) -> TokenStream { + let variant_ident = &variant.ident; + let backtrace = variant.fields.backtrace_field(); + let source_field = variant.fields.iter().find(|field| field.attrs.has_source()); + + match &variant.fields { + Fields::Unit => quote! { Self::#variant_ident => {} }, + Fields::Named(fields) => variant_provide_named_arm( + variant_ident, + fields, + backtrace, + source_field, + request, + needs_trait_import + ), + Fields::Unnamed(fields) => variant_provide_unnamed_arm( + variant_ident, + fields, + backtrace, + source_field, + request, + needs_trait_import + ) + } +} + +fn variant_provide_named_arm( + variant_ident: &Ident, + fields: &[Field], + backtrace: Option>, + source: Option<&Field>, + request: &TokenStream, + needs_trait_import: &mut bool +) -> TokenStream { + let same_as_source = if let (Some(backtrace_field), Some(source_field)) = (backtrace, source) { + source_field.index == backtrace_field.index() + } else { + false + }; + let delegates_to_source = backtrace.is_some_and(|field| { + matches!(field.kind(), BacktraceFieldKind::Explicit) && !field.stores_backtrace() + }); + let mut entries = Vec::new(); + let mut backtrace_binding = None; + let mut source_binding = None; + let mut provide_bindings: Vec<(Ident, &Field)> = Vec::new(); + + for field in fields { + let ident = field.ident.clone().expect("named field"); + let needs_binding = backtrace.is_some_and(|candidate| candidate.index() == field.index) + || source.is_some_and(|candidate| candidate.index == field.index) + || !field.attrs.provides.is_empty(); + + if needs_binding { + let binding = binding_ident(field); + let pattern_binding = binding.clone(); + entries.push(quote!(#ident: #pattern_binding)); + + if backtrace.is_some_and(|candidate| candidate.index() == field.index) { + backtrace_binding = Some(binding.clone()); + } + + if source.is_some_and(|candidate| candidate.index == field.index) { + source_binding = Some(binding.clone()); + } + + if !field.attrs.provides.is_empty() { + provide_bindings.push((binding, field)); + } + } else { + entries.push(quote!(#ident: _)); + } + } + + let mut statements = Vec::new(); + + if let Some(source_field) = source { + *needs_trait_import = true; + let binding = source_binding.expect("source binding"); + statements.push(provide_source_tokens( + quote!(#binding), + source_field, + request + )); + } + + if let Some(backtrace_field) = backtrace + && backtrace_field.stores_backtrace() + && !same_as_source + && !delegates_to_source + { + let binding = backtrace_binding.expect("backtrace binding"); + statements.push(provide_backtrace_tokens( + quote!(#binding), + backtrace_field.field(), + request + )); + } + + for (binding, field) in provide_bindings { + let binding_expr = quote!(#binding); + for spec in &field.attrs.provides { + statements.extend(provide_custom_tokens( + binding_expr.clone(), + field, + spec, + request + )); + } + } + + let pattern = quote!(Self::#variant_ident { #(#entries),* }); + + if statements.is_empty() { + quote! { #pattern => {} } + } else { + quote! { #pattern => { #(#statements)* } } + } +} + +fn variant_provide_unnamed_arm( + variant_ident: &Ident, + fields: &[Field], + backtrace: Option>, + source: Option<&Field>, + request: &TokenStream, + needs_trait_import: &mut bool +) -> TokenStream { + let same_as_source = if let (Some(backtrace_field), Some(source_field)) = (backtrace, source) { + source_field.index == backtrace_field.index() + } else { + false + }; + let delegates_to_source = backtrace.is_some_and(|field| { + matches!(field.kind(), BacktraceFieldKind::Explicit) && !field.stores_backtrace() + }); + let mut elements = Vec::new(); + let mut backtrace_binding = None; + let mut source_binding = None; + let mut provide_bindings: Vec<(Ident, &Field)> = Vec::new(); + + for (index, field) in fields.iter().enumerate() { + let needs_binding = backtrace.is_some_and(|candidate| candidate.index() == index) + || source.is_some_and(|candidate| candidate.index == index) + || !field.attrs.provides.is_empty(); + + if needs_binding { + let binding = binding_ident(field); + let pattern_binding = binding.clone(); + elements.push(quote!(#pattern_binding)); + + if backtrace.is_some_and(|candidate| candidate.index() == index) { + backtrace_binding = Some(binding.clone()); + } + + if source.is_some_and(|candidate| candidate.index == index) { + source_binding = Some(binding.clone()); + } + + if !field.attrs.provides.is_empty() { + provide_bindings.push((binding, field)); + } + } else { + elements.push(quote!(_)); + } + } + + let mut statements = Vec::new(); + + if let Some(source_field) = source { + *needs_trait_import = true; + let binding = source_binding.expect("source binding"); + statements.push(provide_source_tokens( + quote!(#binding), + source_field, + request + )); + } + + if let Some(backtrace_field) = backtrace + && backtrace_field.stores_backtrace() + && !same_as_source + && !delegates_to_source + { + let binding = backtrace_binding.expect("backtrace binding"); + statements.push(provide_backtrace_tokens( + quote!(#binding), + backtrace_field.field(), + request + )); + } + + for (binding, field) in provide_bindings { + let binding_expr = quote!(#binding); + for spec in &field.attrs.provides { + statements.extend(provide_custom_tokens( + binding_expr.clone(), + field, + spec, + request + )); + } + } + + let pattern = if elements.is_empty() { + quote!(Self::#variant_ident()) + } else { + quote!(Self::#variant_ident(#(#elements),*)) + }; + + if statements.is_empty() { + quote! { #pattern => {} } + } else { + quote! { #pattern => { #(#statements)* } } + } +} + +fn provide_custom_tokens( + expr: TokenStream, + field: &Field, + spec: &ProvideSpec, + request: &TokenStream +) -> Vec { + let mut tokens = Vec::new(); + if let Some(reference) = &spec.reference { + tokens.push(provide_custom_ref_tokens( + expr.clone(), + field, + reference, + request + )); + } + if let Some(value) = &spec.value { + tokens.push(provide_custom_value_tokens( + expr.clone(), + field, + value, + request + )); + } + tokens +} + +fn provide_custom_ref_tokens( + expr: TokenStream, + field: &Field, + ty: &TypePath, + request: &TokenStream +) -> TokenStream { + if is_option_type(&field.ty) { + quote! { + if let Some(value) = #expr.as_ref() { + #request.provide_ref::<#ty>(value); + } + } + } else { + quote! { + #request.provide_ref::<#ty>(#expr); + } + } +} + +fn provide_custom_value_tokens( + expr: TokenStream, + field: &Field, + ty: &TypePath, + request: &TokenStream +) -> TokenStream { + if is_option_type(&field.ty) { + quote! { + if let Some(value) = #expr.clone() { + #request.provide_value::<#ty>(value); + } + } + } else { + quote! { + #request.provide_value::<#ty>(#expr.clone()); + } + } +} + +fn provide_backtrace_tokens( + expr: TokenStream, + field: &Field, + request: &TokenStream +) -> TokenStream { + if is_option_type(&field.ty) { + quote! { + if let Some(backtrace) = #expr.as_ref() { + #request.provide_ref::(backtrace); + } + } + } else { + quote! { + #request.provide_ref::(#expr); + } + } +} + +fn provide_source_tokens(expr: TokenStream, field: &Field, request: &TokenStream) -> TokenStream { + if is_option_type(&field.ty) { + quote! { + if let Some(source) = #expr.as_ref() { + source.thiserror_provide(#request); + } + } + } else { + quote! { + #expr.thiserror_provide(#request); + } + } +} + +fn binding_ident(field: &Field) -> Ident { + field + .ident + .clone() + .unwrap_or_else(|| format_ident!("__field{}", field.index, span = field.span)) +} diff --git a/masterror-derive/src/from_impl.rs b/masterror-derive/src/from_impl.rs new file mode 100644 index 0000000..7518d01 --- /dev/null +++ b/masterror-derive/src/from_impl.rs @@ -0,0 +1,166 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::Error; + +use crate::input::{ + ErrorData, ErrorInput, Field, Fields, StructData, VariantData, is_option_type +}; + +pub fn expand(input: &ErrorInput) -> Result, Error> { + let mut impls = Vec::new(); + + match &input.data { + ErrorData::Struct(data) => { + if let Some(field) = data.fields.first_from_field() { + impls.push(struct_from_impl(input, data, field)?); + } + } + ErrorData::Enum(variants) => { + for variant in variants { + if let Some(field) = variant.fields.first_from_field() { + impls.push(enum_from_impl(input, variant, field)?); + } + } + } + } + + Ok(impls) +} + +fn struct_from_impl( + input: &ErrorInput, + data: &StructData, + field: &Field +) -> Result { + let ident = &input.ident; + let ty = &field.ty; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let constructor = struct_constructor(&data.fields, field)?; + + Ok(quote! { + impl #impl_generics core::convert::From<#ty> for #ident #ty_generics #where_clause { + fn from(value: #ty) -> Self { + #constructor + } + } + }) +} + +fn enum_from_impl( + input: &ErrorInput, + variant: &VariantData, + field: &Field +) -> Result { + let ident = &input.ident; + let ty = &field.ty; + let variant_ident = &variant.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let constructor = variant_constructor(variant_ident, &variant.fields, field)?; + + Ok(quote! { + impl #impl_generics core::convert::From<#ty> for #ident #ty_generics #where_clause { + fn from(value: #ty) -> Self { + #constructor + } + } + }) +} + +fn struct_constructor(fields: &Fields, from_field: &Field) -> Result { + match fields { + Fields::Named(named) => { + let mut initializers = Vec::new(); + for field in named { + let field_ident = field.ident.clone().expect("named field"); + let value = field_value_expr(field, from_field)?; + initializers.push(quote! { #field_ident: #value }); + } + Ok(quote! { Self { #(#initializers),* } }) + } + Fields::Unnamed(unnamed) => { + let mut values = Vec::new(); + for field in unnamed { + values.push(field_value_expr(field, from_field)?); + } + Ok(quote! { Self(#(#values),*) }) + } + Fields::Unit => Err(Error::new( + from_field.span, + "#[from] is not supported on unit structs" + )) + } +} + +fn variant_constructor( + variant_ident: &syn::Ident, + fields: &Fields, + from_field: &Field +) -> Result { + match fields { + Fields::Named(named) => { + let mut initializers = Vec::new(); + for field in named { + let field_ident = field.ident.clone().expect("named field"); + let value = field_value_expr(field, from_field)?; + initializers.push(quote! { #field_ident: #value }); + } + Ok(quote! { Self::#variant_ident { #(#initializers),* } }) + } + Fields::Unnamed(unnamed) => { + let mut values = Vec::new(); + for field in unnamed { + values.push(field_value_expr(field, from_field)?); + } + Ok(quote! { Self::#variant_ident(#(#values),*) }) + } + Fields::Unit => Err(Error::new( + from_field.span, + "#[from] is not supported on unit variants" + )) + } +} + +fn field_value_expr(field: &Field, from_field: &Field) -> Result { + if field.index == from_field.index { + return Ok(quote! { value }); + } + + if field.attrs.has_backtrace() { + return Ok(backtrace_initializer(field)); + } + + if field.attrs.has_source() && field.attrs.from.is_none() { + return source_initializer(field); + } + + Err(Error::new( + field.span, + "deriving From requires no fields other than source and backtrace" + )) +} + +fn source_initializer(field: &Field) -> Result { + if is_option_type(&field.ty) { + Ok(quote! { ::core::option::Option::None }) + } else { + Err(Error::new( + field.span, + "additional #[source] fields used with #[from] must be Option<_>" + )) + } +} + +fn backtrace_initializer(field: &Field) -> TokenStream { + let capture = quote! { ::std::backtrace::Backtrace::capture() }; + if is_option_type(&field.ty) { + quote! { + ::core::option::Option::Some(::core::convert::From::from(#capture)) + } + } else { + quote! { + ::core::convert::From::from(#capture) + } + } +} diff --git a/masterror-derive/src/input.rs b/masterror-derive/src/input.rs new file mode 100644 index 0000000..73408b8 --- /dev/null +++ b/masterror-derive/src/input.rs @@ -0,0 +1,1276 @@ +use std::collections::HashSet; + +use proc_macro2::Span; +use syn::{ + AngleBracketedGenericArguments, Attribute, Data, DataEnum, DataStruct, DeriveInput, Error, + Expr, ExprPath, Field as SynField, Fields as SynFields, GenericArgument, Ident, LitBool, + LitInt, LitStr, Token, TypePath, + ext::IdentExt, + parse::{Parse, ParseStream}, + punctuated::Punctuated, + spanned::Spanned, + token::Paren +}; + +use crate::template_support::{DisplayTemplate, TemplateIdentifierSpec, parse_display_template}; + +#[derive(Debug)] +pub struct ErrorInput { + pub ident: Ident, + pub generics: syn::Generics, + pub data: ErrorData +} + +#[derive(Debug)] +pub enum ErrorData { + Struct(Box), + Enum(Vec) +} + +#[derive(Debug)] +pub struct StructData { + pub fields: Fields, + pub display: DisplaySpec, + #[allow(dead_code)] + pub format_args: FormatArgsSpec, + pub app_error: Option +} + +#[derive(Debug)] +pub struct VariantData { + pub ident: Ident, + pub fields: Fields, + pub display: DisplaySpec, + #[allow(dead_code)] + pub format_args: FormatArgsSpec, + pub app_error: Option, + pub span: Span +} + +#[derive(Clone, Debug)] +pub struct AppErrorSpec { + pub kind: ExprPath, + pub code: Option, + pub expose_message: bool, + pub attribute_span: Span +} + +#[derive(Debug)] +pub enum Fields { + Unit, + Named(Vec), + Unnamed(Vec) +} + +impl Fields { + pub fn len(&self) -> usize { + match self { + Self::Unit => 0, + Self::Named(fields) | Self::Unnamed(fields) => fields.len() + } + } + + pub fn iter(&self) -> FieldIter<'_> { + match self { + Self::Unit => FieldIter::Empty, + Self::Named(fields) | Self::Unnamed(fields) => FieldIter::Slice(fields.iter()) + } + } + + pub fn get_named(&self, name: &str) -> Option<&Field> { + match self { + Self::Named(fields) => fields + .iter() + .find(|field| field.ident.as_ref().is_some_and(|ident| ident == name)), + _ => None + } + } + + pub fn get_positional(&self, index: usize) -> Option<&Field> { + match self { + Self::Unnamed(fields) => fields.get(index), + _ => None + } + } + + pub fn from_syn(fields: &SynFields, errors: &mut Vec) -> Self { + match fields { + SynFields::Unit => Self::Unit, + SynFields::Named(named) => { + let mut items = Vec::new(); + for (index, field) in named.named.iter().enumerate() { + items.push(Field::from_syn(field, index, errors)); + } + Self::Named(items) + } + SynFields::Unnamed(unnamed) => { + let mut items = Vec::new(); + for (index, field) in unnamed.unnamed.iter().enumerate() { + items.push(Field::from_syn(field, index, errors)); + } + Self::Unnamed(items) + } + } + } + + pub fn first_from_field(&self) -> Option<&Field> { + self.iter().find(|field| field.attrs.from.is_some()) + } + + pub fn backtrace_field(&self) -> Option> { + self.iter().find_map(|field| { + field + .attrs + .backtrace_kind() + .map(|kind| BacktraceField::new(field, kind)) + }) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BacktraceFieldKind { + Explicit, + Inferred +} + +#[derive(Clone, Copy, Debug)] +pub struct BacktraceField<'a> { + field: &'a Field, + kind: BacktraceFieldKind +} + +impl<'a> BacktraceField<'a> { + pub fn new(field: &'a Field, kind: BacktraceFieldKind) -> Self { + Self { + field, + kind + } + } + + pub fn field(&self) -> &'a Field { + self.field + } + + pub fn kind(&self) -> BacktraceFieldKind { + self.kind + } + + pub fn stores_backtrace(&self) -> bool { + is_backtrace_storage(&self.field.ty) + } + + pub fn index(&self) -> usize { + self.field.index + } +} + +pub enum FieldIter<'a> { + Empty, + Slice(std::slice::Iter<'a, Field>) +} + +impl<'a> Iterator for FieldIter<'a> { + type Item = &'a Field; + + fn next(&mut self) -> Option { + match self { + FieldIter::Empty => None, + FieldIter::Slice(iter) => iter.next() + } + } +} + +#[derive(Debug)] +pub struct Field { + pub ident: Option, + pub member: syn::Member, + pub ty: syn::Type, + pub attrs: FieldAttrs, + pub span: Span, + pub index: usize +} + +impl Field { + fn from_syn(field: &SynField, index: usize, errors: &mut Vec) -> Self { + let ident = field.ident.clone(); + let member = match &ident { + Some(name) => syn::Member::Named(name.clone()), + None => syn::Member::Unnamed(syn::Index::from(index)) + }; + + let attrs = FieldAttrs::from_attrs(&field.attrs, ident.as_ref(), &field.ty, errors); + + Self { + ident, + member, + ty: field.ty.clone(), + attrs, + span: field.span(), + index + } + } +} + +#[derive(Debug, Default)] +pub struct FieldAttrs { + pub from: Option, + pub source: Option, + pub backtrace: Option, + pub provides: Vec, + inferred_source: bool, + inferred_backtrace: bool +} + +#[derive(Debug)] +pub struct ProvideSpec { + pub reference: Option, + pub value: Option +} + +impl FieldAttrs { + fn from_attrs( + attrs: &[Attribute], + ident: Option<&Ident>, + ty: &syn::Type, + errors: &mut Vec + ) -> Self { + let mut result = FieldAttrs::default(); + + for attr in attrs { + if path_is(attr, "from") { + if let Err(err) = attr.meta.require_path_only() { + errors.push(err); + continue; + } + if result.from.is_some() { + errors.push(Error::new_spanned(attr, "duplicate #[from] attribute")); + continue; + } + result.from = Some(attr.clone()); + } else if path_is(attr, "source") { + if let Err(err) = attr.meta.require_path_only() { + errors.push(err); + continue; + } + if result.source.is_some() { + errors.push(Error::new_spanned(attr, "duplicate #[source] attribute")); + continue; + } + result.source = Some(attr.clone()); + } else if path_is(attr, "backtrace") { + if let Err(err) = attr.meta.require_path_only() { + errors.push(err); + continue; + } + if result.backtrace.is_some() { + errors.push(Error::new_spanned(attr, "duplicate #[backtrace] attribute")); + continue; + } + result.backtrace = Some(attr.clone()); + } else if path_is(attr, "provide") { + match parse_provide_attribute(attr) { + Ok(spec) => result.provides.push(spec), + Err(err) => errors.push(err) + } + } + } + + if result.source.is_none() + && let Some(attr) = &result.from + { + result.source = Some(attr.clone()); + } + + if result.source.is_none() && ident.is_some_and(|ident| ident == "source") { + result.inferred_source = true; + } + + if result.backtrace.is_none() { + if is_option_type(ty) { + if option_inner_type(ty).is_some_and(is_backtrace_type) { + result.inferred_backtrace = true; + } + } else if is_backtrace_type(ty) { + result.inferred_backtrace = true; + } + } + + result + } + + pub fn has_source(&self) -> bool { + self.source.is_some() || self.inferred_source + } + + pub fn has_backtrace(&self) -> bool { + self.backtrace.is_some() || self.inferred_backtrace + } + + pub fn backtrace_kind(&self) -> Option { + if self.backtrace.is_some() { + Some(BacktraceFieldKind::Explicit) + } else if self.inferred_backtrace { + Some(BacktraceFieldKind::Inferred) + } else { + None + } + } + + pub fn source_attribute(&self) -> Option<&Attribute> { + self.source.as_ref() + } + + pub fn backtrace_attribute(&self) -> Option<&Attribute> { + self.backtrace.as_ref() + } +} + +fn parse_provide_attribute(attr: &Attribute) -> Result { + attr.parse_args_with(|input: ParseStream| { + let mut reference = None; + let mut value = None; + + while !input.is_empty() { + let ident: Ident = input.call(Ident::parse_any)?; + let name = ident.to_string(); + match name.as_str() { + "ref" => { + if reference.is_some() { + return Err(Error::new(ident.span(), "duplicate `ref` specification")); + } + input.parse::()?; + let ty: TypePath = input.parse()?; + reference = Some(ty); + } + "value" => { + if value.is_some() { + return Err(Error::new(ident.span(), "duplicate `value` specification")); + } + input.parse::()?; + let ty: TypePath = input.parse()?; + value = Some(ty); + } + other => { + return Err(Error::new( + ident.span(), + format!("unknown #[provide] option `{}`", other) + )); + } + } + + if input.peek(Token![,]) { + input.parse::()?; + } else if !input.is_empty() { + return Err(Error::new( + input.span(), + "expected `,` or end of input in #[provide(...)]" + )); + } + } + + if reference.is_none() && value.is_none() { + return Err(Error::new( + attr.span(), + "`#[provide]` requires at least one of `ref = ...` or `value = ...`" + )); + } + + Ok(ProvideSpec { + reference, + value + }) + }) +} + +#[derive(Debug)] +pub enum DisplaySpec { + Transparent { + attribute: Box + }, + Template(DisplayTemplate), + #[allow(dead_code)] + TemplateWithArgs { + template: DisplayTemplate, + args: FormatArgsSpec + }, + #[allow(dead_code)] + FormatterPath { + path: ExprPath, + args: FormatArgsSpec + } +} + +#[allow(dead_code)] +#[derive(Debug, Default)] +pub struct FormatArgsSpec { + pub args: Vec +} + +#[allow(dead_code)] +#[derive(Debug)] +pub struct FormatArg { + pub value: FormatArgValue, + pub kind: FormatBindingKind, + pub span: Span +} + +#[allow(dead_code)] +#[derive(Debug)] +pub enum FormatArgValue { + Expr(Expr), + Shorthand(FormatArgShorthand) +} + +#[allow(dead_code)] +#[derive(Debug)] +pub enum FormatArgShorthand { + Projection(FormatArgProjection) +} + +#[derive(Debug)] +pub struct FormatArgProjection { + pub segments: Vec, + pub span: Span +} + +#[derive(Debug)] +pub enum FormatArgProjectionSegment { + Field(Ident), + Index { index: usize, span: Span }, + MethodCall(FormatArgProjectionMethodCall) +} + +impl FormatArgProjectionSegment { + fn span(&self) -> Span { + match self { + Self::Field(ident) => ident.span(), + Self::Index { + span, .. + } => *span, + Self::MethodCall(call) => call.span + } + } +} + +#[derive(Debug)] +pub struct FormatArgProjectionMethodCall { + pub method: Ident, + pub turbofish: Option, + pub args: Punctuated, + pub span: Span +} + +#[derive(Debug)] +pub struct FormatArgMethodTurbofish { + pub colon2_token: Token![::], + pub generics: AngleBracketedGenericArguments +} + +type MethodCallSuffix = Option<( + Option, + Paren, + Punctuated +)>; + +#[allow(dead_code)] +#[derive(Debug)] +pub enum FormatBindingKind { + Named(Ident), + Positional(usize), + Implicit(usize) +} + +pub fn parse_input(input: DeriveInput) -> Result { + let mut errors = Vec::new(); + + let ident = input.ident; + let generics = input.generics; + + let data = match input.data { + Data::Struct(data) => parse_struct(&ident, &input.attrs, data, &mut errors), + Data::Enum(data) => parse_enum(&input.attrs, data, &mut errors), + Data::Union(union) => { + errors.push(Error::new( + union.union_token.span(), + "Error cannot be derived for unions" + )); + Err(()) + } + }; + + let data = match data { + Ok(value) => value, + Err(()) => { + return Err(collect_errors(errors)); + } + }; + + if errors.is_empty() { + Ok(ErrorInput { + ident, + generics, + data + }) + } else { + Err(collect_errors(errors)) + } +} + +fn parse_struct( + ident: &Ident, + attrs: &[Attribute], + data: DataStruct, + errors: &mut Vec +) -> Result { + let display = extract_display_spec(attrs, ident.span(), errors)?; + let app_error = extract_app_error_spec(attrs, errors)?; + let fields = Fields::from_syn(&data.fields, errors); + + validate_from_usage(&fields, &display, errors); + validate_backtrace_usage(&fields, errors); + validate_transparent(&fields, &display, errors, None); + + Ok(ErrorData::Struct(Box::new(StructData { + fields, + display, + format_args: FormatArgsSpec::default(), + app_error + }))) +} + +fn parse_enum( + attrs: &[Attribute], + data: DataEnum, + errors: &mut Vec +) -> Result { + for attr in attrs { + if path_is(attr, "error") { + errors.push(Error::new_spanned( + attr, + "type-level #[error] attributes are not supported" + )); + } + } + + let mut variants = Vec::new(); + for variant in data.variants { + variants.push(parse_variant(variant, errors)?); + } + + Ok(ErrorData::Enum(variants)) +} + +fn parse_variant(variant: syn::Variant, errors: &mut Vec) -> Result { + let span = variant.span(); + for attr in &variant.attrs { + if path_is(attr, "from") { + errors.push(Error::new_spanned( + attr, + "not expected here; the #[from] attribute belongs on a specific field" + )); + } + } + + let display = extract_display_spec(&variant.attrs, span, errors)?; + let app_error = extract_app_error_spec(&variant.attrs, errors)?; + let fields = Fields::from_syn(&variant.fields, errors); + + validate_from_usage(&fields, &display, errors); + validate_backtrace_usage(&fields, errors); + validate_transparent(&fields, &display, errors, Some(&variant)); + + Ok(VariantData { + ident: variant.ident, + fields, + display, + format_args: FormatArgsSpec::default(), + app_error, + span + }) +} + +fn extract_app_error_spec( + attrs: &[Attribute], + errors: &mut Vec +) -> Result, ()> { + let mut spec = None; + let mut had_error = false; + + for attr in attrs { + if !path_is(attr, "app_error") { + continue; + } + + if spec.is_some() { + errors.push(Error::new_spanned( + attr, + "duplicate #[app_error(...)] attribute" + )); + had_error = true; + continue; + } + + match parse_app_error_attribute(attr) { + Ok(parsed) => spec = Some(parsed), + Err(err) => { + errors.push(err); + had_error = true; + } + } + } + + if had_error { Err(()) } else { Ok(spec) } +} + +fn extract_display_spec( + attrs: &[Attribute], + missing_span: Span, + errors: &mut Vec +) -> Result { + let mut display = None; + let mut saw_error_attribute = false; + + for attr in attrs { + if !path_is(attr, "error") { + continue; + } + + saw_error_attribute = true; + + if display.is_some() { + errors.push(Error::new_spanned(attr, "duplicate #[error] attribute")); + continue; + } + + match parse_error_attribute(attr) { + Ok(spec) => display = Some(spec), + Err(err) => errors.push(err) + } + } + + match display { + Some(spec) => Ok(spec), + None => { + if !saw_error_attribute { + errors.push(Error::new(missing_span, "missing #[error(...)] attribute")); + } + Err(()) + } + } +} + +fn parse_app_error_attribute(attr: &Attribute) -> Result { + attr.parse_args_with(|input: ParseStream| { + let mut kind = None; + let mut code = None; + let mut expose_message = false; + + while !input.is_empty() { + let ident: Ident = input.parse()?; + let name = ident.to_string(); + match name.as_str() { + "kind" => { + if kind.is_some() { + return Err(Error::new(ident.span(), "duplicate kind specification")); + } + input.parse::()?; + let value: ExprPath = input.parse()?; + kind = Some(value); + } + "code" => { + if code.is_some() { + return Err(Error::new(ident.span(), "duplicate code specification")); + } + input.parse::()?; + let value: ExprPath = input.parse()?; + code = Some(value); + } + "message" => { + if expose_message { + return Err(Error::new(ident.span(), "duplicate message flag")); + } + if input.peek(Token![=]) { + input.parse::()?; + let value: LitBool = input.parse()?; + expose_message = value.value; + } else { + expose_message = true; + } + } + other => { + return Err(Error::new( + ident.span(), + format!("unknown #[app_error] option `{}`", other) + )); + } + } + + if input.peek(Token![,]) { + input.parse::()?; + } else if !input.is_empty() { + return Err(Error::new( + input.span(), + "expected `,` or end of input in #[app_error(...)]" + )); + } + } + + let kind = match kind { + Some(kind) => kind, + None => { + return Err(Error::new( + attr.span(), + "missing `kind = ...` in #[app_error(...)]" + )); + } + }; + + Ok(AppErrorSpec { + kind, + code, + expose_message, + attribute_span: attr.span() + }) + }) +} + +fn parse_error_attribute(attr: &Attribute) -> Result { + mod kw { + syn::custom_keyword!(transparent); + syn::custom_keyword!(fmt); + } + + attr.parse_args_with(|input: ParseStream| { + if input.peek(LitStr) { + let lit: LitStr = input.parse()?; + let template = parse_display_template(lit)?; + let args = parse_format_args(input)?; + + if !input.is_empty() { + return Err(Error::new( + input.span(), + "unexpected tokens after format arguments" + )); + } + + if args.args.is_empty() { + Ok(DisplaySpec::Template(template)) + } else { + Ok(DisplaySpec::TemplateWithArgs { + template, + args + }) + } + } else if input.peek(kw::transparent) { + let _: kw::transparent = input.parse()?; + + if !input.is_empty() { + return Err(Error::new( + input.span(), + "format arguments are not supported with #[error(transparent)]" + )); + } + + Ok(DisplaySpec::Transparent { + attribute: Box::new(attr.clone()) + }) + } else if input.peek(kw::fmt) { + input.parse::()?; + input.parse::()?; + let path: ExprPath = input.parse()?; + let args = parse_format_args(input)?; + + for arg in &args.args { + if let FormatBindingKind::Named(ident) = &arg.kind + && ident == "fmt" + { + return Err(Error::new(arg.span, "duplicate `fmt` handler specified")); + } + } + + if !input.is_empty() { + return Err(Error::new( + input.span(), + "`fmt = ...` cannot be combined with additional arguments" + )); + } + + Ok(DisplaySpec::FormatterPath { + path, + args + }) + } else { + Err(Error::new( + input.span(), + "expected string literal, `transparent`, or `fmt = ...`" + )) + } + }) +} + +fn parse_format_args(input: ParseStream) -> Result { + let mut args = FormatArgsSpec::default(); + + if input.is_empty() { + return Ok(args); + } + + let leading_comma = if input.peek(Token![,]) { + let comma: Token![,] = input.parse()?; + Some(comma.span) + } else { + None + }; + + if input.is_empty() { + if let Some(span) = leading_comma { + return Err(Error::new(span, "expected format argument after comma")); + } + return Ok(args); + } + + let parsed = syn::punctuated::Punctuated::::parse_terminated(input)?; + + let mut seen_named = HashSet::new(); + + let mut positional_index = 0usize; + + for raw in parsed { + match raw { + RawFormatArg::Named { + ident, + value, + span + } => { + let name_key = ident.to_string(); + if !seen_named.insert(name_key) { + return Err(Error::new( + ident.span(), + format!("duplicate format argument `{ident}`") + )); + } + + args.args.push(FormatArg { + value, + kind: FormatBindingKind::Named(ident), + span + }); + } + RawFormatArg::Positional { + value, + span + } => { + let index = positional_index; + positional_index += 1; + args.args.push(FormatArg { + value, + kind: FormatBindingKind::Positional(index), + span + }); + } + } + } + + Ok(args) +} + +enum RawFormatArg { + Named { + ident: Ident, + value: FormatArgValue, + span: Span + }, + Positional { + value: FormatArgValue, + span: Span + } +} + +impl Parse for RawFormatArg { + fn parse(input: ParseStream) -> syn::Result { + if input.peek(Ident) && input.peek2(Token![=]) { + let ident: Ident = input.parse()?; + input.parse::()?; + let value = parse_format_arg_value(input)?; + let value_span = format_arg_value_span(&value); + let span = ident + .span() + .join(value_span) + .unwrap_or_else(|| ident.span()); + Ok(Self::Named { + ident, + value, + span + }) + } else { + let value = parse_format_arg_value(input)?; + let span = format_arg_value_span(&value); + Ok(Self::Positional { + value, + span + }) + } + } +} + +fn parse_format_arg_value(input: ParseStream) -> syn::Result { + if input.peek(Token![.]) { + let dot: Token![.] = input.parse()?; + let projection = parse_projection_segments(input, dot.span)?; + Ok(FormatArgValue::Shorthand(FormatArgShorthand::Projection( + projection + ))) + } else { + let expr: Expr = input.parse()?; + Ok(FormatArgValue::Expr(expr)) + } +} + +fn parse_projection_segments( + input: ParseStream, + dot_span: Span +) -> syn::Result { + let first = parse_projection_segment(input, true)?; + let mut segments = vec![first]; + + while input.peek(Token![.]) { + input.parse::()?; + segments.push(parse_projection_segment(input, false)?); + } + + let mut span = join_spans(dot_span, segments[0].span()); + for segment in segments.iter().skip(1) { + span = join_spans(span, segment.span()); + } + + Ok(FormatArgProjection { + segments, + span + }) +} + +fn parse_projection_segment( + input: ParseStream, + first: bool +) -> syn::Result { + if input.peek(LitInt) { + let literal: LitInt = input.parse()?; + let index = literal.base10_parse::()?; + return Ok(FormatArgProjectionSegment::Index { + index, + span: literal.span() + }); + } + + if input.peek(Ident) { + let ident: Ident = input.parse()?; + if let Some((turbofish, paren_token, args)) = parse_method_call_suffix(input)? { + let span = method_call_span(&ident, turbofish.as_ref(), &paren_token); + return Ok(FormatArgProjectionSegment::MethodCall( + FormatArgProjectionMethodCall { + method: ident, + turbofish, + args, + span + } + )); + } + + return Ok(FormatArgProjectionSegment::Field(ident)); + } + + let span = input.span(); + if first { + Err(syn::Error::new( + span, + "expected field, index, or method call after `.`" + )) + } else { + Err(syn::Error::new( + span, + "expected field, index, or method call in projection" + )) + } +} + +fn parse_method_call_suffix(input: ParseStream) -> syn::Result { + let ahead = input.fork(); + + let has_turbofish = ahead.peek(Token![::]); + if has_turbofish { + let _: Token![::] = ahead.parse()?; + let _: AngleBracketedGenericArguments = ahead.parse()?; + } + + if !ahead.peek(Paren) { + return Ok(None); + } + + let turbofish = if has_turbofish { + let colon2_token = input.parse::()?; + let generics = input.parse::()?; + Some(FormatArgMethodTurbofish { + colon2_token, + generics + }) + } else { + None + }; + + let content; + let paren_token = syn::parenthesized!(content in input); + let args = Punctuated::::parse_terminated(&content)?; + + Ok(Some((turbofish, paren_token, args))) +} + +fn method_call_span( + method: &Ident, + turbofish: Option<&FormatArgMethodTurbofish>, + paren_token: &Paren +) -> Span { + let mut span = method.span(); + if let Some(turbofish) = turbofish { + span = join_spans(span, turbofish.generics.gt_token.span); + } + join_spans(span, paren_token.span.close()) +} + +fn join_spans(lhs: Span, rhs: Span) -> Span { + lhs.join(rhs).unwrap_or(lhs) +} + +fn format_arg_value_span(value: &FormatArgValue) -> Span { + match value { + FormatArgValue::Expr(expr) => expr.span(), + FormatArgValue::Shorthand(FormatArgShorthand::Projection(projection)) => projection.span + } +} + +fn validate_from_usage(fields: &Fields, display: &DisplaySpec, errors: &mut Vec) { + let mut from_fields = fields.iter().filter(|field| field.attrs.from.is_some()); + let first = from_fields.next(); + let second = from_fields.next(); + + if let Some(field) = first { + if second.is_some() { + if let Some(attr) = &field.attrs.from { + errors.push(Error::new_spanned( + attr, + "multiple #[from] fields are not supported" + )); + } + return; + } + + let mut has_unexpected_companions = false; + for companion in fields.iter() { + if companion.index == field.index { + continue; + } + + if companion.attrs.has_backtrace() { + continue; + } + + if companion.attrs.has_source() { + if companion.attrs.from.is_none() && !is_option_type(&companion.ty) { + if let Some(attr) = companion.attrs.source_attribute() { + errors.push(Error::new_spanned( + attr, + "additional #[source] fields used with #[from] must be Option<_>" + )); + } else { + errors.push(Error::new( + companion.span, + "additional #[source] fields used with #[from] must be Option<_>" + )); + } + } + continue; + } + + has_unexpected_companions = true; + } + + if has_unexpected_companions && let Some(attr) = &field.attrs.from { + errors.push(Error::new_spanned( + attr, + "deriving From requires no fields other than source and backtrace" + )); + } + + if matches!(display, DisplaySpec::Transparent { .. }) + && fields.len() != 1 + && let Some(attr) = &field.attrs.from + { + errors.push(Error::new_spanned( + attr, + "#[error(transparent)] requires exactly one field" + )); + } + } +} + +fn validate_backtrace_usage(fields: &Fields, errors: &mut Vec) { + let backtrace_fields: Vec<_> = fields + .iter() + .filter(|field| field.attrs.has_backtrace()) + .collect(); + + for field in &backtrace_fields { + validate_backtrace_field_type(field, errors); + } + + if backtrace_fields.len() <= 1 { + return; + } + + for field in backtrace_fields.iter().skip(1) { + if let Some(attr) = field.attrs.backtrace_attribute() { + errors.push(Error::new_spanned( + attr, + "multiple #[backtrace] fields are not supported" + )); + } else { + errors.push(Error::new( + field.span, + "multiple #[backtrace] fields are not supported" + )); + } + } +} + +fn validate_backtrace_field_type(field: &Field, errors: &mut Vec) { + let Some(attr) = field.attrs.backtrace_attribute() else { + return; + }; + + if is_backtrace_storage(&field.ty) { + return; + } + + if field.attrs.has_source() { + return; + } + + errors.push(Error::new_spanned( + attr, + "fields with #[backtrace] must be std::backtrace::Backtrace or Option" + )); +} + +fn validate_transparent( + fields: &Fields, + display: &DisplaySpec, + errors: &mut Vec, + variant: Option<&syn::Variant> +) { + if fields.len() == 1 { + return; + } + + if let DisplaySpec::Transparent { + attribute + } = display + { + match variant { + Some(variant) => { + errors.push(Error::new_spanned( + variant, + "#[error(transparent)] requires exactly one field" + )); + } + None => { + errors.push(Error::new_spanned( + attribute.as_ref(), + "#[error(transparent)] requires exactly one field" + )); + } + } + } +} + +fn path_is(attr: &Attribute, expected: &str) -> bool { + attr.path().is_ident(expected) +} + +fn collect_errors(errors: Vec) -> Error { + let mut iter = errors.into_iter(); + let mut root = iter + .next() + .unwrap_or_else(|| Error::new(Span::call_site(), "unexpected error")); + for err in iter { + root.combine(err); + } + root +} + +pub fn is_option_type(ty: &syn::Type) -> bool { + if let syn::Type::Path(path) = ty { + if path.qself.is_some() { + return false; + } + if let Some(last) = path.path.segments.last() + && last.ident == "Option" + { + return true; + } + } + false +} + +pub(crate) fn option_inner_type(ty: &syn::Type) -> Option<&syn::Type> { + let syn::Type::Path(path) = ty else { + return None; + }; + if path.qself.is_some() { + return None; + } + let last = path.path.segments.last()?; + if last.ident != "Option" { + return None; + } + let syn::PathArguments::AngleBracketed(arguments) = &last.arguments else { + return None; + }; + arguments.args.iter().find_map(|argument| match argument { + GenericArgument::Type(inner) => Some(inner), + _ => None + }) +} + +pub(crate) fn is_backtrace_type(ty: &syn::Type) -> bool { + let syn::Type::Path(path) = ty else { + return false; + }; + if path.qself.is_some() { + return false; + } + let Some(last) = path.path.segments.last() else { + return false; + }; + last.ident == "Backtrace" && matches!(last.arguments, syn::PathArguments::None) +} + +pub(crate) fn is_backtrace_storage(ty: &syn::Type) -> bool { + if is_option_type(ty) { + option_inner_type(ty).is_some_and(is_backtrace_type) + } else { + is_backtrace_type(ty) + } +} + +pub fn placeholder_error(span: Span, identifier: &TemplateIdentifierSpec) -> Error { + match identifier { + TemplateIdentifierSpec::Named(name) => { + Error::new(span, format!("unknown field `{}`", name)) + } + TemplateIdentifierSpec::Positional(index) => { + Error::new(span, format!("field `{}` is not available", index)) + } + TemplateIdentifierSpec::Implicit(index) => { + Error::new(span, format!("field `{}` is not available", index)) + } + } +} diff --git a/masterror-derive/src/lib.rs b/masterror-derive/src/lib.rs new file mode 100644 index 0000000..cffacd7 --- /dev/null +++ b/masterror-derive/src/lib.rs @@ -0,0 +1,40 @@ +//! Derive macro for `masterror::Error`. +//! +//! This crate is not intended to be used directly. Re-exported as +//! `masterror::Error`. + +mod app_error_impl; +mod display; +mod error_trait; +mod from_impl; +mod input; +mod span; +mod template_support; + +use proc_macro::TokenStream; +use quote::quote; +use syn::{DeriveInput, Error, parse_macro_input}; + +#[proc_macro_derive(Error, attributes(error, source, from, backtrace, app_error, provide))] +pub fn derive_error(tokens: TokenStream) -> TokenStream { + let input = parse_macro_input!(tokens as DeriveInput); + match expand(input) { + Ok(stream) => stream.into(), + Err(err) => err.to_compile_error().into() + } +} + +fn expand(input: DeriveInput) -> Result { + let parsed = input::parse_input(input)?; + let display_impl = display::expand(&parsed)?; + let error_impl = error_trait::expand(&parsed)?; + let from_impls = from_impl::expand(&parsed)?; + let app_error_impls = app_error_impl::expand(&parsed)?; + + Ok(quote! { + #display_impl + #error_impl + #(#from_impls)* + #(#app_error_impls)* + }) +} diff --git a/masterror-derive/src/span.rs b/masterror-derive/src/span.rs new file mode 100644 index 0000000..2aa1c65 --- /dev/null +++ b/masterror-derive/src/span.rs @@ -0,0 +1,149 @@ +use core::ops::Range; + +use proc_macro2::Span; +use syn::LitStr; + +/// Computes the span of a substring within a string literal. +/// +/// The range is expressed in byte indices over the interpreted contents of the +/// literal (after unescaping). The function maps it back to a span over the +/// original source code when possible. +pub fn literal_subspan(lit: &LitStr, range: Range) -> Option { + if range.start > range.end { + return None; + } + + let value = lit.value(); + if range.end > value.len() { + return None; + } + + let token = lit.token(); + let repr = token.to_string(); + + if repr.starts_with('r') { + raw_range(&repr, range).and_then(|sub| token.subspan(sub)) + } else { + escaped_range(&repr, &value, range).and_then(|sub| token.subspan(sub)) + } +} + +fn raw_range(repr: &str, range: Range) -> Option> { + let bytes = repr.as_bytes(); + let mut idx = 0usize; + if bytes.get(idx)? != &b'r' { + return None; + } + + idx += 1; + while matches!(bytes.get(idx), Some(b'#')) { + idx += 1; + } + + if bytes.get(idx)? != &b'"' { + return None; + } + + let hash_count = idx - 1; + let start_content = idx + 1; + let end_content = repr.len().checked_sub(hash_count + 1)?; + + if start_content > end_content || range.end > end_content - start_content { + return None; + } + + let start = start_content + range.start; + let end = start_content + range.end; + Some(start..end) +} + +fn escaped_range(repr: &str, value: &str, range: Range) -> Option> { + let bytes = repr.as_bytes(); + if bytes.first()? != &b'"' || bytes.last()? != &b'"' { + return None; + } + + let mut mapping = vec![0usize; value.len() + 1]; + let mut token_pos = 1usize; + let content_end = repr.len() - 1; + let mut value_pos = 0usize; + + mapping[value_pos] = token_pos; + + while token_pos < content_end && value_pos < value.len() { + if bytes[token_pos] == b'\\' { + let escape_len = escape_sequence_len(&bytes[token_pos..content_end])?; + let ch = value[value_pos..].chars().next()?; + let produced = ch.len_utf8(); + + for offset in 0..produced { + mapping[value_pos + offset] = token_pos; + } + + value_pos += produced; + token_pos += escape_len; + mapping[value_pos] = token_pos; + } else { + let ch = core::str::from_utf8(&bytes[token_pos..content_end]) + .ok()? + .chars() + .next()?; + let char_len = ch.len_utf8(); + + for offset in 0..char_len { + mapping[value_pos + offset] = token_pos; + } + + value_pos += ch.len_utf8(); + token_pos += char_len; + mapping[value_pos] = token_pos; + } + } + + if value_pos != value.len() { + return None; + } + + mapping[value_pos] = content_end; + + if range.end > value.len() { + return None; + } + + Some(mapping[range.start]..mapping[range.end]) +} + +fn escape_sequence_len(bytes: &[u8]) -> Option { + if bytes.len() < 2 || bytes[0] != b'\\' { + return None; + } + + match bytes[1] { + b'\\' | b'"' | b'\'' | b'n' | b'r' | b't' | b'0' => Some(2), + b'x' => { + if bytes.len() >= 4 { + Some(4) + } else { + None + } + } + b'u' => { + let mut idx = 2usize; + if bytes.get(idx)? != &b'{' { + return None; + } + + idx += 1; + while idx < bytes.len() && bytes[idx] != b'}' { + idx += 1; + } + + if idx >= bytes.len() { + return None; + } + + Some(idx + 1) + } + _ => None + } +} diff --git a/masterror-derive/src/template_support.rs b/masterror-derive/src/template_support.rs new file mode 100644 index 0000000..fb33c9a --- /dev/null +++ b/masterror-derive/src/template_support.rs @@ -0,0 +1,101 @@ +use masterror_template::template::{ + ErrorTemplate, TemplateError, TemplateFormatter, TemplateIdentifier, TemplateSegment +}; +use proc_macro2::Span; +use syn::{Error, LitStr}; + +use crate::span::literal_subspan; + +#[derive(Debug, Clone)] +pub struct DisplayTemplate { + pub segments: Vec +} + +#[derive(Debug, Clone)] +pub enum TemplateSegmentSpec { + Literal(String), + Placeholder(TemplatePlaceholderSpec) +} + +#[derive(Debug, Clone)] +pub struct TemplatePlaceholderSpec { + pub span: Span, + pub identifier: TemplateIdentifierSpec, + pub formatter: TemplateFormatter +} + +#[derive(Debug, Clone)] +pub enum TemplateIdentifierSpec { + Named(String), + Positional(usize), + Implicit(usize) +} + +pub fn parse_display_template(lit: LitStr) -> Result { + let value = lit.value(); + let parsed = ErrorTemplate::parse(&value).map_err(|err| template_error(&lit, err))?; + + let mut segments = Vec::new(); + for segment in parsed.segments() { + match segment { + TemplateSegment::Literal(text) => { + segments.push(TemplateSegmentSpec::Literal(text.to_string())); + } + TemplateSegment::Placeholder(placeholder) => { + let span = placeholder_span(&lit, placeholder.span()); + let identifier = match placeholder.identifier() { + TemplateIdentifier::Named(name) => { + TemplateIdentifierSpec::Named(name.to_string()) + } + TemplateIdentifier::Positional(index) => { + TemplateIdentifierSpec::Positional(*index) + } + TemplateIdentifier::Implicit(index) => TemplateIdentifierSpec::Implicit(*index) + }; + + segments.push(TemplateSegmentSpec::Placeholder(TemplatePlaceholderSpec { + span, + identifier, + formatter: placeholder.formatter().clone() + })); + } + } + } + + Ok(DisplayTemplate { + segments + }) +} + +fn placeholder_span(lit: &LitStr, range: core::ops::Range) -> Span { + literal_subspan(lit, range).unwrap_or_else(|| lit.span()) +} + +fn template_error(lit: &LitStr, error: TemplateError) -> Error { + let message = error.to_string(); + let span = match &error { + TemplateError::UnmatchedClosingBrace { + index + } => literal_subspan(lit, *index..(*index + 1)), + TemplateError::UnterminatedPlaceholder { + start + } => literal_subspan(lit, *start..(*start + 1)), + TemplateError::NestedPlaceholder { + index + } => literal_subspan(lit, *index..(*index + 1)), + TemplateError::EmptyPlaceholder { + start + } => literal_subspan(lit, *start..(*start + 1)), + TemplateError::InvalidIdentifier { + span + } => literal_subspan(lit, span.clone()), + TemplateError::InvalidIndex { + span + } => literal_subspan(lit, span.clone()), + TemplateError::InvalidFormatter { + span + } => literal_subspan(lit, span.clone()) + }; + + Error::new(span.unwrap_or_else(|| lit.span()), message) +} diff --git a/masterror-template/Cargo.toml b/masterror-template/Cargo.toml new file mode 100644 index 0000000..62bc80b --- /dev/null +++ b/masterror-template/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "masterror-template" +version = "0.3.1" +rust-version = "1.90" +edition = "2024" +repository = "https://github.com/RAprogramm/masterror" +readme = "README.md" +description = "Template utilities for masterror and its derive macros" +publish = false +license = "MIT OR Apache-2.0" + +[dependencies] diff --git a/masterror-template/src/lib.rs b/masterror-template/src/lib.rs new file mode 100644 index 0000000..2c184b8 --- /dev/null +++ b/masterror-template/src/lib.rs @@ -0,0 +1,8 @@ +//! Shared helpers for error derive macros. +//! +//! This crate exposes the formatting template parser used by `masterror` +//! to interpret `#[error("...")]` attributes. It is internal to the +//! workspace but kept separate so that procedural macros can reuse the +//! parsing logic without a circular dependency. + +pub mod template; diff --git a/masterror-template/src/template.rs b/masterror-template/src/template.rs new file mode 100644 index 0000000..9d027a4 --- /dev/null +++ b/masterror-template/src/template.rs @@ -0,0 +1,960 @@ +use core::{fmt, ops::Range}; +use std::borrow::Cow; + +mod parser; + +/// Parsed representation of an `#[error("...")]` template. +/// +/// Templates are represented as a sequence of literal segments and +/// placeholders. The structure mirrors the internal representation used by +/// formatting machinery, but keeps the slices borrowed from the original input +/// to avoid unnecessary allocations. +/// +/// # Examples +/// +/// ``` +/// use masterror_template::template::{ErrorTemplate, TemplateIdentifier}; +/// +/// let template = ErrorTemplate::parse("{code}: {message}").expect("parse"); +/// let rendered = format!( +/// "{}", +/// template.display_with(|placeholder, f| match placeholder.identifier() { +/// TemplateIdentifier::Named("code") => write!(f, "{}", 404), +/// TemplateIdentifier::Named("message") => f.write_str("Not Found"), +/// _ => Ok(()) +/// }) +/// ); +/// +/// assert_eq!(rendered, "404: Not Found"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ErrorTemplate<'a> { + source: &'a str, + segments: Vec> +} + +impl<'a> ErrorTemplate<'a> { + /// Parses an error display template. + pub fn parse(source: &'a str) -> Result { + let segments = parser::parse_template(source)?; + Ok(Self { + source, + segments + }) + } + + /// Returns the original template string. + pub const fn source(&self) -> &'a str { + self.source + } + + /// Returns the parsed segments. + pub fn segments(&self) -> &[TemplateSegment<'a>] { + &self.segments + } + + /// Iterates over placeholder segments in order of appearance. + pub fn placeholders(&self) -> impl Iterator> { + self.segments.iter().filter_map(|segment| match segment { + TemplateSegment::Placeholder(placeholder) => Some(placeholder), + TemplateSegment::Literal(_) => None + }) + } + + /// Produces a display implementation that delegates placeholder rendering + /// to the provided resolver. + pub fn display_with(&'a self, resolver: F) -> DisplayWith<'a, 'a, F> + where + F: Fn(&TemplatePlaceholder<'a>, &mut fmt::Formatter<'_>) -> fmt::Result + { + DisplayWith { + template: self, + resolver + } + } +} + +/// A lazily formatted view over a template. +#[derive(Debug)] +pub struct DisplayWith<'a, 't, F> +where + F: Fn(&TemplatePlaceholder<'a>, &mut fmt::Formatter<'_>) -> fmt::Result +{ + template: &'t ErrorTemplate<'a>, + resolver: F +} + +impl<'a, 't, F> fmt::Display for DisplayWith<'a, 't, F> +where + F: Fn(&TemplatePlaceholder<'a>, &mut fmt::Formatter<'_>) -> fmt::Result +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for segment in &self.template.segments { + match segment { + TemplateSegment::Literal(literal) => f.write_str(literal)?, + TemplateSegment::Placeholder(placeholder) => { + (self.resolver)(placeholder, f)?; + } + } + } + + Ok(()) + } +} + +/// A single segment of the parsed template. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplateSegment<'a> { + /// Literal text copied verbatim. + Literal(&'a str), + /// Placeholder (`{name}` or `{0}`) that needs formatting. + Placeholder(TemplatePlaceholder<'a>) +} + +/// Placeholder metadata extracted from a template. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TemplatePlaceholder<'a> { + span: Range, + identifier: TemplateIdentifier<'a>, + formatter: TemplateFormatter +} + +impl<'a> TemplatePlaceholder<'a> { + /// Byte range (inclusive start, exclusive end) of the placeholder within + /// the original template. + pub fn span(&self) -> Range { + self.span.clone() + } + + /// Returns the parsed identifier. + pub const fn identifier(&self) -> &TemplateIdentifier<'a> { + &self.identifier + } + + /// Returns the requested formatter. + pub fn formatter(&self) -> &TemplateFormatter { + &self.formatter + } +} + +/// Placeholder identifier parsed from the template. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplateIdentifier<'a> { + /// Implicit positional index inferred from the placeholder order (`{}` / + /// `{:?}` / etc.). + Implicit(usize), + /// Positional index (`{0}` / `{1:?}` / etc.). + Positional(usize), + /// Named field (`{name}` / `{kind:?}` / etc.). + Named(&'a str) +} + +impl<'a> TemplateIdentifier<'a> { + /// Returns the identifier as a string when it is named. + pub const fn as_str(&self) -> Option<&'a str> { + match self { + Self::Named(value) => Some(value), + Self::Positional(_) | Self::Implicit(_) => None + } + } +} + +/// Formatter traits recognised within placeholders. +/// +/// # Examples +/// +/// ``` +/// use masterror_template::template::{TemplateFormatter, TemplateFormatterKind}; +/// +/// let formatter = TemplateFormatter::LowerHex { +/// alternate: true +/// }; +/// +/// assert_eq!(formatter.kind(), TemplateFormatterKind::LowerHex); +/// assert_eq!(TemplateFormatterKind::LowerHex.specifier(), Some('x')); +/// assert!(TemplateFormatterKind::LowerHex.supports_alternate()); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TemplateFormatterKind { + /// Default `Display` trait (`{value}`). + Display, + /// `Debug` trait (`{value:?}` / `{value:#?}`). + Debug, + /// `LowerHex` trait (`{value:x}` / `{value:#x}`). + LowerHex, + /// `UpperHex` trait (`{value:X}` / `{value:#X}`). + UpperHex, + /// `Pointer` trait (`{value:p}` / `{value:#p}`). + Pointer, + /// `Binary` trait (`{value:b}` / `{value:#b}`). + Binary, + /// `Octal` trait (`{value:o}` / `{value:#o}`). + Octal, + /// `LowerExp` trait (`{value:e}` / `{value:#e}`). + LowerExp, + /// `UpperExp` trait (`{value:E}` / `{value:#E}`). + UpperExp +} + +impl TemplateFormatterKind { + /// Maps a format specifier character to a formatter kind. + /// + /// Returns `None` when the specifier is unsupported. + /// + /// # Examples + /// + /// ``` + /// use masterror_template::template::TemplateFormatterKind; + /// + /// assert_eq!( + /// TemplateFormatterKind::from_specifier('?'), + /// Some(TemplateFormatterKind::Debug) + /// ); + /// assert_eq!(TemplateFormatterKind::from_specifier('Q'), None); + /// ``` + pub const fn from_specifier(specifier: char) -> Option { + match specifier { + '?' => Some(Self::Debug), + 'x' => Some(Self::LowerHex), + 'X' => Some(Self::UpperHex), + 'p' => Some(Self::Pointer), + 'b' => Some(Self::Binary), + 'o' => Some(Self::Octal), + 'e' => Some(Self::LowerExp), + 'E' => Some(Self::UpperExp), + _ => None + } + } + + /// Returns the canonical format specifier character, if any. + /// + /// The default `Display` kind has no dedicated specifier and therefore + /// returns `None`. + /// + /// # Examples + /// + /// ``` + /// use masterror_template::template::TemplateFormatterKind; + /// + /// assert_eq!(TemplateFormatterKind::LowerHex.specifier(), Some('x')); + /// assert_eq!(TemplateFormatterKind::Display.specifier(), None); + /// ``` + pub const fn specifier(self) -> Option { + match self { + Self::Display => None, + Self::Debug => Some('?'), + Self::LowerHex => Some('x'), + Self::UpperHex => Some('X'), + Self::Pointer => Some('p'), + Self::Binary => Some('b'), + Self::Octal => Some('o'), + Self::LowerExp => Some('e'), + Self::UpperExp => Some('E') + } + } + + /// Indicates whether the formatter kind supports the alternate (`#`) flag. + /// + /// # Examples + /// + /// ``` + /// use masterror_template::template::TemplateFormatterKind; + /// + /// assert!(TemplateFormatterKind::Binary.supports_alternate()); + /// assert!(!TemplateFormatterKind::Display.supports_alternate()); + /// ``` + pub const fn supports_alternate(self) -> bool { + !matches!(self, Self::Display) + } +} + +/// Formatting mode requested by the placeholder. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplateFormatter { + /// Default `Display` formatting (`{value}`) with an optional format spec. + Display { + /// Raw display format specifier (for example `">8"` or ".3"). + spec: Option> + }, + /// `Debug` formatting (`{value:?}` or `{value:#?}`). + Debug { + /// Whether `{value:#?}` (alternate debug) was requested. + alternate: bool + }, + /// Lower-hexadecimal formatting (`{value:x}` / `{value:#x}`). + LowerHex { + /// Whether alternate formatting (`{value:#x}`) was requested. + alternate: bool + }, + /// Upper-hexadecimal formatting (`{value:X}` / `{value:#X}`). + UpperHex { + /// Whether alternate formatting (`{value:#X}`) was requested. + alternate: bool + }, + /// Pointer formatting (`{value:p}` / `{value:#p}`). + Pointer { + /// Whether alternate formatting (`{value:#p}`) was requested. + alternate: bool + }, + /// Binary formatting (`{value:b}` / `{value:#b}`). + Binary { + /// Whether alternate formatting (`{value:#b}`) was requested. + alternate: bool + }, + /// Octal formatting (`{value:o}` / `{value:#o}`). + Octal { + /// Whether alternate formatting (`{value:#o}`) was requested. + alternate: bool + }, + /// Lower exponential formatting (`{value:e}` / `{value:#e}`). + LowerExp { + /// Whether alternate formatting (`{value:#e}`) was requested. + alternate: bool + }, + /// Upper exponential formatting (`{value:E}` / `{value:#E}`). + UpperExp { + /// Whether alternate formatting (`{value:#E}`) was requested. + alternate: bool + } +} + +impl TemplateFormatter { + /// Constructs a formatter from a [`TemplateFormatterKind`] and `alternate` + /// flag. + /// + /// The `alternate` flag is ignored for [`TemplateFormatterKind::Display`]. + /// + /// # Examples + /// + /// ``` + /// use masterror_template::template::{TemplateFormatter, TemplateFormatterKind}; + /// + /// let formatter = TemplateFormatter::from_kind(TemplateFormatterKind::Binary, true); + /// + /// assert!(matches!( + /// formatter, + /// TemplateFormatter::Binary { + /// alternate: true + /// } + /// )); + /// ``` + pub const fn from_kind(kind: TemplateFormatterKind, alternate: bool) -> Self { + match kind { + TemplateFormatterKind::Display => Self::Display { + spec: None + }, + TemplateFormatterKind::Debug => Self::Debug { + alternate + }, + TemplateFormatterKind::LowerHex => Self::LowerHex { + alternate + }, + TemplateFormatterKind::UpperHex => Self::UpperHex { + alternate + }, + TemplateFormatterKind::Pointer => Self::Pointer { + alternate + }, + TemplateFormatterKind::Binary => Self::Binary { + alternate + }, + TemplateFormatterKind::Octal => Self::Octal { + alternate + }, + TemplateFormatterKind::LowerExp => Self::LowerExp { + alternate + }, + TemplateFormatterKind::UpperExp => Self::UpperExp { + alternate + } + } + } + + /// Returns the underlying formatter kind. + /// + /// # Examples + /// + /// ``` + /// use masterror_template::template::{TemplateFormatter, TemplateFormatterKind}; + /// + /// let formatter = TemplateFormatter::Pointer { + /// alternate: false + /// }; + /// + /// assert_eq!(formatter.kind(), TemplateFormatterKind::Pointer); + /// ``` + pub const fn kind(&self) -> TemplateFormatterKind { + match self { + Self::Display { + .. + } => TemplateFormatterKind::Display, + Self::Debug { + .. + } => TemplateFormatterKind::Debug, + Self::LowerHex { + .. + } => TemplateFormatterKind::LowerHex, + Self::UpperHex { + .. + } => TemplateFormatterKind::UpperHex, + Self::Pointer { + .. + } => TemplateFormatterKind::Pointer, + Self::Binary { + .. + } => TemplateFormatterKind::Binary, + Self::Octal { + .. + } => TemplateFormatterKind::Octal, + Self::LowerExp { + .. + } => TemplateFormatterKind::LowerExp, + Self::UpperExp { + .. + } => TemplateFormatterKind::UpperExp + } + } + + /// Parses a formatting specifier (the portion after `:`) into a formatter. + pub fn from_format_spec(spec: &str) -> Option { + Self::parse_specifier(spec) + } + + pub(crate) fn parse_specifier(spec: &str) -> Option { + parser::parse_formatter_spec(spec) + } + + /// Returns the stored display format specifier, if any. + pub fn display_spec(&self) -> Option<&str> { + match self { + Self::Display { + spec: Some(spec) + } => Some(spec), + _ => None + } + } + + /// Indicates whether a display formatter carries additional formatting + /// parameters. + pub fn has_display_spec(&self) -> bool { + matches!( + self, + Self::Display { + spec: Some(_) + } + ) + } + + /// Returns the formatter fragment that should follow the `:` in a format + /// string. + pub fn format_fragment(&self) -> Option> { + match self { + Self::Display { + spec + } => spec.as_deref().map(Cow::Borrowed), + Self::Debug { + alternate + } => { + if *alternate { + Some(Cow::Borrowed("#?")) + } else { + Some(Cow::Borrowed("?")) + } + } + Self::LowerHex { + alternate + } => { + if *alternate { + Some(Cow::Borrowed("#x")) + } else { + Some(Cow::Borrowed("x")) + } + } + Self::UpperHex { + alternate + } => { + if *alternate { + Some(Cow::Borrowed("#X")) + } else { + Some(Cow::Borrowed("X")) + } + } + Self::Pointer { + alternate + } => { + if *alternate { + Some(Cow::Borrowed("#p")) + } else { + Some(Cow::Borrowed("p")) + } + } + Self::Binary { + alternate + } => { + if *alternate { + Some(Cow::Borrowed("#b")) + } else { + Some(Cow::Borrowed("b")) + } + } + Self::Octal { + alternate + } => { + if *alternate { + Some(Cow::Borrowed("#o")) + } else { + Some(Cow::Borrowed("o")) + } + } + Self::LowerExp { + alternate + } => { + if *alternate { + Some(Cow::Borrowed("#e")) + } else { + Some(Cow::Borrowed("e")) + } + } + Self::UpperExp { + alternate + } => { + if *alternate { + Some(Cow::Borrowed("#E")) + } else { + Some(Cow::Borrowed("E")) + } + } + } + } + + /// Returns `true` when alternate formatting (`#`) was requested. + pub const fn is_alternate(&self) -> bool { + match self { + Self::Display { + .. + } => false, + Self::Debug { + alternate + } + | Self::LowerHex { + alternate + } + | Self::UpperHex { + alternate + } + | Self::Pointer { + alternate + } + | Self::Binary { + alternate + } + | Self::Octal { + alternate + } + | Self::LowerExp { + alternate + } + | Self::UpperExp { + alternate + } => *alternate + } + } +} + +/// Parsing errors produced when validating a template. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplateError { + /// Encountered a stray closing brace. + UnmatchedClosingBrace { + /// Byte index of the stray `}` in the original template. + index: usize + }, + /// Placeholder without a matching closing brace. + UnterminatedPlaceholder { + /// Byte index where the unterminated placeholder starts. + start: usize + }, + /// Encountered `{{` or `}}` imbalance inside a placeholder. + NestedPlaceholder { + /// Byte index of the unexpected brace. + index: usize + }, + /// Placeholder without an identifier. + EmptyPlaceholder { + /// Byte index where the empty placeholder starts. + start: usize + }, + /// Identifier is malformed (contains illegal characters). + InvalidIdentifier { + /// Span (byte indices) covering the invalid identifier. + span: Range + }, + /// Positional identifier is not a valid unsigned integer. + InvalidIndex { + /// Span (byte indices) covering the invalid positional identifier. + span: Range + }, + /// Unsupported formatting specifier. + InvalidFormatter { + /// Span (byte indices) covering the unsupported formatter. + span: Range + } +} + +impl fmt::Display for TemplateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnmatchedClosingBrace { + index + } => { + write!(f, "unmatched closing brace at byte {}", index) + } + Self::UnterminatedPlaceholder { + start + } => { + write!(f, "placeholder starting at byte {} is not closed", start) + } + Self::NestedPlaceholder { + index + } => { + write!( + f, + "nested placeholder starting at byte {} is not supported", + index + ) + } + Self::EmptyPlaceholder { + start + } => { + write!(f, "placeholder starting at byte {} is empty", start) + } + Self::InvalidIdentifier { + span + } => { + write!( + f, + "invalid placeholder identifier spanning bytes {}..{}", + span.start, span.end + ) + } + Self::InvalidIndex { + span + } => { + write!( + f, + "positional placeholder spanning bytes {}..{} is not a valid unsigned integer", + span.start, span.end + ) + } + Self::InvalidFormatter { + span + } => { + write!( + f, + "placeholder spanning bytes {}..{} uses an unsupported formatter", + span.start, span.end + ) + } + } + } +} + +impl std::error::Error for TemplateError {} + +#[cfg(test)] +mod tests { + use super::*; + + fn named(name: &str) -> TemplateIdentifier<'_> { + TemplateIdentifier::Named(name) + } + + #[test] + fn parses_basic_template() { + let template = ErrorTemplate::parse("{code}: {message}").expect("parse"); + let segments = template.segments(); + + assert_eq!(segments.len(), 3); + assert!(matches!(segments[0], TemplateSegment::Placeholder(_))); + assert!(matches!(segments[1], TemplateSegment::Literal(": "))); + assert!(matches!(segments[2], TemplateSegment::Placeholder(_))); + + let placeholders: Vec<_> = template.placeholders().collect(); + assert_eq!(placeholders.len(), 2); + assert_eq!(placeholders[0].identifier(), &named("code")); + assert_eq!(placeholders[1].identifier(), &named("message")); + } + + #[test] + fn parses_implicit_identifiers() { + let template = ErrorTemplate::parse("{}, {:?}, {name}, {}").expect("parse"); + let mut placeholders = template.placeholders(); + + let first = placeholders.next().expect("first placeholder"); + assert_eq!(first.identifier(), &TemplateIdentifier::Implicit(0)); + assert_eq!( + first.formatter(), + &TemplateFormatter::Display { + spec: None + } + ); + + let second = placeholders.next().expect("second placeholder"); + assert_eq!(second.identifier(), &TemplateIdentifier::Implicit(1)); + assert_eq!( + second.formatter(), + &TemplateFormatter::Debug { + alternate: false + } + ); + + let third = placeholders.next().expect("third placeholder"); + assert_eq!(third.identifier(), &named("name")); + + let fourth = placeholders.next().expect("fourth placeholder"); + assert_eq!(fourth.identifier(), &TemplateIdentifier::Implicit(2)); + assert!(placeholders.next().is_none()); + } + + #[test] + fn parses_debug_formatter() { + let template = ErrorTemplate::parse("{0:#?}").expect("parse"); + let placeholders: Vec<_> = template.placeholders().collect(); + + assert_eq!(placeholders.len(), 1); + assert_eq!( + placeholders[0].identifier(), + &TemplateIdentifier::Positional(0) + ); + assert_eq!( + placeholders[0].formatter(), + &TemplateFormatter::Debug { + alternate: true + } + ); + assert!(placeholders[0].formatter().is_alternate()); + } + + #[test] + fn parses_extended_formatters() { + let cases = [ + ( + "{value:x}", + TemplateFormatter::LowerHex { + alternate: false + } + ), + ( + "{value:#x}", + TemplateFormatter::LowerHex { + alternate: true + } + ), + ( + "{value:X}", + TemplateFormatter::UpperHex { + alternate: false + } + ), + ( + "{value:#X}", + TemplateFormatter::UpperHex { + alternate: true + } + ), + ( + "{value:p}", + TemplateFormatter::Pointer { + alternate: false + } + ), + ( + "{value:#p}", + TemplateFormatter::Pointer { + alternate: true + } + ), + ( + "{value:b}", + TemplateFormatter::Binary { + alternate: false + } + ), + ( + "{value:#b}", + TemplateFormatter::Binary { + alternate: true + } + ), + ( + "{value:o}", + TemplateFormatter::Octal { + alternate: false + } + ), + ( + "{value:#o}", + TemplateFormatter::Octal { + alternate: true + } + ), + ( + "{value:e}", + TemplateFormatter::LowerExp { + alternate: false + } + ), + ( + "{value:#e}", + TemplateFormatter::LowerExp { + alternate: true + } + ), + ( + "{value:E}", + TemplateFormatter::UpperExp { + alternate: false + } + ), + ( + "{value:#E}", + TemplateFormatter::UpperExp { + alternate: true + } + ) + ]; + + for (template_str, expected) in &cases { + let template = ErrorTemplate::parse(template_str).expect("parse"); + let placeholder = template.placeholders().next().expect("placeholder present"); + assert_eq!(placeholder.formatter(), expected, "case: {template_str}"); + } + } + + #[test] + fn preserves_hash_fill_display_specs() { + let template = ErrorTemplate::parse("{value:#>4}").expect("parse"); + let placeholder = template.placeholders().next().expect("placeholder present"); + + assert_eq!(placeholder.formatter().display_spec(), Some("#>4")); + assert_eq!( + placeholder.formatter().format_fragment().as_deref(), + Some("#>4") + ); + + let expected = TemplateFormatter::Display { + spec: Some("#>4".into()) + }; + + assert_eq!(placeholder.formatter(), &expected); + } + + #[test] + fn formatter_kind_helpers_cover_all_variants() { + let table = [ + (TemplateFormatterKind::Debug, '?'), + (TemplateFormatterKind::LowerHex, 'x'), + (TemplateFormatterKind::UpperHex, 'X'), + (TemplateFormatterKind::Pointer, 'p'), + (TemplateFormatterKind::Binary, 'b'), + (TemplateFormatterKind::Octal, 'o'), + (TemplateFormatterKind::LowerExp, 'e'), + (TemplateFormatterKind::UpperExp, 'E') + ]; + + for (kind, specifier) in table { + assert_eq!(TemplateFormatterKind::from_specifier(specifier), Some(kind)); + assert_eq!(kind.specifier(), Some(specifier)); + + let with_alternate = TemplateFormatter::from_kind(kind, true); + let without_alternate = TemplateFormatter::from_kind(kind, false); + + assert_eq!(with_alternate.kind(), kind); + assert_eq!(without_alternate.kind(), kind); + + if kind.supports_alternate() { + assert!(with_alternate.is_alternate()); + assert!(!without_alternate.is_alternate()); + } else { + assert!(!with_alternate.is_alternate()); + assert!(!without_alternate.is_alternate()); + } + } + + let display = TemplateFormatter::from_kind(TemplateFormatterKind::Display, true); + assert_eq!(display.kind(), TemplateFormatterKind::Display); + assert!(!display.is_alternate()); + assert_eq!(TemplateFormatterKind::Display.specifier(), None); + assert!(!TemplateFormatterKind::Display.supports_alternate()); + } + + #[test] + fn handles_brace_escaping() { + let template = ErrorTemplate::parse("{{}} -> {value}").expect("parse"); + let mut iter = template.segments().iter(); + + assert!(matches!(iter.next(), Some(TemplateSegment::Literal("{")))); + assert!(matches!(iter.next(), Some(TemplateSegment::Literal("}")))); + assert!(matches!( + iter.next(), + Some(TemplateSegment::Literal(" -> ")) + )); + assert!(matches!( + iter.next(), + Some(TemplateSegment::Placeholder(TemplatePlaceholder { .. })) + )); + assert!(iter.next().is_none()); + } + + #[test] + fn rejects_unmatched_closing_brace() { + let err = ErrorTemplate::parse("oops}").expect_err("should fail"); + assert!(matches!( + err, + TemplateError::UnmatchedClosingBrace { + index: 4 + } + )); + } + + #[test] + fn rejects_unterminated_placeholder() { + let err = ErrorTemplate::parse("{oops").expect_err("should fail"); + assert!(matches!( + err, + TemplateError::UnterminatedPlaceholder { + start: 0 + } + )); + } + + #[test] + fn rejects_invalid_identifier() { + let err = ErrorTemplate::parse("{invalid-name}").expect_err("should fail"); + assert!(matches!(err, TemplateError::InvalidIdentifier { span } if span == (0..14))); + } + + #[test] + fn rejects_unknown_formatter() { + let err = ErrorTemplate::parse("{value:%}").expect_err("should fail"); + assert!(matches!(err, TemplateError::InvalidFormatter { span } if span == (0..9))); + } + + #[test] + fn display_with_resolves_placeholders() { + let template = ErrorTemplate::parse("{code}: {message}").expect("parse"); + let code = 418; + let message = "I'm a teapot"; + + let rendered = format!( + "{}", + template.display_with(|placeholder, f| match placeholder.identifier() { + TemplateIdentifier::Named("code") => write!(f, "{}", code), + TemplateIdentifier::Named("message") => f.write_str(message), + other => panic!("unexpected placeholder: {:?}", other) + }) + ); + + assert_eq!(rendered, "418: I'm a teapot"); + } +} diff --git a/masterror-template/src/template/parser.rs b/masterror-template/src/template/parser.rs new file mode 100644 index 0000000..d1b52e0 --- /dev/null +++ b/masterror-template/src/template/parser.rs @@ -0,0 +1,607 @@ +use core::ops::Range; + +use super::{ + TemplateError, TemplateFormatter, TemplateFormatterKind, TemplateIdentifier, + TemplatePlaceholder, TemplateSegment +}; + +pub fn parse_template<'a>(source: &'a str) -> Result>, TemplateError> { + let mut segments = Vec::new(); + let mut iter = source.char_indices().peekable(); + let mut literal_start = 0usize; + let mut implicit_counter = 0usize; + + while let Some((index, ch)) = iter.next() { + match ch { + '{' => { + if matches!(iter.peek(), Some(&(_, '{'))) { + if index > literal_start { + segments.push(TemplateSegment::Literal(&source[literal_start..index])); + } + + segments.push(TemplateSegment::Literal( + &source[index..index + ch.len_utf8()] + )); + + if let Some((_, escaped)) = iter.next() { + literal_start = index + ch.len_utf8() + escaped.len_utf8(); + } else { + return Err(TemplateError::UnterminatedPlaceholder { + start: index + }); + } + continue; + } + + if index > literal_start { + segments.push(TemplateSegment::Literal(&source[literal_start..index])); + } + + let parsed = parse_placeholder(source, index, &mut implicit_counter)?; + segments.push(TemplateSegment::Placeholder(parsed.placeholder)); + + literal_start = parsed.after; + while matches!(iter.peek(), Some(&(next_index, _)) if next_index < parsed.after) { + iter.next(); + } + } + '}' => { + if matches!(iter.peek(), Some(&(_, '}'))) { + if index > literal_start { + segments.push(TemplateSegment::Literal(&source[literal_start..index])); + } + + segments.push(TemplateSegment::Literal( + &source[index..index + ch.len_utf8()] + )); + + if let Some((_, escaped)) = iter.next() { + literal_start = index + ch.len_utf8() + escaped.len_utf8(); + } else { + return Err(TemplateError::UnterminatedPlaceholder { + start: index + }); + } + continue; + } + + return Err(TemplateError::UnmatchedClosingBrace { + index + }); + } + _ => {} + } + } + + if literal_start < source.len() { + segments.push(TemplateSegment::Literal(&source[literal_start..])); + } + + Ok(segments) +} + +struct ParsedPlaceholder<'a> { + placeholder: TemplatePlaceholder<'a>, + after: usize +} + +fn parse_placeholder<'a>( + source: &'a str, + start: usize, + implicit_counter: &mut usize +) -> Result, TemplateError> { + for (offset, ch) in source[start + 1..].char_indices() { + let absolute = start + 1 + offset; + match ch { + '}' => { + let end = absolute; + let placeholder = build_placeholder(source, start, end, implicit_counter)?; + return Ok(ParsedPlaceholder { + placeholder, + after: end + 1 + }); + } + '{' => { + return Err(TemplateError::NestedPlaceholder { + index: absolute + }); + } + _ => {} + } + } + + Err(TemplateError::UnterminatedPlaceholder { + start + }) +} + +fn build_placeholder<'a>( + source: &'a str, + start: usize, + end: usize, + implicit_counter: &mut usize +) -> Result, TemplateError> { + let span = start..(end + 1); + let body = &source[start + 1..end]; + + if body.is_empty() { + let identifier = next_implicit_identifier(implicit_counter, &span)?; + return Ok(TemplatePlaceholder { + span, + identifier, + formatter: TemplateFormatter::Display { + spec: None + } + }); + } + + let trimmed = body.trim(); + + if trimmed.is_empty() { + return Err(TemplateError::EmptyPlaceholder { + start + }); + } + + let (identifier, formatter) = split_placeholder(trimmed, span.clone(), implicit_counter)?; + + Ok(TemplatePlaceholder { + span, + identifier, + formatter + }) +} + +fn split_placeholder<'a>( + body: &'a str, + span: Range, + implicit_counter: &mut usize +) -> Result<(TemplateIdentifier<'a>, TemplateFormatter), TemplateError> { + let mut parts = body.splitn(2, ':'); + let identifier_text = parts.next().unwrap_or("").trim(); + + let identifier = parse_identifier(identifier_text, span.clone(), implicit_counter)?; + + let formatter = match parts.next().map(str::trim) { + None => TemplateFormatter::Display { + spec: None + }, + Some("") => { + return Err(TemplateError::InvalidFormatter { + span + }); + } + Some(spec) => parse_formatter(spec, span.clone())? + }; + + Ok((identifier, formatter)) +} + +fn parse_formatter(spec: &str, span: Range) -> Result { + parse_formatter_spec(spec).ok_or(TemplateError::InvalidFormatter { + span + }) +} + +pub(super) fn parse_formatter_spec(spec: &str) -> Option { + let trimmed = spec.trim(); + if trimmed.is_empty() { + return None; + } + + if let Some((last_index, ty)) = trimmed.char_indices().next_back() { + if let Some(kind) = TemplateFormatterKind::from_specifier(ty) { + let prefix = &trimmed[..last_index]; + let alternate = detect_alternate_flag(prefix)?; + + return Some(TemplateFormatter::from_kind(kind, alternate)); + } + + if ty.is_ascii_alphabetic() { + return None; + } + } + + if !display_allows_hash(trimmed) { + return None; + } + + if trimmed.chars().any(|ch| matches!(ch, '%' | '{' | '}')) { + return None; + } + + Some(TemplateFormatter::Display { + spec: Some(trimmed.to_owned().into_boxed_str()) + }) +} + +fn detect_alternate_flag(prefix: &str) -> Option { + let mut rest = prefix; + + if rest.len() >= 2 { + let mut iter = rest.char_indices(); + if let (Some((_, _)), Some((second_index, second))) = (iter.next(), iter.next()) + && matches!(second, '<' | '>' | '^' | '=') + { + let skip = second_index + second.len_utf8(); + rest = &rest[skip..]; + } + } + + if let Some(first) = rest.chars().next() + && matches!(first, '<' | '>' | '^' | '=') + { + rest = &rest[first.len_utf8()..]; + } + + loop { + let mut chars = rest.chars(); + let Some(ch) = chars.next() else { + return Some(false); + }; + + match ch { + '+' | '-' | ' ' => { + rest = &rest[ch.len_utf8()..]; + } + '#' => { + rest = &rest[ch.len_utf8()..]; + if rest.chars().any(|value| value == '#') { + return None; + } + return Some(true); + } + _ => return Some(false) + } + } +} + +fn display_allows_hash(spec: &str) -> bool { + match spec.find('#') { + None => true, + Some(0) => { + let mut chars = spec.chars(); + let _ = chars.next(); + let Some(align) = chars.next() else { + return false; + }; + + if !matches!(align, '<' | '>' | '^' | '=') { + return false; + } + + chars.all(|ch| ch != '#') + } + Some(_) => false + } +} + +fn parse_identifier<'a>( + text: &'a str, + span: Range, + implicit_counter: &mut usize +) -> Result, TemplateError> { + if text.is_empty() { + return next_implicit_identifier(implicit_counter, &span); + } + + if text.chars().all(|ch| ch.is_ascii_digit()) { + let value = text + .parse::() + .map_err(|_| TemplateError::InvalidIndex { + span: span.clone() + })?; + return Ok(TemplateIdentifier::Positional(value)); + } + + if text + .chars() + .all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) + { + return Ok(TemplateIdentifier::Named(text)); + } + + Err(TemplateError::InvalidIdentifier { + span + }) +} + +fn next_implicit_identifier<'a>( + implicit_counter: &mut usize, + span: &Range +) -> Result, TemplateError> { + let index = *implicit_counter; + *implicit_counter = index + .checked_add(1) + .ok_or_else(|| TemplateError::InvalidIdentifier { + span: span.clone() + })?; + + Ok(TemplateIdentifier::Implicit(index)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_supported_formatter_specs() { + let cases = [ + ( + "{value:?}", + TemplateFormatter::Debug { + alternate: false + } + ), + ( + "{value:#?}", + TemplateFormatter::Debug { + alternate: true + } + ), + ( + "{value:*>#?}", + TemplateFormatter::Debug { + alternate: true + } + ), + ( + "{value:#>8?}", + TemplateFormatter::Debug { + alternate: false + } + ), + ( + "{value:x}", + TemplateFormatter::LowerHex { + alternate: false + } + ), + ( + "{value:>08x}", + TemplateFormatter::LowerHex { + alternate: false + } + ), + ( + "{value:#x}", + TemplateFormatter::LowerHex { + alternate: true + } + ), + ( + "{value:*<#x}", + TemplateFormatter::LowerHex { + alternate: true + } + ), + ( + "{value:X}", + TemplateFormatter::UpperHex { + alternate: false + } + ), + ( + "{value:*>#X}", + TemplateFormatter::UpperHex { + alternate: true + } + ), + ( + "{value:#X}", + TemplateFormatter::UpperHex { + alternate: true + } + ), + ( + "{value:p}", + TemplateFormatter::Pointer { + alternate: false + } + ), + ( + "{value:>+#18p}", + TemplateFormatter::Pointer { + alternate: true + } + ), + ( + "{value:#p}", + TemplateFormatter::Pointer { + alternate: true + } + ), + ( + "{value:b}", + TemplateFormatter::Binary { + alternate: false + } + ), + ( + "{value:#08b}", + TemplateFormatter::Binary { + alternate: true + } + ), + ( + "{value:#b}", + TemplateFormatter::Binary { + alternate: true + } + ), + ( + "{value:o}", + TemplateFormatter::Octal { + alternate: false + } + ), + ( + "{value:+#o}", + TemplateFormatter::Octal { + alternate: true + } + ), + ( + "{value:#o}", + TemplateFormatter::Octal { + alternate: true + } + ), + ( + "{value:e}", + TemplateFormatter::LowerExp { + alternate: false + } + ), + ( + "{value:#0e}", + TemplateFormatter::LowerExp { + alternate: true + } + ), + ( + "{value:#e}", + TemplateFormatter::LowerExp { + alternate: true + } + ), + ( + "{value:E}", + TemplateFormatter::UpperExp { + alternate: false + } + ), + ( + "{value:#^10E}", + TemplateFormatter::UpperExp { + alternate: false + } + ), + ( + "{value:#E}", + TemplateFormatter::UpperExp { + alternate: true + } + ) + ]; + + for (source, expected_formatter) in &cases { + let segments = parse_template(source).expect("template parsed"); + let placeholder = match segments.first() { + Some(TemplateSegment::Placeholder(placeholder)) => placeholder, + other => panic!("unexpected segments for {source:?}: {other:?}") + }; + + assert_eq!( + placeholder.formatter(), + expected_formatter, + "case: {source}" + ); + } + } + + #[test] + fn rejects_malformed_formatters() { + let cases = [ + "{value:}", + "{value:#}", + "{value:#4}", + "{value:>8q}", + "{value:##x}" + ]; + + for source in &cases { + let err = parse_template(source).expect_err("expected formatter error"); + assert!( + matches!(err, TemplateError::InvalidFormatter { span } if span == (0..source.len())) + ); + } + } + + #[test] + fn parses_display_format_specs() { + let cases = [ + ("{value:>8}", ">8"), + ("{value:.3}", ".3"), + ("{value:*<10}", "*<10"), + ("{value:#>4}", "#>4"), + ("{value:#>+6}", "#>+6") + ]; + + for (source, expected_spec) in cases { + let segments = parse_template(source).expect("template parsed"); + let placeholder = match segments.first() { + Some(TemplateSegment::Placeholder(placeholder)) => placeholder, + other => panic!("unexpected segments for {source:?}: {other:?}") + }; + + let formatter = placeholder.formatter(); + assert!(formatter.display_spec().is_some()); + assert_eq!(formatter.display_spec(), Some(expected_spec)); + } + } + + #[test] + fn parses_empty_braces_as_implicit_display() { + let segments = parse_template("{}").expect("template parsed"); + let placeholder = match segments.first() { + Some(TemplateSegment::Placeholder(placeholder)) => placeholder, + other => panic!("unexpected segments for empty braces: {other:?}") + }; + + assert_eq!(placeholder.identifier(), &TemplateIdentifier::Implicit(0)); + assert_eq!( + placeholder.formatter(), + &TemplateFormatter::Display { + spec: None + } + ); + } + + #[test] + fn increments_implicit_indices_across_placeholders() { + let segments = parse_template("{}, {value}, {:?}, {}").expect("template parsed"); + let placeholders: Vec<_> = segments + .iter() + .filter_map(|segment| match segment { + TemplateSegment::Placeholder(placeholder) => Some(placeholder), + TemplateSegment::Literal(_) => None + }) + .collect(); + + assert_eq!(placeholders.len(), 4); + assert_eq!( + placeholders[0].identifier(), + &TemplateIdentifier::Implicit(0) + ); + assert_eq!( + placeholders[1].identifier(), + &TemplateIdentifier::Named("value") + ); + assert_eq!( + placeholders[2].identifier(), + &TemplateIdentifier::Implicit(1) + ); + assert_eq!( + placeholders[2].formatter(), + &TemplateFormatter::Debug { + alternate: false + } + ); + assert_eq!( + placeholders[3].identifier(), + &TemplateIdentifier::Implicit(2) + ); + } + + #[test] + fn rejects_whitespace_only_placeholders() { + let err = parse_template("{ }").expect_err("should fail"); + assert!(matches!( + err, + TemplateError::EmptyPlaceholder { + start: 0 + } + )); + } +} diff --git a/src/code.rs b/src/code.rs index c975069..8ce7783 100644 --- a/src/code.rs +++ b/src/code.rs @@ -67,264 +67,6 @@ //! } //! ``` -use std::fmt::{self, Display}; +mod app_code; -use serde::{Deserialize, Serialize}; -#[cfg(feature = "openapi")] -use utoipa::ToSchema; - -use crate::kind::AppErrorKind; - -/// Stable machine-readable error code exposed to clients. -/// -/// Values are serialized as **SCREAMING_SNAKE_CASE** strings (e.g., -/// `"NOT_FOUND"`). This type is part of the public wire contract. -/// -/// Design rules: -/// - Keep the set small and meaningful. -/// - Prefer adding new variants over overloading existing ones. -/// - Do not encode private/internal details in codes. -#[cfg_attr(feature = "openapi", derive(ToSchema))] -#[non_exhaustive] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum AppCode { - // ───────────── 4xx family (client-visible categories) ───────────── - /// Resource does not exist or is not visible to the caller. - /// - /// Typically mapped to HTTP **404 Not Found**. - NotFound, - - /// Input failed validation (shape, constraints, business rules). - /// - /// Typically mapped to HTTP **422 Unprocessable Entity**. - Validation, - - /// State conflict with an existing resource or concurrent update. - /// - /// Typically mapped to HTTP **409 Conflict**. - Conflict, - - /// Authentication required or failed (missing/invalid credentials). - /// - /// Typically mapped to HTTP **401 Unauthorized**. - Unauthorized, - - /// Authenticated but not allowed to perform the operation. - /// - /// Typically mapped to HTTP **403 Forbidden**. - Forbidden, - - /// Operation is not implemented or not supported by this deployment. - /// - /// Typically mapped to HTTP **501 Not Implemented**. - NotImplemented, - - /// Malformed request or missing required parameters. - /// - /// Typically mapped to HTTP **400 Bad Request**. - BadRequest, - - /// Client exceeded rate limits or quota. - /// - /// Typically mapped to HTTP **429 Too Many Requests**. - RateLimited, - - /// Telegram authentication flow failed (signature, timestamp, or payload). - /// - /// Typically mapped to HTTP **401 Unauthorized**. - TelegramAuth, - - /// Provided JWT is invalid (expired, malformed, wrong signature/claims). - /// - /// Typically mapped to HTTP **401 Unauthorized**. - InvalidJwt, - - // ───────────── 5xx family (server/infra categories) ───────────── - /// Unexpected server-side failure not captured by more specific kinds. - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Internal, - - /// Database-related failure (query, connection, migration, etc.). - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Database, - - /// Generic service-layer failure (business logic or orchestration). - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Service, - - /// Configuration error (missing/invalid environment or runtime config). - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Config, - - /// Failure in the Turnkey subsystem/integration. - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Turnkey, - - /// Operation did not complete within the allotted time. - /// - /// Typically mapped to HTTP **504 Gateway Timeout**. - Timeout, - - /// Network-level error (DNS, connect, TLS, request build). - /// - /// Typically mapped to HTTP **503 Service Unavailable**. - Network, - - /// External dependency is unavailable or degraded (cache, broker, - /// third-party). - /// - /// Typically mapped to HTTP **503 Service Unavailable**. - DependencyUnavailable, - - /// Failed to serialize data (encode). - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Serialization, - - /// Failed to deserialize data (decode). - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Deserialization, - - /// Upstream API returned an error or protocol-level failure. - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - ExternalApi, - - /// Queue processing failure (publish/consume/ack). - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Queue, - - /// Cache subsystem failure (read/write/encoding). - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Cache -} - -impl AppCode { - /// Get the canonical string form of this code (SCREAMING_SNAKE_CASE). - /// - /// This is equivalent to how the code is serialized to JSON. - pub const fn as_str(&self) -> &'static str { - match self { - // 4xx - AppCode::NotFound => "NOT_FOUND", - AppCode::Validation => "VALIDATION", - AppCode::Conflict => "CONFLICT", - AppCode::Unauthorized => "UNAUTHORIZED", - AppCode::Forbidden => "FORBIDDEN", - AppCode::NotImplemented => "NOT_IMPLEMENTED", - AppCode::BadRequest => "BAD_REQUEST", - AppCode::RateLimited => "RATE_LIMITED", - AppCode::TelegramAuth => "TELEGRAM_AUTH", - AppCode::InvalidJwt => "INVALID_JWT", - - // 5xx - AppCode::Internal => "INTERNAL", - AppCode::Database => "DATABASE", - AppCode::Service => "SERVICE", - AppCode::Config => "CONFIG", - AppCode::Turnkey => "TURNKEY", - AppCode::Timeout => "TIMEOUT", - AppCode::Network => "NETWORK", - AppCode::DependencyUnavailable => "DEPENDENCY_UNAVAILABLE", - AppCode::Serialization => "SERIALIZATION", - AppCode::Deserialization => "DESERIALIZATION", - AppCode::ExternalApi => "EXTERNAL_API", - AppCode::Queue => "QUEUE", - AppCode::Cache => "CACHE" - } - } -} - -impl Display for AppCode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - // Stable human/machine readable form matching JSON representation. - f.write_str(self.as_str()) - } -} - -impl From for AppCode { - /// Map internal taxonomy (`AppErrorKind`) to public machine code - /// (`AppCode`). - /// - /// The mapping is 1:1 today and intentionally conservative. - fn from(kind: AppErrorKind) -> Self { - match kind { - // 4xx - AppErrorKind::NotFound => Self::NotFound, - AppErrorKind::Validation => Self::Validation, - AppErrorKind::Conflict => Self::Conflict, - AppErrorKind::Unauthorized => Self::Unauthorized, - AppErrorKind::Forbidden => Self::Forbidden, - AppErrorKind::NotImplemented => Self::NotImplemented, - AppErrorKind::BadRequest => Self::BadRequest, - AppErrorKind::RateLimited => Self::RateLimited, - AppErrorKind::TelegramAuth => Self::TelegramAuth, - AppErrorKind::InvalidJwt => Self::InvalidJwt, - - // 5xx - AppErrorKind::Internal => Self::Internal, - AppErrorKind::Database => Self::Database, - AppErrorKind::Service => Self::Service, - AppErrorKind::Config => Self::Config, - AppErrorKind::Turnkey => Self::Turnkey, - AppErrorKind::Timeout => Self::Timeout, - AppErrorKind::Network => Self::Network, - AppErrorKind::DependencyUnavailable => Self::DependencyUnavailable, - AppErrorKind::Serialization => Self::Serialization, - AppErrorKind::Deserialization => Self::Deserialization, - AppErrorKind::ExternalApi => Self::ExternalApi, - AppErrorKind::Queue => Self::Queue, - AppErrorKind::Cache => Self::Cache - } - } -} - -#[cfg(test)] -mod tests { - use super::{AppCode, AppErrorKind}; - - #[test] - fn as_str_matches_json_serde_names() { - assert_eq!(AppCode::NotFound.as_str(), "NOT_FOUND"); - assert_eq!(AppCode::RateLimited.as_str(), "RATE_LIMITED"); - assert_eq!( - AppCode::DependencyUnavailable.as_str(), - "DEPENDENCY_UNAVAILABLE" - ); - } - - #[test] - fn mapping_from_kind_is_stable() { - // Spot checks to guard against accidental remaps. - assert!(matches!( - AppCode::from(AppErrorKind::NotFound), - AppCode::NotFound - )); - assert!(matches!( - AppCode::from(AppErrorKind::Validation), - AppCode::Validation - )); - assert!(matches!( - AppCode::from(AppErrorKind::Internal), - AppCode::Internal - )); - assert!(matches!( - AppCode::from(AppErrorKind::Timeout), - AppCode::Timeout - )); - } - - #[test] - fn display_uses_screaming_snake_case() { - assert_eq!(AppCode::BadRequest.to_string(), "BAD_REQUEST"); - } -} +pub use app_code::AppCode; diff --git a/src/code/app_code.rs b/src/code/app_code.rs new file mode 100644 index 0000000..11bf384 --- /dev/null +++ b/src/code/app_code.rs @@ -0,0 +1,261 @@ +use std::fmt::{self, Display}; + +use serde::{Deserialize, Serialize}; +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + +use crate::kind::AppErrorKind; + +/// Stable machine-readable error code exposed to clients. +/// +/// Values are serialized as **SCREAMING_SNAKE_CASE** strings (e.g., +/// `"NOT_FOUND"`). This type is part of the public wire contract. +/// +/// Design rules: +/// - Keep the set small and meaningful. +/// - Prefer adding new variants over overloading existing ones. +/// - Do not encode private/internal details in codes. +#[cfg_attr(feature = "openapi", derive(ToSchema))] +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AppCode { + // ───────────── 4xx family (client-visible categories) ───────────── + /// Resource does not exist or is not visible to the caller. + /// + /// Typically mapped to HTTP **404 Not Found**. + NotFound, + + /// Input failed validation (shape, constraints, business rules). + /// + /// Typically mapped to HTTP **422 Unprocessable Entity**. + Validation, + + /// State conflict with an existing resource or concurrent update. + /// + /// Typically mapped to HTTP **409 Conflict**. + Conflict, + + /// Authentication required or failed (missing/invalid credentials). + /// + /// Typically mapped to HTTP **401 Unauthorized**. + Unauthorized, + + /// Authenticated but not allowed to perform the operation. + /// + /// Typically mapped to HTTP **403 Forbidden**. + Forbidden, + + /// Operation is not implemented or not supported by this deployment. + /// + /// Typically mapped to HTTP **501 Not Implemented**. + NotImplemented, + + /// Malformed request or missing required parameters. + /// + /// Typically mapped to HTTP **400 Bad Request**. + BadRequest, + + /// Client exceeded rate limits or quota. + /// + /// Typically mapped to HTTP **429 Too Many Requests**. + RateLimited, + + /// Telegram authentication flow failed (signature, timestamp, or payload). + /// + /// Typically mapped to HTTP **401 Unauthorized**. + TelegramAuth, + + /// Provided JWT is invalid (expired, malformed, wrong signature/claims). + /// + /// Typically mapped to HTTP **401 Unauthorized**. + InvalidJwt, + + // ───────────── 5xx family (server/infra categories) ───────────── + /// Unexpected server-side failure not captured by more specific kinds. + /// + /// Typically mapped to HTTP **500 Internal Server Error**. + Internal, + + /// Database-related failure (query, connection, migration, etc.). + /// + /// Typically mapped to HTTP **500 Internal Server Error**. + Database, + + /// Generic service-layer failure (business logic or orchestration). + /// + /// Typically mapped to HTTP **500 Internal Server Error**. + Service, + + /// Configuration error (missing/invalid environment or runtime config). + /// + /// Typically mapped to HTTP **500 Internal Server Error**. + Config, + + /// Failure in the Turnkey subsystem/integration. + /// + /// Typically mapped to HTTP **500 Internal Server Error**. + Turnkey, + + /// Operation did not complete within the allotted time. + /// + /// Typically mapped to HTTP **504 Gateway Timeout**. + Timeout, + + /// Network-level error (DNS, connect, TLS, request build). + /// + /// Typically mapped to HTTP **503 Service Unavailable**. + Network, + + /// External dependency is unavailable or degraded (cache, broker, + /// third-party). + /// + /// Typically mapped to HTTP **503 Service Unavailable**. + DependencyUnavailable, + + /// Failed to serialize data (encode). + /// + /// Typically mapped to HTTP **500 Internal Server Error**. + Serialization, + + /// Failed to deserialize data (decode). + /// + /// Typically mapped to HTTP **500 Internal Server Error**. + Deserialization, + + /// Upstream API returned an error or protocol-level failure. + /// + /// Typically mapped to HTTP **500 Internal Server Error**. + ExternalApi, + + /// Queue processing failure (publish/consume/ack). + /// + /// Typically mapped to HTTP **500 Internal Server Error**. + Queue, + + /// Cache subsystem failure (read/write/encoding). + /// + /// Typically mapped to HTTP **500 Internal Server Error**. + Cache +} + +impl AppCode { + /// Get the canonical string form of this code (SCREAMING_SNAKE_CASE). + /// + /// This is equivalent to how the code is serialized to JSON. + pub const fn as_str(&self) -> &'static str { + match self { + // 4xx + AppCode::NotFound => "NOT_FOUND", + AppCode::Validation => "VALIDATION", + AppCode::Conflict => "CONFLICT", + AppCode::Unauthorized => "UNAUTHORIZED", + AppCode::Forbidden => "FORBIDDEN", + AppCode::NotImplemented => "NOT_IMPLEMENTED", + AppCode::BadRequest => "BAD_REQUEST", + AppCode::RateLimited => "RATE_LIMITED", + AppCode::TelegramAuth => "TELEGRAM_AUTH", + AppCode::InvalidJwt => "INVALID_JWT", + + // 5xx + AppCode::Internal => "INTERNAL", + AppCode::Database => "DATABASE", + AppCode::Service => "SERVICE", + AppCode::Config => "CONFIG", + AppCode::Turnkey => "TURNKEY", + AppCode::Timeout => "TIMEOUT", + AppCode::Network => "NETWORK", + AppCode::DependencyUnavailable => "DEPENDENCY_UNAVAILABLE", + AppCode::Serialization => "SERIALIZATION", + AppCode::Deserialization => "DESERIALIZATION", + AppCode::ExternalApi => "EXTERNAL_API", + AppCode::Queue => "QUEUE", + AppCode::Cache => "CACHE" + } + } +} + +impl Display for AppCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Stable human/machine readable form matching JSON representation. + f.write_str(self.as_str()) + } +} + +impl From for AppCode { + /// Map internal taxonomy (`AppErrorKind`) to public machine code + /// (`AppCode`). + /// + /// The mapping is 1:1 today and intentionally conservative. + fn from(kind: AppErrorKind) -> Self { + match kind { + // 4xx + AppErrorKind::NotFound => Self::NotFound, + AppErrorKind::Validation => Self::Validation, + AppErrorKind::Conflict => Self::Conflict, + AppErrorKind::Unauthorized => Self::Unauthorized, + AppErrorKind::Forbidden => Self::Forbidden, + AppErrorKind::NotImplemented => Self::NotImplemented, + AppErrorKind::BadRequest => Self::BadRequest, + AppErrorKind::RateLimited => Self::RateLimited, + AppErrorKind::TelegramAuth => Self::TelegramAuth, + AppErrorKind::InvalidJwt => Self::InvalidJwt, + + // 5xx + AppErrorKind::Internal => Self::Internal, + AppErrorKind::Database => Self::Database, + AppErrorKind::Service => Self::Service, + AppErrorKind::Config => Self::Config, + AppErrorKind::Turnkey => Self::Turnkey, + AppErrorKind::Timeout => Self::Timeout, + AppErrorKind::Network => Self::Network, + AppErrorKind::DependencyUnavailable => Self::DependencyUnavailable, + AppErrorKind::Serialization => Self::Serialization, + AppErrorKind::Deserialization => Self::Deserialization, + AppErrorKind::ExternalApi => Self::ExternalApi, + AppErrorKind::Queue => Self::Queue, + AppErrorKind::Cache => Self::Cache + } + } +} + +#[cfg(test)] +mod tests { + use super::{AppCode, AppErrorKind}; + + #[test] + fn as_str_matches_json_serde_names() { + assert_eq!(AppCode::NotFound.as_str(), "NOT_FOUND"); + assert_eq!(AppCode::RateLimited.as_str(), "RATE_LIMITED"); + assert_eq!( + AppCode::DependencyUnavailable.as_str(), + "DEPENDENCY_UNAVAILABLE" + ); + } + + #[test] + fn mapping_from_kind_is_stable() { + // Spot checks to guard against accidental remaps. + assert!(matches!( + AppCode::from(AppErrorKind::NotFound), + AppCode::NotFound + )); + assert!(matches!( + AppCode::from(AppErrorKind::Validation), + AppCode::Validation + )); + assert!(matches!( + AppCode::from(AppErrorKind::Internal), + AppCode::Internal + )); + assert!(matches!( + AppCode::from(AppErrorKind::Timeout), + AppCode::Timeout + )); + } + + #[test] + fn display_uses_screaming_snake_case() { + assert_eq!(AppCode::BadRequest.to_string(), "BAD_REQUEST"); + } +} diff --git a/src/convert/sqlx.rs b/src/convert/sqlx.rs index 8d0d726..df9be12 100644 --- a/src/convert/sqlx.rs +++ b/src/convert/sqlx.rs @@ -1,22 +1,25 @@ -//! Conversions from [`sqlx`] errors into [`AppError`]. +//! Conversions from `sqlx` errors into `AppError`. //! -//! Enabled with the `sqlx` feature flag. +//! Feature flags: +//! - `sqlx` → maps `sqlx_core::error::Error` +//! - `sqlx-migrate` → maps `sqlx::migrate::MigrateError` //! //! ## Mappings //! -//! - [`sqlx::Error::RowNotFound`] → `AppErrorKind::NotFound` -//! - Any other [`sqlx::Error`] → `AppErrorKind::Database` -//! - [`sqlx::migrate::MigrateError`] → `AppErrorKind::Database` +//! - `sqlx_core::error::Error::RowNotFound` → `AppErrorKind::NotFound` +//! - any other `sqlx_core::error::Error` → `AppErrorKind::Database` +//! - `sqlx::migrate::MigrateError` → `AppErrorKind::Database` //! -//! The original error message is preserved in the `AppError.message` for +//! The original error message is preserved in `AppError.message` for //! observability. SQL driver–specific details are **not** mapped to separate //! kinds to keep the taxonomy stable. //! //! ## Example //! //! ```rust,ignore +//! // Requires: features = ["sqlx"] //! use masterror::{AppError, AppErrorKind}; -//! use sqlx::Error as SqlxError; +//! use sqlx_core::error::Error as SqlxError; //! //! fn handle_db_error(e: SqlxError) -> AppError { //! e.into() @@ -27,13 +30,15 @@ //! assert!(matches!(err.kind, AppErrorKind::NotFound)); //! ``` +#[cfg(feature = "sqlx-migrate")] +use sqlx::migrate::MigrateError; #[cfg(feature = "sqlx")] -use sqlx::{Error as SqlxError, migrate::MigrateError}; +use sqlx_core::error::Error as SqlxError; -#[cfg(feature = "sqlx")] +#[cfg(any(feature = "sqlx", feature = "sqlx-migrate"))] use crate::AppError; -/// Map a [`sqlx::Error`] into an [`AppError`]. +/// Map a `sqlx_core::error::Error` into an `AppError`. /// /// - `RowNotFound` → `AppErrorKind::NotFound` /// - all other cases → `AppErrorKind::Database` @@ -50,12 +55,12 @@ impl From for AppError { } } -/// Map a [`sqlx::migrate::MigrateError`] into an [`AppError`]. +/// Map a `sqlx::migrate::MigrateError` into an `AppError`. /// /// All migration errors are considered `AppErrorKind::Database`. /// The error string is preserved in `message`. -#[cfg(feature = "sqlx")] -#[cfg_attr(docsrs, doc(cfg(feature = "sqlx")))] +#[cfg(feature = "sqlx-migrate")] +#[cfg_attr(docsrs, doc(cfg(feature = "sqlx-migrate")))] impl From for AppError { fn from(err: MigrateError) -> Self { AppError::database(Some(err.to_string())) @@ -63,7 +68,7 @@ impl From for AppError { } #[cfg(all(test, feature = "sqlx"))] -mod tests { +mod tests_sqlx { use std::io; use super::*; diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..9b793dd --- /dev/null +++ b/src/error.rs @@ -0,0 +1,112 @@ +//! Utilities for building custom error derive infrastructure. +//! +//! This module exposes lower-level building blocks that will eventually power +//! a native replacement for the `thiserror` derive. The initial goal is to +//! parse and validate display templates (`#[error("...")]`) in a reusable +//! and well-tested manner so that future procedural macros can focus on +//! generating code. +//! +//! The API is intentionally low level. It makes no assumptions about how the +//! parsed data is going to be used and instead provides precise spans and +//! formatting metadata that higher-level code can rely on. +//! +//! ## Formatter traits +//! +//! `TemplateFormatter` enumerates the formatting modes supported by +//! `#[error("...")]` placeholders. It mirrors the formatter detection logic in +//! `thiserror` v2 so migrating existing derives is a drop-in change. +//! `TemplateFormatter::is_alternate()` surfaces the `#` flag, and +//! [`TemplateFormatterKind`](crate::error::template::TemplateFormatterKind) +//! describes the underlying `core::fmt` trait with helpers like +//! `specifier()`/`supports_alternate()` for programmatic inspection. +//! +//! ```rust +//! use core::ptr; +//! +//! use masterror::Error; +//! +//! #[derive(Debug, Error)] +//! #[error( +//! "debug={payload:?}, hex={id:#x}, ptr={ptr:p}, bin={mask:#b}, \ +//! oct={mask:o}, lower={ratio:e}, upper={ratio:E}" +//! )] +//! struct FormatterShowcase { +//! id: u32, +//! payload: String, +//! ptr: *const u8, +//! mask: u8, +//! ratio: f32 +//! } +//! +//! let err = FormatterShowcase { +//! id: 0x2a, +//! payload: "hello".into(), +//! ptr: ptr::null(), +//! mask: 0b1010_0001, +//! ratio: 0.15625 +//! }; +//! +//! let rendered = err.to_string(); +//! assert!(rendered.contains("debug=\"hello\"")); +//! assert!(rendered.contains("hex=0x2a")); +//! assert!(rendered.contains("ptr=0x0")); +//! assert!(rendered.contains("bin=0b10100001")); +//! assert!(rendered.contains("oct=241")); +//! assert!(rendered.contains("lower=1.5625e-1")); +//! assert!(rendered.contains("upper=1.5625E-1")); +//! ``` +//! +//! Programmatic consumers can inspect placeholders and their requested +//! formatters via [`ErrorTemplate`](crate::error::template::ErrorTemplate): +//! +//! ```rust +//! use masterror::error::template::{ErrorTemplate, TemplateFormatter, TemplateFormatterKind}; +//! +//! let template = ErrorTemplate::parse("{code:#x} → {payload:?}").expect("parse"); +//! let mut placeholders = template.placeholders(); +//! +//! let code = placeholders.next().expect("code placeholder"); +//! let code_formatter = code.formatter(); +//! assert!(matches!( +//! code_formatter, +//! TemplateFormatter::LowerHex { +//! alternate: true +//! } +//! )); +//! let code_kind = code_formatter.kind(); +//! assert_eq!(code_kind, TemplateFormatterKind::LowerHex); +//! assert!(code_formatter.is_alternate()); +//! assert_eq!(code_kind.specifier(), Some('x')); +//! assert!(code_kind.supports_alternate()); +//! let lowered = TemplateFormatter::from_kind(code_kind, false); +//! assert!(matches!( +//! lowered, +//! TemplateFormatter::LowerHex { +//! alternate: false +//! } +//! )); +//! +//! let payload = placeholders.next().expect("payload placeholder"); +//! let payload_formatter = payload.formatter(); +//! assert_eq!( +//! payload_formatter, +//! &TemplateFormatter::Debug { +//! alternate: false +//! } +//! ); +//! let payload_kind = payload_formatter.kind(); +//! assert_eq!(payload_kind, TemplateFormatterKind::Debug); +//! assert_eq!(payload_kind.specifier(), Some('?')); +//! assert!(payload_kind.supports_alternate()); +//! let pretty_debug = TemplateFormatter::from_kind(payload_kind, true); +//! assert!(matches!( +//! pretty_debug, +//! TemplateFormatter::Debug { +//! alternate: true +//! } +//! )); +//! assert!(pretty_debug.is_alternate()); +//! ``` + +/// Parser and formatter helpers for `#[error("...")]` templates. +pub mod template; diff --git a/src/error/template.rs b/src/error/template.rs new file mode 100644 index 0000000..e71e86c --- /dev/null +++ b/src/error/template.rs @@ -0,0 +1,7 @@ +//! Parser and formatter helpers for `#[error("...")]` attributes. +//! +//! This module re-exports the shared helpers from the internal +//! `masterror_template` crate so that downstream code can continue using the +//! stable path `masterror::error::template`. + +pub use masterror_template::template::*; diff --git a/src/frontend.rs b/src/frontend.rs index b1b334f..b79e0c0 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -1,5 +1,3 @@ -#![allow(unused_variables)] - //! Browser/WASM helpers for converting application errors into JavaScript //! values. //! @@ -35,272 +33,11 @@ //! # } //! ``` -#[cfg(target_arch = "wasm32")] -use js_sys::{Function, Reflect}; -#[cfg(target_arch = "wasm32")] -use serde_wasm_bindgen::to_value; -#[cfg(target_arch = "wasm32")] -use wasm_bindgen::JsCast; -use wasm_bindgen::JsValue; - -use crate::{AppError, AppResult, Error, ErrorResponse}; - -/// Error returned when emitting to the browser console fails or is unsupported. -#[derive(Debug, Error, PartialEq, Eq)] -#[cfg_attr(docsrs, doc(cfg(feature = "frontend")))] -pub enum BrowserConsoleError { - /// Failed to serialize the payload into [`JsValue`]. - #[error("failed to serialize payload for browser console: {message}")] - Serialization { - /// Human-readable description of the serialization failure. - message: String - }, - /// The global `console` object is unavailable or could not be accessed. - #[error("browser console object is not available: {message}")] - ConsoleUnavailable { - /// Additional context explaining the failure. - message: String - }, - /// The `console.error` function is missing or not accessible. - #[error("failed to access browser console `error`: {message}")] - ConsoleErrorUnavailable { - /// Additional context explaining the failure. - message: String - }, - /// The retrieved `console.error` value is not callable. - #[error("browser console `error` method is not callable")] - ConsoleMethodNotCallable, - /// Invoking `console.error` returned an error. - #[error("failed to invoke browser console `error`: {message}")] - ConsoleInvocation { - /// Textual representation of the JavaScript exception. - message: String - }, - /// Logging is not supported on the current compilation target. - #[error("browser console logging is not supported on this target")] - UnsupportedTarget -} - -impl BrowserConsoleError { - /// Returns the contextual message associated with the error, when - /// available. - /// - /// This is primarily useful for surfacing browser-provided diagnostics in - /// higher-level logs or telemetry. - /// - /// # Examples - /// - /// ``` - /// # #[cfg(feature = "frontend")] - /// # { - /// use masterror::frontend::BrowserConsoleError; - /// - /// let err = BrowserConsoleError::ConsoleUnavailable { - /// message: "console missing".to_owned() - /// }; - /// assert_eq!(err.context(), Some("console missing")); - /// - /// let err = BrowserConsoleError::ConsoleMethodNotCallable; - /// assert_eq!(err.context(), None); - /// # } - /// ``` - pub fn context(&self) -> Option<&str> { - match self { - Self::Serialization { - message - } - | Self::ConsoleUnavailable { - message - } - | Self::ConsoleErrorUnavailable { - message - } - | Self::ConsoleInvocation { - message - } => Some(message.as_str()), - Self::ConsoleMethodNotCallable | Self::UnsupportedTarget => None - } - } -} - -/// Extensions for serializing errors to JavaScript and logging to the browser -/// console. -#[cfg_attr(docsrs, doc(cfg(feature = "frontend")))] -pub trait BrowserConsoleExt { - /// Convert the error into a [`JsValue`] suitable for passing to JavaScript. - fn to_js_value(&self) -> AppResult; - - /// Emit the error as a structured payload via `console.error`. - /// - /// On non-WASM targets this returns - /// [`BrowserConsoleError::UnsupportedTarget`]. - fn log_to_browser_console(&self) -> AppResult<(), BrowserConsoleError> { - let payload = self.to_js_value()?; - log_js_value(&payload) - } -} - -impl BrowserConsoleExt for ErrorResponse { - fn to_js_value(&self) -> AppResult { - #[cfg(target_arch = "wasm32")] - { - to_value(self).map_err(|err| BrowserConsoleError::Serialization { - message: err.to_string() - }) - } - - #[cfg(not(target_arch = "wasm32"))] - { - Err(BrowserConsoleError::UnsupportedTarget) - } - } -} - -impl BrowserConsoleExt for AppError { - fn to_js_value(&self) -> AppResult { - #[cfg(target_arch = "wasm32")] - { - let response: ErrorResponse = self.into(); - response.to_js_value() - } - - #[cfg(not(target_arch = "wasm32"))] - { - Err(BrowserConsoleError::UnsupportedTarget) - } - } -} - -#[cfg(target_arch = "wasm32")] -fn log_js_value(value: &JsValue) -> AppResult<(), BrowserConsoleError> { - let global = js_sys::global(); - let console = Reflect::get(&global, &JsValue::from_str("console")).map_err(|err| { - BrowserConsoleError::ConsoleUnavailable { - message: format_js_value(&err) - } - })?; - - if console.is_undefined() || console.is_null() { - return Err(BrowserConsoleError::ConsoleUnavailable { - message: "console is undefined".into() - }); - } - - let error_fn = Reflect::get(&console, &JsValue::from_str("error")).map_err(|err| { - BrowserConsoleError::ConsoleErrorUnavailable { - message: format_js_value(&err) - } - })?; - - if error_fn.is_undefined() || error_fn.is_null() { - return Err(BrowserConsoleError::ConsoleErrorUnavailable { - message: "console.error is undefined".into() - }); - } - - let func = error_fn - .dyn_into::() - .map_err(|_| BrowserConsoleError::ConsoleMethodNotCallable)?; +mod browser_console_error; +mod browser_console_ext; - func.call1(&console, value) - .map_err(|err| BrowserConsoleError::ConsoleInvocation { - message: format_js_value(&err) - })?; - - Ok(()) -} - -#[cfg(not(target_arch = "wasm32"))] -fn log_js_value(_value: &JsValue) -> AppResult<(), BrowserConsoleError> { - Err(BrowserConsoleError::UnsupportedTarget) -} - -#[cfg(target_arch = "wasm32")] -fn format_js_value(value: &JsValue) -> String { - value.as_string().unwrap_or_else(|| format!("{value:?}")) -} +pub use browser_console_error::BrowserConsoleError; +pub use browser_console_ext::BrowserConsoleExt; #[cfg(test)] -mod tests { - use super::*; - use crate::AppCode; - - #[test] - fn context_returns_optional_message() { - let serialization = BrowserConsoleError::Serialization { - message: "encode failed".to_owned() - }; - assert_eq!(serialization.context(), Some("encode failed")); - - let invocation = BrowserConsoleError::ConsoleInvocation { - message: "js error".to_owned() - }; - assert_eq!(invocation.context(), Some("js error")); - - assert_eq!( - BrowserConsoleError::ConsoleMethodNotCallable.context(), - None - ); - assert_eq!(BrowserConsoleError::UnsupportedTarget.context(), None); - } - - #[cfg(not(target_arch = "wasm32"))] - mod native { - use super::*; - - #[test] - fn to_js_value_is_unsupported_on_native_targets() { - let response = - ErrorResponse::new(404, AppCode::NotFound, "missing user").expect("status"); - assert!(matches!( - response.to_js_value(), - Err(BrowserConsoleError::UnsupportedTarget) - )); - - let err = AppError::conflict("already exists"); - assert!(matches!( - err.to_js_value(), - Err(BrowserConsoleError::UnsupportedTarget) - )); - } - - #[test] - fn console_logging_returns_unsupported_on_native_targets() { - let err = AppError::internal("boom"); - let result = err.log_to_browser_console(); - assert!(matches!( - result, - Err(BrowserConsoleError::UnsupportedTarget) - )); - } - } - - #[cfg(target_arch = "wasm32")] - mod wasm { - use serde_wasm_bindgen::from_value; - - use super::*; - use crate::AppErrorKind; - - #[test] - fn error_response_to_js_value_roundtrip() { - let response = - ErrorResponse::new(404, AppCode::NotFound, "missing user").expect("status"); - let js = response.to_js_value().expect("serialize"); - let decoded: ErrorResponse = from_value(js).expect("decode"); - assert_eq!(decoded.status, 404); - assert_eq!(decoded.code, AppCode::NotFound); - assert_eq!(decoded.message, "missing user"); - } - - #[test] - fn app_error_to_js_value_roundtrip() { - let err = AppError::conflict("already exists"); - let js = err.to_js_value().expect("serialize"); - let decoded: ErrorResponse = from_value(js).expect("decode"); - assert_eq!(decoded.code, AppCode::Conflict); - assert_eq!(decoded.message, "already exists"); - assert_eq!(decoded.status, AppErrorKind::Conflict.http_status()); - } - } -} +mod tests; diff --git a/src/frontend/browser_console_error.rs b/src/frontend/browser_console_error.rs new file mode 100644 index 0000000..2d648c5 --- /dev/null +++ b/src/frontend/browser_console_error.rs @@ -0,0 +1,79 @@ +use crate::Error; + +/// Error returned when emitting to the browser console fails or is unsupported. +#[derive(Debug, Error, PartialEq, Eq)] +#[cfg_attr(docsrs, doc(cfg(feature = "frontend")))] +pub enum BrowserConsoleError { + /// Failed to serialize the payload into [`wasm_bindgen::JsValue`]. + #[error("failed to serialize payload for browser console: {message}")] + Serialization { + /// Human-readable description of the serialization failure. + message: String + }, + /// The global `console` object is unavailable or could not be accessed. + #[error("browser console object is not available: {message}")] + ConsoleUnavailable { + /// Additional context explaining the failure. + message: String + }, + /// The `console.error` function is missing or not accessible. + #[error("failed to access browser console `error`: {message}")] + ConsoleErrorUnavailable { + /// Additional context explaining the failure. + message: String + }, + /// The retrieved `console.error` value is not callable. + #[error("browser console `error` method is not callable")] + ConsoleMethodNotCallable, + /// Invoking `console.error` returned an error. + #[error("failed to invoke browser console `error`: {message}")] + ConsoleInvocation { + /// Textual representation of the JavaScript exception. + message: String + }, + /// Logging is not supported on the current compilation target. + #[error("browser console logging is not supported on this target")] + UnsupportedTarget +} + +impl BrowserConsoleError { + /// Returns the contextual message associated with the error, when + /// available. + /// + /// This is primarily useful for surfacing browser-provided diagnostics in + /// higher-level logs or telemetry. + /// + /// # Examples + /// + /// ``` + /// # #[cfg(feature = "frontend")] + /// # { + /// use masterror::frontend::BrowserConsoleError; + /// + /// let err = BrowserConsoleError::ConsoleUnavailable { + /// message: "console missing".to_owned() + /// }; + /// assert_eq!(err.context(), Some("console missing")); + /// + /// let err = BrowserConsoleError::ConsoleMethodNotCallable; + /// assert_eq!(err.context(), None); + /// # } + /// ``` + pub fn context(&self) -> Option<&str> { + match self { + Self::Serialization { + message + } + | Self::ConsoleUnavailable { + message + } + | Self::ConsoleErrorUnavailable { + message + } + | Self::ConsoleInvocation { + message + } => Some(message.as_str()), + Self::ConsoleMethodNotCallable | Self::UnsupportedTarget => None + } + } +} diff --git a/src/frontend/browser_console_ext.rs b/src/frontend/browser_console_ext.rs new file mode 100644 index 0000000..f6d4a16 --- /dev/null +++ b/src/frontend/browser_console_ext.rs @@ -0,0 +1,107 @@ +#[cfg(target_arch = "wasm32")] +use js_sys::{Function, Reflect}; +#[cfg(target_arch = "wasm32")] +use serde_wasm_bindgen::to_value; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::JsCast; +use wasm_bindgen::JsValue; + +use super::BrowserConsoleError; +use crate::{AppError, AppResult, ErrorResponse}; + +/// Extensions for serializing errors to JavaScript and logging to the browser +/// console. +#[cfg_attr(docsrs, doc(cfg(feature = "frontend")))] +pub trait BrowserConsoleExt { + /// Convert the error into a [`JsValue`] suitable for passing to JavaScript. + fn to_js_value(&self) -> AppResult; + + /// Emit the error as a structured payload via `console.error`. + /// + /// On non-WASM targets this returns + /// [`BrowserConsoleError::UnsupportedTarget`]. + fn log_to_browser_console(&self) -> AppResult<(), BrowserConsoleError> { + let payload = self.to_js_value()?; + log_js_value(&payload) + } +} + +impl BrowserConsoleExt for ErrorResponse { + fn to_js_value(&self) -> AppResult { + #[cfg(target_arch = "wasm32")] + { + to_value(self).map_err(|err| BrowserConsoleError::Serialization { + message: err.to_string() + }) + } + + #[cfg(not(target_arch = "wasm32"))] + { + Err(BrowserConsoleError::UnsupportedTarget) + } + } +} + +impl BrowserConsoleExt for AppError { + fn to_js_value(&self) -> AppResult { + #[cfg(target_arch = "wasm32")] + { + let response: ErrorResponse = self.into(); + response.to_js_value() + } + + #[cfg(not(target_arch = "wasm32"))] + { + Err(BrowserConsoleError::UnsupportedTarget) + } + } +} + +#[cfg(target_arch = "wasm32")] +fn log_js_value(value: &JsValue) -> AppResult<(), BrowserConsoleError> { + let global = js_sys::global(); + let console = Reflect::get(&global, &JsValue::from_str("console")).map_err(|err| { + BrowserConsoleError::ConsoleUnavailable { + message: format_js_value(&err) + } + })?; + + if console.is_undefined() || console.is_null() { + return Err(BrowserConsoleError::ConsoleUnavailable { + message: "console is undefined".into() + }); + } + + let error_fn = Reflect::get(&console, &JsValue::from_str("error")).map_err(|err| { + BrowserConsoleError::ConsoleErrorUnavailable { + message: format_js_value(&err) + } + })?; + + if error_fn.is_undefined() || error_fn.is_null() { + return Err(BrowserConsoleError::ConsoleErrorUnavailable { + message: "console.error is undefined".into() + }); + } + + let func = error_fn + .dyn_into::() + .map_err(|_| BrowserConsoleError::ConsoleMethodNotCallable)?; + + func.call1(&console, value) + .map_err(|err| BrowserConsoleError::ConsoleInvocation { + message: format_js_value(&err) + })?; + + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn log_js_value(_value: &JsValue) -> AppResult<(), BrowserConsoleError> { + Err(BrowserConsoleError::UnsupportedTarget) +} + +#[cfg(target_arch = "wasm32")] +fn format_js_value(value: &JsValue) -> String { + value.as_string().unwrap_or_else(|| format!("{value:?}")) +} diff --git a/src/frontend/tests.rs b/src/frontend/tests.rs new file mode 100644 index 0000000..893765c --- /dev/null +++ b/src/frontend/tests.rs @@ -0,0 +1,79 @@ +use super::{BrowserConsoleError, BrowserConsoleExt}; +use crate::{AppCode, AppError, ErrorResponse}; + +#[test] +fn context_returns_optional_message() { + let serialization = BrowserConsoleError::Serialization { + message: "encode failed".to_owned() + }; + assert_eq!(serialization.context(), Some("encode failed")); + + let invocation = BrowserConsoleError::ConsoleInvocation { + message: "js error".to_owned() + }; + assert_eq!(invocation.context(), Some("js error")); + + assert_eq!( + BrowserConsoleError::ConsoleMethodNotCallable.context(), + None + ); + assert_eq!(BrowserConsoleError::UnsupportedTarget.context(), None); +} + +#[cfg(not(target_arch = "wasm32"))] +mod native { + use super::*; + + #[test] + fn to_js_value_is_unsupported_on_native_targets() { + let response = ErrorResponse::new(404, AppCode::NotFound, "missing user").expect("status"); + assert!(matches!( + response.to_js_value(), + Err(BrowserConsoleError::UnsupportedTarget) + )); + + let err = AppError::conflict("already exists"); + assert!(matches!( + err.to_js_value(), + Err(BrowserConsoleError::UnsupportedTarget) + )); + } + + #[test] + fn console_logging_returns_unsupported_on_native_targets() { + let err = AppError::internal("boom"); + let result = err.log_to_browser_console(); + assert!(matches!( + result, + Err(BrowserConsoleError::UnsupportedTarget) + )); + } +} + +#[cfg(target_arch = "wasm32")] +mod wasm { + use serde_wasm_bindgen::from_value; + + use super::*; + use crate::AppErrorKind; + + #[test] + fn error_response_to_js_value_roundtrip() { + let response = ErrorResponse::new(404, AppCode::NotFound, "missing user").expect("status"); + let js = response.to_js_value().expect("serialize"); + let decoded: ErrorResponse = from_value(js).expect("decode"); + assert_eq!(decoded.status, 404); + assert_eq!(decoded.code, AppCode::NotFound); + assert_eq!(decoded.message, "missing user"); + } + + #[test] + fn app_error_to_js_value_roundtrip() { + let err = AppError::conflict("already exists"); + let js = err.to_js_value().expect("serialize"); + let decoded: ErrorResponse = from_value(js).expect("decode"); + assert_eq!(decoded.code, AppCode::Conflict); + assert_eq!(decoded.message, "already exists"); + assert_eq!(decoded.status, AppErrorKind::Conflict.http_status()); + } +} diff --git a/src/lib.rs b/src/lib.rs index cbb406a..4e6fceb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -185,7 +185,11 @@ mod app_error; mod code; mod convert; +pub mod error; mod kind; +#[cfg(error_generic_member_access)] +#[doc(hidden)] +pub mod provide; mod response; #[cfg(feature = "frontend")] @@ -202,11 +206,10 @@ pub mod prelude; pub use app_error::{AppError, AppResult}; pub use code::AppCode; pub use kind::AppErrorKind; -pub use response::{ErrorResponse, RetryAdvice}; -/// Derive macro re-export providing the same ergonomics as `thiserror::Error`. +/// Native derive macro for error enums and structs. /// -/// Supports `#[from]` conversions and `#[error(transparent)]` wrappers out of -/// the box while keeping compile-time validation of wrapper shapes. +/// Supports `#[from]` conversions, transparent wrappers, and precise +/// diagnostics for `#[error("...")]` templates with field-aware validation. /// /// ``` /// use std::error::Error as StdError; @@ -258,4 +261,5 @@ pub use response::{ErrorResponse, RetryAdvice}; /// expected_source /// ); /// ``` -pub use thiserror::Error; +pub use masterror_derive::Error; +pub use response::{ErrorResponse, RetryAdvice}; diff --git a/src/provide.rs b/src/provide.rs new file mode 100644 index 0000000..9bb0d90 --- /dev/null +++ b/src/provide.rs @@ -0,0 +1,27 @@ +//! Internal helpers for error derives. +//! +//! This module is not intended for public consumption. The trait is +//! documented as hidden and mirrors the shim provided by `thiserror` to allow +//! derived errors to forward `core::error::Request` values to their sources. + +use core::error::{Error, Request}; + +#[doc(hidden)] +pub trait ThiserrorProvide: Sealed { + fn thiserror_provide<'a>(&'a self, request: &mut Request<'a>); +} + +impl ThiserrorProvide for T +where + T: Error + ?Sized +{ + #[inline] + fn thiserror_provide<'a>(&'a self, request: &mut Request<'a>) { + self.provide(request); + } +} + +#[doc(hidden)] +pub trait Sealed {} + +impl Sealed for T where T: Error + ?Sized {} diff --git a/src/turnkey.rs b/src/turnkey.rs index 1173151..7d4c080 100644 --- a/src/turnkey.rs +++ b/src/turnkey.rs @@ -28,322 +28,12 @@ //! assert!(matches!(k, TurnkeyErrorKind::UniqueLabel)); //! ``` -use crate::{AppError, AppErrorKind, Error}; +mod classifier; +mod conversions; +mod domain; -/// High-level, stable Turnkey error categories. -/// -/// Marked `#[non_exhaustive]` to allow adding variants without a breaking -/// change. Consumers must use a wildcard arm when matching. -/// -/// Mapping to [`AppErrorKind`] is intentionally conservative: -/// - `UniqueLabel` → `Conflict` -/// - `RateLimited` → `RateLimited` -/// - `Timeout` → `Timeout` -/// - `Auth` → `Unauthorized` -/// - `Network` → `Network` -/// - `Service` → `Turnkey` -#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub enum TurnkeyErrorKind { - /// Unique label violation or duplicate resource. - #[error("label already exists")] - UniqueLabel, - /// Throttling or quota exceeded. - #[error("rate limited or throttled")] - RateLimited, - /// Operation exceeded allowed time. - #[error("request timed out")] - Timeout, - /// Authentication/authorization failure. - #[error("authentication/authorization failed")] - Auth, - /// Network-level error (DNS/connect/TLS/build). - #[error("network error")] - Network, - /// Generic service error in the Turnkey subsystem. - #[error("service error")] - Service -} - -/// Turnkey domain error with stable kind and safe, human-readable message. -#[derive(Debug, Error, Clone, PartialEq, Eq)] -#[error("{kind}: {msg}")] -pub struct TurnkeyError { - /// Stable semantic category. - pub kind: TurnkeyErrorKind, - /// Public, non-sensitive message. - pub msg: String -} - -impl TurnkeyError { - /// Construct a new domain error. - /// - /// # Examples - /// ```rust - /// use masterror::turnkey::{TurnkeyError, TurnkeyErrorKind}; - /// let e = TurnkeyError::new(TurnkeyErrorKind::Timeout, "rpc deadline exceeded"); - /// assert!(matches!(e.kind, TurnkeyErrorKind::Timeout)); - /// ``` - #[inline] - pub fn new(kind: TurnkeyErrorKind, msg: impl Into) -> Self { - Self { - kind, - msg: msg.into() - } - } -} - -/// Map [`TurnkeyErrorKind`] into the canonical [`AppErrorKind`]. -/// -/// Keep mappings conservative and stable. See enum docs for rationale. -#[must_use] -#[inline] -pub fn map_turnkey_kind(kind: TurnkeyErrorKind) -> AppErrorKind { - match kind { - TurnkeyErrorKind::UniqueLabel => AppErrorKind::Conflict, - TurnkeyErrorKind::RateLimited => AppErrorKind::RateLimited, - TurnkeyErrorKind::Timeout => AppErrorKind::Timeout, - TurnkeyErrorKind::Auth => AppErrorKind::Unauthorized, - TurnkeyErrorKind::Network => AppErrorKind::Network, - TurnkeyErrorKind::Service => AppErrorKind::Turnkey, - // Future-proofing: unknown variants map to Turnkey (500) by default. - #[allow(unreachable_patterns)] - _ => AppErrorKind::Turnkey - } -} - -/// Heuristic classifier for raw SDK/provider messages (ASCII case-insensitive). -/// -/// This helper **does not allocate**; it performs case-insensitive `contains` -/// checks over the input string to map common upstream texts to stable kinds. -/// -/// The classifier is intentionally minimal; providers can and will change -/// messages. Prefer returning structured errors from adapters whenever -/// possible. -/// -/// # Examples -/// ```rust -/// use masterror::turnkey::{TurnkeyErrorKind, classify_turnkey_error}; -/// assert!(matches!( -/// classify_turnkey_error("429 Too Many Requests"), -/// TurnkeyErrorKind::RateLimited -/// )); -/// assert!(matches!( -/// classify_turnkey_error("label must be unique"), -/// TurnkeyErrorKind::UniqueLabel -/// )); -/// assert!(matches!( -/// classify_turnkey_error("request timed out"), -/// TurnkeyErrorKind::Timeout -/// )); -/// ``` -#[must_use] -pub fn classify_turnkey_error(msg: &str) -> TurnkeyErrorKind { - // Patterns grouped by kind. Keep short, ASCII, and conservative. - const UNIQUE_PATTERNS: &[&str] = &[ - "label must be unique", - "already exists", - "duplicate", - "unique" - ]; - const RL_PATTERNS: &[&str] = &["429", "rate", "throttle"]; - const TO_PATTERNS: &[&str] = &["timeout", "timed out", "deadline exceeded"]; - const AUTH_PATTERNS: &[&str] = &["401", "403", "unauthor", "forbidden"]; - const NET_PATTERNS: &[&str] = &["network", "connection", "connect", "dns", "tls", "socket"]; - - if contains_any_nocase(msg, UNIQUE_PATTERNS) { - TurnkeyErrorKind::UniqueLabel - } else if contains_any_nocase(msg, RL_PATTERNS) { - TurnkeyErrorKind::RateLimited - } else if contains_any_nocase(msg, TO_PATTERNS) { - TurnkeyErrorKind::Timeout - } else if contains_any_nocase(msg, AUTH_PATTERNS) { - TurnkeyErrorKind::Auth - } else if contains_any_nocase(msg, NET_PATTERNS) { - TurnkeyErrorKind::Network - } else { - TurnkeyErrorKind::Service - } -} - -/// Returns true if `haystack` contains `needle` ignoring ASCII case. -/// Performs the search without allocating. -#[inline] -fn contains_nocase(haystack: &str, needle: &str) -> bool { - // Fast path: empty needle always matches. - if needle.is_empty() { - return true; - } - // Walk haystack windows and compare ASCII case-insensitively. - haystack.as_bytes().windows(needle.len()).any(|w| { - w.iter() - .copied() - .map(ascii_lower) - .eq(needle.as_bytes().iter().copied().map(ascii_lower)) - }) -} - -/// Check whether `haystack` contains any of the `needles` (ASCII -/// case-insensitive). -#[inline] -fn contains_any_nocase(haystack: &str, needles: &[&str]) -> bool { - needles.iter().any(|n| contains_nocase(haystack, n)) -} - -/// Converts ASCII letters to lowercase and leaves other bytes unchanged. -#[inline] -const fn ascii_lower(b: u8) -> u8 { - // ASCII-only fold without RangeInclusive to keep const-friendly on MSRV 1.89 - if b >= b'A' && b <= b'Z' { b + 32 } else { b } -} - -// ── Conversions into AppError ──────────────────────────────────────────────── - -impl From for AppErrorKind { - #[inline] - fn from(k: TurnkeyErrorKind) -> Self { - map_turnkey_kind(k) - } -} - -impl From for AppError { - #[inline] - fn from(e: TurnkeyError) -> Self { - // Prefer explicit constructors to keep transport mapping consistent. - match e.kind { - TurnkeyErrorKind::UniqueLabel => AppError::conflict(e.msg), - TurnkeyErrorKind::RateLimited => AppError::rate_limited(e.msg), - TurnkeyErrorKind::Timeout => AppError::timeout(e.msg), - TurnkeyErrorKind::Auth => AppError::unauthorized(e.msg), - TurnkeyErrorKind::Network => AppError::network(e.msg), - TurnkeyErrorKind::Service => AppError::turnkey(e.msg) - } - } -} +pub use classifier::classify_turnkey_error; +pub use domain::{TurnkeyError, TurnkeyErrorKind, map_turnkey_kind}; #[cfg(test)] -mod tests { - use super::*; - use crate::AppErrorKind; - - #[test] - fn map_is_stable() { - assert_eq!( - map_turnkey_kind(TurnkeyErrorKind::UniqueLabel), - AppErrorKind::Conflict - ); - assert_eq!( - map_turnkey_kind(TurnkeyErrorKind::RateLimited), - AppErrorKind::RateLimited - ); - assert_eq!( - map_turnkey_kind(TurnkeyErrorKind::Timeout), - AppErrorKind::Timeout - ); - assert_eq!( - map_turnkey_kind(TurnkeyErrorKind::Auth), - AppErrorKind::Unauthorized - ); - assert_eq!( - map_turnkey_kind(TurnkeyErrorKind::Network), - AppErrorKind::Network - ); - assert_eq!( - map_turnkey_kind(TurnkeyErrorKind::Service), - AppErrorKind::Turnkey - ); - } - - #[test] - fn classifier_unique() { - for s in [ - "Label must be UNIQUE", - "already exists: trading-key-foo", - "duplicate label", - "unique constraint violation" - ] { - assert!( - matches!(classify_turnkey_error(s), TurnkeyErrorKind::UniqueLabel), - "failed on: {s}" - ); - } - } - - #[test] - fn classifier_rate_limited() { - for s in [ - "429 Too Many Requests", - "rate limit exceeded", - "throttled by upstream" - ] { - assert!( - matches!(classify_turnkey_error(s), TurnkeyErrorKind::RateLimited), - "failed on: {s}" - ); - } - } - - #[test] - fn classifier_timeout() { - for s in [ - "request timed out", - "Timeout while waiting", - "deadline exceeded" - ] { - assert!( - matches!(classify_turnkey_error(s), TurnkeyErrorKind::Timeout), - "failed on: {s}" - ); - } - } - - #[test] - fn classifier_auth() { - for s in ["401 Unauthorized", "403 Forbidden", "unauthor ized"] { - assert!( - matches!(classify_turnkey_error(s), TurnkeyErrorKind::Auth), - "failed on: {s}" - ); - } - } - - #[test] - fn classifier_network() { - for s in [ - "network error", - "connection reset", - "DNS failure", - "TLS handshake", - "socket hang up" - ] { - assert!( - matches!(classify_turnkey_error(s), TurnkeyErrorKind::Network), - "failed on: {s}" - ); - } - } - - #[test] - fn classifier_service_fallback() { - assert!(matches!( - classify_turnkey_error("unrecognized issue"), - TurnkeyErrorKind::Service - )); - } - - #[test] - fn contains_nocase_works_without_alloc() { - assert!(contains_nocase("ABCdef", "cDe")); - assert!(contains_any_nocase("hello world", &["nope", "WORLD"])); - assert!(!contains_nocase("rustacean", "python")); - assert!(contains_nocase("", "")); // by definition - } - - #[test] - fn from_turnkey_error_into_app_error() { - let e = TurnkeyError::new(TurnkeyErrorKind::RateLimited, "try later"); - let a: AppError = e.into(); - assert_eq!(a.kind, AppErrorKind::RateLimited); - // message plumbing is AppError-specific; sanity-check only kind here. - } -} +mod tests; diff --git a/src/turnkey/classifier.rs b/src/turnkey/classifier.rs new file mode 100644 index 0000000..f9a2d93 --- /dev/null +++ b/src/turnkey/classifier.rs @@ -0,0 +1,99 @@ +use super::domain::TurnkeyErrorKind; + +/// Heuristic classifier for raw SDK/provider messages (ASCII case-insensitive). +/// +/// This helper **does not allocate**; it performs case-insensitive `contains` +/// checks over the input string to map common upstream texts to stable kinds. +/// +/// The classifier is intentionally minimal; providers can and will change +/// messages. Prefer returning structured errors from adapters whenever +/// possible. +/// +/// # Examples +/// ```rust +/// use masterror::turnkey::{TurnkeyErrorKind, classify_turnkey_error}; +/// assert!(matches!( +/// classify_turnkey_error("429 Too Many Requests"), +/// TurnkeyErrorKind::RateLimited +/// )); +/// assert!(matches!( +/// classify_turnkey_error("label must be unique"), +/// TurnkeyErrorKind::UniqueLabel +/// )); +/// assert!(matches!( +/// classify_turnkey_error("request timed out"), +/// TurnkeyErrorKind::Timeout +/// )); +/// ``` +#[must_use] +pub fn classify_turnkey_error(msg: &str) -> TurnkeyErrorKind { + // Patterns grouped by kind. Keep short, ASCII, and conservative. + const UNIQUE_PATTERNS: &[&str] = &[ + "label must be unique", + "already exists", + "duplicate", + "unique" + ]; + const RL_PATTERNS: &[&str] = &["429", "rate", "throttle"]; + const TO_PATTERNS: &[&str] = &["timeout", "timed out", "deadline exceeded"]; + const AUTH_PATTERNS: &[&str] = &["401", "403", "unauthor", "forbidden"]; + const NET_PATTERNS: &[&str] = &["network", "connection", "connect", "dns", "tls", "socket"]; + + if contains_any_nocase(msg, UNIQUE_PATTERNS) { + TurnkeyErrorKind::UniqueLabel + } else if contains_any_nocase(msg, RL_PATTERNS) { + TurnkeyErrorKind::RateLimited + } else if contains_any_nocase(msg, TO_PATTERNS) { + TurnkeyErrorKind::Timeout + } else if contains_any_nocase(msg, AUTH_PATTERNS) { + TurnkeyErrorKind::Auth + } else if contains_any_nocase(msg, NET_PATTERNS) { + TurnkeyErrorKind::Network + } else { + TurnkeyErrorKind::Service + } +} + +/// Returns true if `haystack` contains `needle` ignoring ASCII case. +/// Performs the search without allocating. +#[inline] +fn contains_nocase(haystack: &str, needle: &str) -> bool { + // Fast path: empty needle always matches. + if needle.is_empty() { + return true; + } + // Walk haystack windows and compare ASCII case-insensitively. + haystack.as_bytes().windows(needle.len()).any(|w| { + w.iter() + .copied() + .map(ascii_lower) + .eq(needle.as_bytes().iter().copied().map(ascii_lower)) + }) +} + +/// Check whether `haystack` contains any of the `needles` (ASCII +/// case-insensitive). +#[inline] +fn contains_any_nocase(haystack: &str, needles: &[&str]) -> bool { + needles.iter().any(|n| contains_nocase(haystack, n)) +} + +/// Converts ASCII letters to lowercase and leaves other bytes unchanged. +#[inline] +const fn ascii_lower(b: u8) -> u8 { + // ASCII-only fold without RangeInclusive to keep const-friendly on MSRV 1.89 + if b >= b'A' && b <= b'Z' { b + 32 } else { b } +} + +#[cfg(test)] +pub(super) mod internal_tests { + use super::*; + + #[test] + fn contains_nocase_works_without_alloc() { + assert!(contains_nocase("ABCdef", "cDe")); + assert!(contains_any_nocase("hello world", &["nope", "WORLD"])); + assert!(!contains_nocase("rustacean", "python")); + assert!(contains_nocase("", "")); + } +} diff --git a/src/turnkey/conversions.rs b/src/turnkey/conversions.rs new file mode 100644 index 0000000..22c0d0e --- /dev/null +++ b/src/turnkey/conversions.rs @@ -0,0 +1,24 @@ +use super::domain::{TurnkeyError, TurnkeyErrorKind, map_turnkey_kind}; +use crate::{AppError, AppErrorKind}; + +impl From for AppErrorKind { + #[inline] + fn from(k: TurnkeyErrorKind) -> Self { + map_turnkey_kind(k) + } +} + +impl From for AppError { + #[inline] + fn from(e: TurnkeyError) -> Self { + // Prefer explicit constructors to keep transport mapping consistent. + match e.kind { + TurnkeyErrorKind::UniqueLabel => AppError::conflict(e.msg), + TurnkeyErrorKind::RateLimited => AppError::rate_limited(e.msg), + TurnkeyErrorKind::Timeout => AppError::timeout(e.msg), + TurnkeyErrorKind::Auth => AppError::unauthorized(e.msg), + TurnkeyErrorKind::Network => AppError::network(e.msg), + TurnkeyErrorKind::Service => AppError::turnkey(e.msg) + } + } +} diff --git a/src/turnkey/domain.rs b/src/turnkey/domain.rs new file mode 100644 index 0000000..b30a8fa --- /dev/null +++ b/src/turnkey/domain.rs @@ -0,0 +1,83 @@ +use crate::{AppErrorKind, Error}; + +/// High-level, stable Turnkey error categories. +/// +/// Marked `#[non_exhaustive]` to allow adding variants without a breaking +/// change. Consumers must use a wildcard arm when matching. +/// +/// Mapping to [`AppErrorKind`] is intentionally conservative: +/// - `UniqueLabel` → `Conflict` +/// - `RateLimited` → `RateLimited` +/// - `Timeout` → `Timeout` +/// - `Auth` → `Unauthorized` +/// - `Network` → `Network` +/// - `Service` → `Turnkey` +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum TurnkeyErrorKind { + /// Unique label violation or duplicate resource. + #[error("label already exists")] + UniqueLabel, + /// Throttling or quota exceeded. + #[error("rate limited or throttled")] + RateLimited, + /// Operation exceeded allowed time. + #[error("request timed out")] + Timeout, + /// Authentication/authorization failure. + #[error("authentication/authorization failed")] + Auth, + /// Network-level error (DNS/connect/TLS/build). + #[error("network error")] + Network, + /// Generic service error in the Turnkey subsystem. + #[error("service error")] + Service +} + +/// Turnkey domain error with stable kind and safe, human-readable message. +#[derive(Debug, Error, Clone, PartialEq, Eq)] +#[error("{kind}: {msg}")] +pub struct TurnkeyError { + /// Stable semantic category. + pub kind: TurnkeyErrorKind, + /// Public, non-sensitive message. + pub msg: String +} + +impl TurnkeyError { + /// Construct a new domain error. + /// + /// # Examples + /// ```rust + /// use masterror::turnkey::{TurnkeyError, TurnkeyErrorKind}; + /// let e = TurnkeyError::new(TurnkeyErrorKind::Timeout, "rpc deadline exceeded"); + /// assert!(matches!(e.kind, TurnkeyErrorKind::Timeout)); + /// ``` + #[inline] + pub fn new(kind: TurnkeyErrorKind, msg: impl Into) -> Self { + Self { + kind, + msg: msg.into() + } + } +} + +/// Map [`TurnkeyErrorKind`] into the canonical [`AppErrorKind`]. +/// +/// Keep mappings conservative and stable. See enum docs for rationale. +#[must_use] +#[inline] +pub fn map_turnkey_kind(kind: TurnkeyErrorKind) -> AppErrorKind { + match kind { + TurnkeyErrorKind::UniqueLabel => AppErrorKind::Conflict, + TurnkeyErrorKind::RateLimited => AppErrorKind::RateLimited, + TurnkeyErrorKind::Timeout => AppErrorKind::Timeout, + TurnkeyErrorKind::Auth => AppErrorKind::Unauthorized, + TurnkeyErrorKind::Network => AppErrorKind::Network, + TurnkeyErrorKind::Service => AppErrorKind::Turnkey, + // Future-proofing: unknown variants map to Turnkey (500) by default. + #[allow(unreachable_patterns)] + _ => AppErrorKind::Turnkey + } +} diff --git a/src/turnkey/tests.rs b/src/turnkey/tests.rs new file mode 100644 index 0000000..64bfa70 --- /dev/null +++ b/src/turnkey/tests.rs @@ -0,0 +1,115 @@ +use super::{TurnkeyError, TurnkeyErrorKind, classify_turnkey_error, map_turnkey_kind}; +use crate::{AppError, AppErrorKind}; + +#[test] +fn map_is_stable() { + assert_eq!( + map_turnkey_kind(TurnkeyErrorKind::UniqueLabel), + AppErrorKind::Conflict + ); + assert_eq!( + map_turnkey_kind(TurnkeyErrorKind::RateLimited), + AppErrorKind::RateLimited + ); + assert_eq!( + map_turnkey_kind(TurnkeyErrorKind::Timeout), + AppErrorKind::Timeout + ); + assert_eq!( + map_turnkey_kind(TurnkeyErrorKind::Auth), + AppErrorKind::Unauthorized + ); + assert_eq!( + map_turnkey_kind(TurnkeyErrorKind::Network), + AppErrorKind::Network + ); + assert_eq!( + map_turnkey_kind(TurnkeyErrorKind::Service), + AppErrorKind::Turnkey + ); +} + +#[test] +fn classifier_unique() { + for s in [ + "Label must be UNIQUE", + "already exists: trading-key-foo", + "duplicate label", + "unique constraint violation" + ] { + assert!( + matches!(classify_turnkey_error(s), TurnkeyErrorKind::UniqueLabel), + "failed on: {s}" + ); + } +} + +#[test] +fn classifier_rate_limited() { + for s in [ + "429 Too Many Requests", + "rate limit exceeded", + "throttled by upstream" + ] { + assert!( + matches!(classify_turnkey_error(s), TurnkeyErrorKind::RateLimited), + "failed on: {s}" + ); + } +} + +#[test] +fn classifier_timeout() { + for s in [ + "request timed out", + "Timeout while waiting", + "deadline exceeded" + ] { + assert!( + matches!(classify_turnkey_error(s), TurnkeyErrorKind::Timeout), + "failed on: {s}" + ); + } +} + +#[test] +fn classifier_auth() { + for s in ["401 Unauthorized", "403 Forbidden", "unauthor ized"] { + assert!( + matches!(classify_turnkey_error(s), TurnkeyErrorKind::Auth), + "failed on: {s}" + ); + } +} + +#[test] +fn classifier_network() { + for s in [ + "network error", + "connection reset", + "DNS failure", + "TLS handshake", + "socket hang up" + ] { + assert!( + matches!(classify_turnkey_error(s), TurnkeyErrorKind::Network), + "failed on: {s}" + ); + } +} + +#[test] +fn classifier_service_fallback() { + assert!(matches!( + classify_turnkey_error("unrecognized issue"), + TurnkeyErrorKind::Service + )); +} + +#[test] +fn from_turnkey_error_into_app_error() { + let e = TurnkeyError::new(TurnkeyErrorKind::RateLimited, "try later"); + let a: AppError = e.into(); + assert_eq!(a.kind, AppErrorKind::RateLimited); + // message plumbing is AppError-specific; sanity-check only kind here. +} diff --git a/target.md b/target.md new file mode 100644 index 0000000..340c5d6 --- /dev/null +++ b/target.md @@ -0,0 +1,82 @@ +Цели дизайна + +Явные коды и домены +thiserror форматирует сообщение, но не даёт стабильных кодов. Введи: + +ErrorCode(NonZeroU32) и ErrorDomain(&'static str). + +Инвариант: код стабилен, сообщение и поля — нет. + +Диапазоны кодов per-crate/перенейминг через макрос домена. + +Прозрачный рантайм: ноль лишних аллокаций + +Display строится без format! при конструировании. + +Сообщение — &'static str + форматирование аргументов на выводе. + +Опциональный Backtrace по фиче, захват один раз при создании. + +Минимальный proc-macro + +Зависимости только proc_macro2, syn, quote. Никаких reflection-трюков, никаких «скрытых» From на всё подряд. + +Атрибуты валидируются жёстко, ошибки — диагностически понятные. + +Интероп и миграция + +From/Into по фиче. + +Маппинг thiserror-стиля: #[source], #[transparent], форматные строки. + +Утилиты: chain() итератор по причинам, report() для человекочитаемого трейсинга. + +Контекст и структурные данные без глобального стейта + +Лёгкий Context как иммутабельный key-value snapshot (SmallVec<[(&'static str, Cow<'static, str>); N]>), без мутабельного глобала. + +По фиче serde — сериализация структурных данных. + +MSRV/скорость компиляции + +MSRV = актуальный LTS/твоя корпоративная, без «танцев» в макросе. + +Меньше генерируемого кода: без дублирующих From/Display там, где можно обойтись общими трейтом-адаптерами. + +--- + +Реализация: план работ + +core + +Трейты MasterError, ErrorCategory, Context, итератор chain(), Report для красивого вывода. + +Фичи: backtrace, serde, anyhow, miette. + +derive + +Парсер атрибутов (только syn/quote). + +Проверки: единственный #[source]; запрет transparent вместе с кастомным #[error("…")]; обязательность #[code(..)]. + +Генерация Display с match, без промежуточных String. + +интеграции + +impl From for E только по явному #[from]. + +Into по фиче: сохраняем code/domain в anyhow::Context через newtype-адаптер и downcast_ref. + +UX-инструменты + +err! и bail! макросы, которые не плодят типы, а создают твой enum? Нет. Оставь это anyhow. Дай макрос ensure!(cond, MyError::X { … }) как удобство, но без магии. + +#[deny(masterror::missing_code)] внутренняя диагностическая либа на базе rustc lints через proc_macro_diagnostic для ранней обратной связи. + +Документация и примеры + +Cookbook: «миграция с thiserror за 15 минут». + +Пример с Axum: маппинг на HTTP-статусы по category() и логирование кода/домена. + +Выход на прод: чек-лист diff --git a/tests/error_derive.rs b/tests/error_derive.rs index 3fc6c2f..1b9858f 100644 --- a/tests/error_derive.rs +++ b/tests/error_derive.rs @@ -1,4 +1,8 @@ -use std::error::Error as StdError; +#![allow(unused_variables, non_shorthand_field_patterns)] + +#[cfg(error_generic_member_access)] +use std::ptr; +use std::{error::Error as StdError, fmt}; use masterror::Error; @@ -105,6 +109,449 @@ enum TransparentEnum { TransparentVariant(#[from] TransparentInner) } +#[derive(Debug, Error)] +#[error("{source:?}")] +struct StructFromWithBacktrace { + #[from] + source: LeafError, + #[backtrace] + trace: Option +} + +#[derive(Debug, Error)] +enum VariantFromWithBacktrace { + #[error("{source:?}")] + WithTrace { + #[from] + source: LeafError, + #[backtrace] + trace: Option + } +} + +#[derive(Debug, Error)] +#[error("captured")] +struct StructWithBacktrace { + #[backtrace] + trace: std::backtrace::Backtrace +} + +#[derive(Debug, Error)] +enum EnumWithBacktrace { + #[error("tuple {0}")] + Tuple(&'static str, #[backtrace] std::backtrace::Backtrace), + #[error("named {message}")] + Named { + message: &'static str, + #[backtrace] + trace: std::backtrace::Backtrace + }, + #[error("unit")] + Unit +} + +#[cfg_attr(not(error_generic_member_access), allow(dead_code))] +#[derive(Clone, Debug, PartialEq, Eq)] +struct TelemetrySnapshot { + name: &'static str, + value: u64 +} + +#[cfg_attr(not(error_generic_member_access), allow(dead_code))] +#[derive(Debug, Error)] +#[error("structured telemetry {snapshot:?}")] +struct StructuredTelemetryError { + #[provide(ref = TelemetrySnapshot, value = TelemetrySnapshot)] + snapshot: TelemetrySnapshot +} + +#[cfg_attr(not(error_generic_member_access), allow(dead_code))] +#[derive(Debug, Error)] +#[error("optional telemetry {telemetry:?}")] +struct OptionalTelemetryError { + #[provide(ref = TelemetrySnapshot)] + telemetry: Option +} + +#[cfg_attr(not(error_generic_member_access), allow(dead_code))] +#[derive(Debug, Error)] +#[error("optional owned telemetry {telemetry:?}")] +struct OptionalOwnedTelemetryError { + #[provide(value = TelemetrySnapshot)] + telemetry: Option +} + +#[cfg_attr(not(error_generic_member_access), allow(dead_code))] +#[derive(Debug, Error)] +enum EnumTelemetryError { + #[error("named {label}")] + Named { + label: &'static str, + #[provide(ref = TelemetrySnapshot)] + snapshot: TelemetrySnapshot + }, + #[error("optional tuple")] + Optional(#[provide(ref = TelemetrySnapshot)] Option), + #[error("owned tuple")] + Owned(#[provide(value = TelemetrySnapshot)] TelemetrySnapshot) +} + +#[derive(Debug, Error)] +#[error("{source:?}")] +struct DelegatedBacktraceFromSource { + #[from] + #[source] + #[backtrace] + source: StructWithBacktrace +} + +#[derive(Debug, Error)] +#[error("{source:?}")] +struct OptionalDelegatedBacktrace { + #[source] + #[backtrace] + source: Option +} + +#[derive(Debug, Error)] +#[error("auto {source}")] +struct AutoSourceStruct { + source: LeafError +} + +#[derive(Debug, Error)] +enum AutoSourceEnum { + #[error("named {source}")] + Named { source: LeafError } +} + +#[derive(Debug, Error)] +#[error("captured")] +struct AutoBacktraceStruct { + trace: std::backtrace::Backtrace +} + +#[derive(Debug, Error)] +#[error("optional")] +struct AutoOptionalBacktraceStruct { + trace: Option +} + +#[derive(Debug, Error)] +enum AutoBacktraceEnum { + #[error("named {message}")] + Named { + message: &'static str, + trace: std::backtrace::Backtrace + }, + #[error("tuple {0:?}")] + Tuple(Option) +} + +#[derive(Debug, Error)] +#[error( + "display={value} debug={value:?} #debug={value:#?} x={value:x} X={value:X} \ + #x={value:#x} #X={value:#X} b={value:b} #b={value:#b} o={value:o} #o={value:#o} \ + e={float:e} #e={float:#e} E={float:E} #E={float:#E} p={ptr:p} #p={ptr:#p}" +)] +struct FormatterShowcase { + value: u32, + float: f64, + ptr: *const u32 +} + +#[derive(Debug)] +struct PrettyDebugValue { + label: &'static str +} + +impl fmt::Display for PrettyDebugValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.label) + } +} + +#[derive(Debug, Error)] +#[error("display={value} debug={value:?} #debug={value:#?} tuple={tuple:?} #tuple={tuple:#?}")] +struct FormatterDebugShowcase { + value: PrettyDebugValue, + tuple: (&'static str, u8) +} + +#[derive(Debug, Error)] +#[error("{formatted}", formatted = self.message.to_uppercase())] +struct FormatArgExpressionError { + message: &'static str +} + +#[derive(Debug, Error)] +#[error("{}, {label}, {}", label = self.label, self.first, self.second)] +struct MixedImplicitArgsError { + label: &'static str, + first: &'static str, + second: &'static str +} + +#[derive(Debug, Error)] +enum FormatArgEnum { + #[error("{detail}", detail = detail.to_uppercase())] + Upper { detail: String } +} + +#[derive(Debug, Error)] +#[error("{1}::{0}", self.first, self.second)] +struct ExplicitIndexArgsError { + first: &'static str, + second: &'static str +} + +#[derive(Debug, Error)] +#[error("{0}::{label}", label = self.label, self.value)] +struct MixedNamedPositionalArgsError { + label: &'static str, + value: &'static str +} + +#[derive(Debug, Error)] +#[error("{value}", value = .value)] +struct FieldShortcutError { + value: &'static str +} + +#[derive(Debug, Error)] +#[error("{}, {}", .0, .1)] +struct TupleShortcutError(&'static str, &'static str); + +#[derive(Debug)] +struct RangeLimits { + lo: i32, + hi: i32 +} + +#[derive(Debug, Error)] +#[error( + "range {lo}-{hi} suggestion {suggestion}", + lo = .limits.lo, + hi = .limits.hi, + suggestion = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) +)] +struct ProjectionStructError { + limits: RangeLimits, + suggestion: Option +} + +#[derive(Debug)] +struct TuplePayload { + data: &'static str +} + +#[derive(Debug, Error)] +enum ProjectionEnumError { + #[error("tuple data {data}", data = .0.data)] + Tuple(TuplePayload), + #[error( + "named suggestion {value}", + value = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) + )] + Named { suggestion: Option } +} + +#[derive(Debug, Error)] +#[error("{value}")] +struct DisplayFormatterError { + value: &'static str +} + +#[derive(Debug, Error)] +#[error("debug={value:?} #debug={value:#?}")] +struct DebugFormatterError { + value: PrettyDebugValue +} + +#[derive(Debug, Error)] +#[error("lower={value:x} #lower={value:#x}")] +struct LowerHexFormatterError { + value: u32 +} + +#[derive(Debug, Error)] +#[error("upper={value:X} #upper={value:#X}")] +struct UpperHexFormatterError { + value: u32 +} + +#[derive(Debug, Error)] +#[error("binary={value:b} #binary={value:#b}")] +struct BinaryFormatterError { + value: u16 +} + +#[derive(Debug, Error)] +#[error("octal={value:o} #octal={value:#o}")] +struct OctalFormatterError { + value: u16 +} + +#[derive(Debug, Error)] +#[error("pointer={value:p} #pointer={value:#p}")] +struct PointerFormatterError { + value: *const u32 +} + +#[derive(Debug, Error)] +#[error("lower={value:e} #lower={value:#e}")] +struct LowerExpFormatterError { + value: f64 +} + +#[derive(Debug, Error)] +#[error("upper={value:E} #upper={value:#E}")] +struct UpperExpFormatterError { + value: f64 +} + +#[derive(Debug, Error)] +#[error("{value:>8}", value = .value)] +struct DisplayAlignmentError { + value: &'static str +} + +#[derive(Debug, Error)] +#[error("{value:.3}", value = .value)] +struct DisplayPrecisionError { + value: f64 +} + +#[derive(Debug, Error)] +#[error("{value:*<6}", value = .value)] +struct DisplayFillError { + value: &'static str +} + +#[derive(Debug, Error)] +#[error("{value:>width$}", value = .value, width = .width)] +struct DisplayDynamicWidthError { + value: &'static str, + width: usize +} + +#[derive(Debug, Error)] +#[error("{value:.precision$}", value = .value, precision = .precision)] +struct DisplayDynamicPrecisionError { + value: f64, + precision: usize +} + +#[cfg(error_generic_member_access)] +fn assert_backtrace_interfaces(error: &E, expected: &std::backtrace::Backtrace) +where + E: StdError + ?Sized +{ + let reported = std::error::Error::backtrace(error).expect("backtrace"); + assert!(ptr::eq(expected, reported)); + let provided = + std::error::request_ref::(error).expect("provided backtrace"); + assert!(ptr::eq(reported, provided)); +} + +#[cfg(not(error_generic_member_access))] +fn assert_backtrace_interfaces(_error: &E, _expected: &std::backtrace::Backtrace) +where + E: StdError + ?Sized +{ +} + +#[cfg(error_generic_member_access)] +#[test] +fn struct_provides_custom_telemetry() { + let telemetry = TelemetrySnapshot { + name: "job", + value: 7 + }; + let err = StructuredTelemetryError { + snapshot: telemetry.clone() + }; + + let provided_ref = + std::error::request_ref::(&err).expect("telemetry reference"); + assert!(ptr::eq(provided_ref, &err.snapshot)); + + let provided_value = + std::error::request_value::(&err).expect("telemetry value"); + assert_eq!(provided_value, telemetry); +} + +#[cfg(error_generic_member_access)] +#[test] +fn option_telemetry_only_provided_when_present() { + let snapshot = TelemetrySnapshot { + name: "task", + value: 13 + }; + + let with_value = OptionalTelemetryError { + telemetry: Some(snapshot.clone()) + }; + let provided = + std::error::request_ref::(&with_value).expect("optional telemetry"); + let inner = with_value.telemetry.as_ref().expect("inner telemetry"); + assert!(ptr::eq(provided, inner)); + + let without = OptionalTelemetryError { + telemetry: None + }; + assert!(std::error::request_ref::(&without).is_none()); + + let owned_value = OptionalOwnedTelemetryError { + telemetry: Some(snapshot.clone()) + }; + let provided_owned = + std::error::request_value::(&owned_value).expect("owned telemetry"); + assert_eq!(provided_owned, snapshot); + + let owned_none = OptionalOwnedTelemetryError { + telemetry: None + }; + assert!(std::error::request_value::(&owned_none).is_none()); +} + +#[cfg(error_generic_member_access)] +#[test] +fn enum_variants_provide_custom_telemetry() { + let named_snapshot = TelemetrySnapshot { + name: "span", + value: 21 + }; + + let named = EnumTelemetryError::Named { + label: "named", + snapshot: named_snapshot.clone() + }; + let provided_named = + std::error::request_ref::(&named).expect("named telemetry"); + if let EnumTelemetryError::Named { + snapshot, .. + } = &named + { + assert!(ptr::eq(provided_named, snapshot)); + } + + let optional = EnumTelemetryError::Optional(Some(named_snapshot.clone())); + let provided_optional = + std::error::request_ref::(&optional).expect("optional telemetry"); + if let EnumTelemetryError::Optional(Some(snapshot)) = &optional { + assert!(ptr::eq(provided_optional, snapshot)); + } + + let optional_none = EnumTelemetryError::Optional(None); + assert!(std::error::request_ref::(&optional_none).is_none()); + + let owned = EnumTelemetryError::Owned(named_snapshot.clone()); + let provided_owned = + std::error::request_value::(&owned).expect("owned telemetry"); + assert_eq!(provided_owned, named_snapshot); +} + #[test] fn named_struct_display_and_source() { let err = NamedError { @@ -142,6 +589,64 @@ fn enum_variants_cover_display_and_source() { assert_eq!(StdError::source(&pair).unwrap().to_string(), "leaf failure"); } +#[test] +fn named_format_arg_expression_is_used() { + let err = FormatArgExpressionError { + message: "value" + }; + assert_eq!(err.to_string(), "VALUE"); +} + +#[test] +fn implicit_format_args_follow_positional_ordering() { + let err = MixedImplicitArgsError { + label: "tag", + first: "one", + second: "two" + }; + assert_eq!(err.to_string(), "one, tag, two"); +} + +#[test] +fn explicit_format_arg_indices_resolve() { + let err = ExplicitIndexArgsError { + first: "left", + second: "right" + }; + assert_eq!(err.to_string(), "right::left"); +} + +#[test] +fn mixed_named_and_positional_indices_resolve() { + let err = MixedNamedPositionalArgsError { + label: "tag", + value: "item" + }; + assert_eq!(err.to_string(), "item::tag"); +} + +#[test] +fn field_shorthand_arguments_use_struct_fields() { + let err = FieldShortcutError { + value: "shortcut" + }; + assert_eq!(err.to_string(), "shortcut"); +} + +#[test] +fn tuple_shorthand_arguments_resolve_positions() { + let err = TupleShortcutError("first", "second"); + assert_eq!(err.to_string(), "first, second"); +} + +#[test] +fn enum_variant_format_args_resolve_bindings() { + let err = FormatArgEnum::Upper { + detail: String::from("variant") + }; + assert_eq!(err.to_string(), "VARIANT"); +} + #[test] fn tuple_struct_from_wraps_source() { let err = TupleWrapper::from(LeafError); @@ -220,3 +725,393 @@ fn transparent_enum_variant_from_impl() { Some(String::from("leaf failure")) ); } + +#[test] +fn struct_from_with_backtrace_field_captures_trace() { + let err = StructFromWithBacktrace::from(LeafError); + assert!(err.trace.is_some()); + let stored = err.trace.as_ref().expect("trace stored"); + assert_backtrace_interfaces(&err, stored); + assert_eq!( + StdError::source(&err).map(|err| err.to_string()), + Some(String::from("leaf failure")) + ); +} + +#[test] +fn enum_from_with_backtrace_field_captures_trace() { + let err = VariantFromWithBacktrace::from(LeafError); + let trace = match &err { + VariantFromWithBacktrace::WithTrace { + trace, .. + } => { + assert!(trace.is_some()); + trace.as_ref().unwrap() + } + }; + assert_backtrace_interfaces(&err, trace); + assert_eq!( + StdError::source(&err).map(|err| err.to_string()), + Some(String::from("leaf failure")) + ); +} + +#[test] +fn struct_backtrace_field_is_returned() { + let err = StructWithBacktrace { + trace: std::backtrace::Backtrace::capture() + }; + assert_backtrace_interfaces(&err, &err.trace); + assert!(StdError::source(&err).is_none()); +} + +#[test] +fn struct_backtrace_attribute_on_source_delegates() { + let source = StructWithBacktrace { + trace: std::backtrace::Backtrace::capture() + }; + let err = DelegatedBacktraceFromSource::from(source); + let inner = StdError::source(&err) + .and_then(|source| source.downcast_ref::()) + .expect("delegated source"); + assert_backtrace_interfaces(&err, &inner.trace); +} + +#[test] +fn optional_source_backtrace_attribute_delegates() { + let err = OptionalDelegatedBacktrace { + source: Some(StructWithBacktrace { + trace: std::backtrace::Backtrace::capture() + }) + }; + let inner = StdError::source(&err) + .and_then(|source| source.downcast_ref::()) + .expect("optional delegated source"); + assert_backtrace_interfaces(&err, &inner.trace); +} + +#[test] +fn optional_source_backtrace_absent_when_none() { + let err = OptionalDelegatedBacktrace { + source: None + }; + assert!(StdError::source(&err).is_none()); + #[cfg(error_generic_member_access)] + { + assert!(std::error::Error::backtrace(&err).is_none()); + assert!(std::error::request_ref::(&err).is_none()); + } +} + +#[test] +fn enum_backtrace_field_is_returned() { + let tuple = EnumWithBacktrace::Tuple("tuple", std::backtrace::Backtrace::capture()); + if let EnumWithBacktrace::Tuple(_, trace) = &tuple { + assert_backtrace_interfaces(&tuple, trace); + } + + let named = EnumWithBacktrace::Named { + message: "named", + trace: std::backtrace::Backtrace::capture() + }; + if let EnumWithBacktrace::Named { + trace, .. + } = &named + { + assert_backtrace_interfaces(&named, trace); + } + + let unit = EnumWithBacktrace::Unit; + #[cfg(error_generic_member_access)] + { + assert!(std::error::Error::backtrace(&unit).is_none()); + } +} + +#[test] +fn supports_display_and_debug_formatters() { + let value = PrettyDebugValue { + label: "Alpha" + }; + let tuple = ("tuple", 7u8); + + let expected = format!( + "display={value} debug={value:?} #debug={value:#?} tuple={tuple:?} #tuple={tuple:#?}", + ); + + let standard_debug = format!("{value:?}"); + let alternate_debug = format!("{value:#?}"); + assert_ne!(standard_debug, alternate_debug); + + let tuple_debug = format!("{tuple:?}"); + let tuple_alternate_debug = format!("{tuple:#?}"); + assert_ne!(tuple_debug, tuple_alternate_debug); + + let err = FormatterDebugShowcase { + value, + tuple + }; + + assert_eq!(err.to_string(), expected); + assert!(StdError::source(&err).is_none()); +} + +#[test] +fn struct_projection_shorthand_handles_nested_segments() { + let err = ProjectionStructError { + limits: RangeLimits { + lo: 2, hi: 5 + }, + suggestion: Some("retry".to_string()) + }; + assert_eq!(err.to_string(), "range 2-5 suggestion retry"); + + let none = ProjectionStructError { + limits: RangeLimits { + lo: -1, hi: 3 + }, + suggestion: None + }; + assert_eq!(none.to_string(), "range -1-3 suggestion "); +} + +#[test] +fn enum_projection_shorthand_handles_nested_segments() { + let tuple = ProjectionEnumError::Tuple(TuplePayload { + data: "payload" + }); + assert_eq!(tuple.to_string(), "tuple data payload"); + + let named = ProjectionEnumError::Named { + suggestion: Some("escalate".to_string()) + }; + assert_eq!(named.to_string(), "named suggestion escalate"); + + let fallback = ProjectionEnumError::Named { + suggestion: None + }; + assert_eq!(fallback.to_string(), "named suggestion "); +} + +#[test] +fn struct_named_source_is_inferred() { + let err = AutoSourceStruct { + source: LeafError + }; + assert_eq!(err.to_string(), "auto leaf failure"); + let source = StdError::source(&err).expect("source"); + assert_eq!(source.to_string(), "leaf failure"); +} + +#[test] +fn enum_named_source_is_inferred() { + let err = AutoSourceEnum::Named { + source: LeafError + }; + assert_eq!(err.to_string(), "named leaf failure"); + let source = StdError::source(&err).expect("source"); + assert_eq!(source.to_string(), "leaf failure"); +} + +#[test] +fn struct_backtrace_is_inferred_without_attribute() { + let err = AutoBacktraceStruct { + trace: std::backtrace::Backtrace::capture() + }; + assert_backtrace_interfaces(&err, &err.trace); + assert!(StdError::source(&err).is_none()); +} + +#[test] +fn struct_optional_backtrace_is_inferred_without_attribute() { + let err = AutoOptionalBacktraceStruct { + trace: Some(std::backtrace::Backtrace::capture()) + }; + let stored = err.trace.as_ref().expect("trace stored"); + assert_backtrace_interfaces(&err, stored); + assert!(StdError::source(&err).is_none()); +} + +#[test] +fn enum_backtrace_is_inferred_without_attribute() { + let named = AutoBacktraceEnum::Named { + message: "named", + trace: std::backtrace::Backtrace::capture() + }; + if let AutoBacktraceEnum::Named { + trace, .. + } = &named + { + assert_backtrace_interfaces(&named, trace); + } + assert!(StdError::source(&named).is_none()); + + let tuple = AutoBacktraceEnum::Tuple(Some(std::backtrace::Backtrace::capture())); + if let AutoBacktraceEnum::Tuple(Some(trace)) = &tuple { + assert_backtrace_interfaces(&tuple, trace); + } + assert!(StdError::source(&tuple).is_none()); + + #[cfg(error_generic_member_access)] + { + let none = AutoBacktraceEnum::Tuple(None); + assert!(std::error::Error::backtrace(&none).is_none()); + } +} + +#[test] +fn supports_extended_formatters() { + let value = 0x5A5Au32; + let float = 1234.5_f64; + let ptr = core::ptr::null::(); + + let err = FormatterShowcase { + value, + float, + ptr + }; + + let expected = format!( + "display={value} debug={value:?} #debug={value:#?} x={value:x} X={value:X} \ + #x={value:#x} #X={value:#X} b={value:b} #b={value:#b} o={value:o} #o={value:#o} \ + e={float:e} #e={float:#e} E={float:E} #E={float:#E} p={ptr:p} #p={ptr:#p}" + ); + + let lower_hex = format!("{value:x}"); + let upper_hex = format!("{value:X}"); + assert_ne!(lower_hex, upper_hex); + + let lower_exp = format!("{float:e}"); + let upper_exp = format!("{float:E}"); + assert_ne!(lower_exp, upper_exp); + + assert_eq!(err.to_string(), expected); + assert!(StdError::source(&err).is_none()); +} + +#[test] +fn formatter_variants_render_expected_output() { + let display = DisplayFormatterError { + value: "display" + }; + assert_eq!(display.to_string(), "display"); + + let debug = DebugFormatterError { + value: PrettyDebugValue { + label: "Debug" + } + }; + let debug_expected = format!( + "debug={value:?} #debug={value:#?}", + value = PrettyDebugValue { + label: "Debug" + } + ); + assert_eq!(debug.to_string(), debug_expected); + assert_ne!( + format!( + "{value:?}", + value = PrettyDebugValue { + label: "Debug" + } + ), + format!( + "{value:#?}", + value = PrettyDebugValue { + label: "Debug" + } + ) + ); + + const HEX_VALUE: u32 = 0x5A5A; + let lower_hex = LowerHexFormatterError { + value: HEX_VALUE + }; + let lower_hex_expected = format!("lower={value:x} #lower={value:#x}", value = HEX_VALUE); + assert_eq!(lower_hex.to_string(), lower_hex_expected); + assert_ne!(format!("{HEX_VALUE:x}"), format!("{HEX_VALUE:#x}")); + + let upper_hex = UpperHexFormatterError { + value: HEX_VALUE + }; + let upper_hex_expected = format!("upper={value:X} #upper={value:#X}", value = HEX_VALUE); + assert_eq!(upper_hex.to_string(), upper_hex_expected); + assert_ne!(format!("{HEX_VALUE:X}"), format!("{HEX_VALUE:#X}")); + assert_ne!(format!("{HEX_VALUE:x}"), format!("{HEX_VALUE:X}")); + + const INTEGER_VALUE: u16 = 0b1010_1100; + let binary = BinaryFormatterError { + value: INTEGER_VALUE + }; + let binary_expected = format!("binary={value:b} #binary={value:#b}", value = INTEGER_VALUE); + assert_eq!(binary.to_string(), binary_expected); + assert_ne!(format!("{INTEGER_VALUE:b}"), format!("{INTEGER_VALUE:#b}")); + + let octal = OctalFormatterError { + value: INTEGER_VALUE + }; + let octal_expected = format!("octal={value:o} #octal={value:#o}", value = INTEGER_VALUE); + assert_eq!(octal.to_string(), octal_expected); + assert_ne!(format!("{INTEGER_VALUE:o}"), format!("{INTEGER_VALUE:#o}")); + + let pointer_value = core::ptr::null::(); + let pointer = PointerFormatterError { + value: pointer_value + }; + let pointer_expected = format!( + "pointer={value:p} #pointer={value:#p}", + value = pointer_value + ); + assert_eq!(pointer.to_string(), pointer_expected); + assert_ne!(format!("{pointer_value:p}"), format!("{pointer_value:#p}")); + + const FLOAT_VALUE: f64 = 1234.5; + let lower_exp = LowerExpFormatterError { + value: FLOAT_VALUE + }; + let lower_exp_expected = format!("lower={value:e} #lower={value:#e}", value = FLOAT_VALUE); + assert_eq!(lower_exp.to_string(), lower_exp_expected); + + let upper_exp = UpperExpFormatterError { + value: FLOAT_VALUE + }; + let upper_exp_expected = format!("upper={value:E} #upper={value:#E}", value = FLOAT_VALUE); + assert_eq!(upper_exp.to_string(), upper_exp_expected); + assert_ne!(format!("{FLOAT_VALUE:e}"), format!("{FLOAT_VALUE:E}")); +} + +#[test] +fn display_format_specs_match_standard_formatting() { + let alignment = DisplayAlignmentError { + value: "x" + }; + assert_eq!(alignment.to_string(), format!("{:>8}", "x")); + + let precision = DisplayPrecisionError { + value: 123.456_f64 + }; + assert_eq!(precision.to_string(), format!("{:.3}", 123.456_f64)); + + let fill = DisplayFillError { + value: "ab" + }; + assert_eq!(fill.to_string(), format!("{:*<6}", "ab")); + + let dynamic_width = DisplayDynamicWidthError { + value: "x", + width: 5 + }; + assert_eq!( + dynamic_width.to_string(), + format!("{value:>width$}", value = "x", width = 5) + ); + + let dynamic_precision = DisplayDynamicPrecisionError { + value: 123.456_f64, + precision: 4 + }; + assert_eq!( + dynamic_precision.to_string(), + format!("{value:.precision$}", value = 123.456_f64, precision = 4) + ); +} diff --git a/tests/error_derive_from_trybuild.rs b/tests/error_derive_from_trybuild.rs index 377c5c2..0879942 100644 --- a/tests/error_derive_from_trybuild.rs +++ b/tests/error_derive_from_trybuild.rs @@ -11,3 +11,33 @@ fn transparent_attribute_compile_failures() { let t = TestCases::new(); t.compile_fail("tests/ui/transparent/*.rs"); } + +#[test] +fn backtrace_attribute_compile_failures() { + let t = TestCases::new(); + t.compile_fail("tests/ui/backtrace/*.rs"); +} + +#[test] +fn formatter_attribute_passes() { + let t = TestCases::new(); + t.pass("tests/ui/formatter/pass/*.rs"); +} + +#[test] +fn formatter_attribute_compile_failures() { + let t = TestCases::new(); + t.compile_fail("tests/ui/formatter/fail/*.rs"); +} + +#[test] +fn app_error_attribute_passes() { + let t = TestCases::new(); + t.pass("tests/ui/app_error/pass/*.rs"); +} + +#[test] +fn app_error_attribute_compile_failures() { + let t = TestCases::new(); + t.compile_fail("tests/ui/app_error/fail/*.rs"); +} diff --git a/tests/ui/app_error/fail/enum_missing_variant.rs b/tests/ui/app_error/fail/enum_missing_variant.rs new file mode 100644 index 0000000..86d63b5 --- /dev/null +++ b/tests/ui/app_error/fail/enum_missing_variant.rs @@ -0,0 +1,12 @@ +use masterror::{AppErrorKind, Error}; + +#[derive(Debug, Error)] +enum Mixed { + #[error("with spec")] + #[app_error(kind = AppErrorKind::NotFound)] + WithSpec, + #[error("without")] + Without, +} + +fn main() {} diff --git a/tests/ui/app_error/fail/enum_missing_variant.stderr b/tests/ui/app_error/fail/enum_missing_variant.stderr new file mode 100644 index 0000000..d000de1 --- /dev/null +++ b/tests/ui/app_error/fail/enum_missing_variant.stderr @@ -0,0 +1,13 @@ +error: all variants must use #[app_error(...)] to derive AppError conversion + --> tests/ui/app_error/fail/enum_missing_variant.rs:8:5 + | +8 | #[error("without")] + | ^ + +warning: unused import: `AppErrorKind` + --> tests/ui/app_error/fail/enum_missing_variant.rs:1:17 + | +1 | use masterror::{AppErrorKind, Error}; + | ^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/app_error/fail/missing_code.rs b/tests/ui/app_error/fail/missing_code.rs new file mode 100644 index 0000000..b8c7b1b --- /dev/null +++ b/tests/ui/app_error/fail/missing_code.rs @@ -0,0 +1,13 @@ +use masterror::{AppCode, AppErrorKind, Error}; + +#[derive(Debug, Error)] +enum MissingCode { + #[error("with code")] + #[app_error(kind = AppErrorKind::NotFound, code = AppCode::NotFound)] + WithCode, + #[error("without code")] + #[app_error(kind = AppErrorKind::Service)] + WithoutCode, +} + +fn main() {} diff --git a/tests/ui/app_error/fail/missing_code.stderr b/tests/ui/app_error/fail/missing_code.stderr new file mode 100644 index 0000000..70ccade --- /dev/null +++ b/tests/ui/app_error/fail/missing_code.stderr @@ -0,0 +1,13 @@ +error: AppCode conversion requires `code = ...` in #[app_error(...)] + --> tests/ui/app_error/fail/missing_code.rs:9:5 + | +9 | #[app_error(kind = AppErrorKind::Service)] + | ^ + +warning: unused imports: `AppCode` and `AppErrorKind` + --> tests/ui/app_error/fail/missing_code.rs:1:17 + | +1 | use masterror::{AppCode, AppErrorKind, Error}; + | ^^^^^^^ ^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/app_error/fail/missing_kind.rs b/tests/ui/app_error/fail/missing_kind.rs new file mode 100644 index 0000000..ab9a3cd --- /dev/null +++ b/tests/ui/app_error/fail/missing_kind.rs @@ -0,0 +1,8 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error("oops")] +#[app_error(message)] +struct MissingSpec; + +fn main() {} diff --git a/tests/ui/app_error/fail/missing_kind.stderr b/tests/ui/app_error/fail/missing_kind.stderr new file mode 100644 index 0000000..c615e98 --- /dev/null +++ b/tests/ui/app_error/fail/missing_kind.stderr @@ -0,0 +1,5 @@ +error: missing `kind = ...` in #[app_error(...)] + --> tests/ui/app_error/fail/missing_kind.rs:5:1 + | +5 | #[app_error(message)] + | ^ diff --git a/tests/ui/app_error/pass/enum.rs b/tests/ui/app_error/pass/enum.rs new file mode 100644 index 0000000..2b2a22e --- /dev/null +++ b/tests/ui/app_error/pass/enum.rs @@ -0,0 +1,30 @@ +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +enum ApiError { + #[error("missing resource {id}")] + #[app_error( + kind = AppErrorKind::NotFound, + code = AppCode::NotFound, + message + )] + Missing { id: u64 }, + #[error("backend unavailable")] + #[app_error(kind = AppErrorKind::Service, code = AppCode::Service)] + Backend, +} + +fn main() { + let missing = ApiError::Missing { id: 7 }; + let app_missing: AppError = missing.into(); + assert!(matches!(app_missing.kind, AppErrorKind::NotFound)); + assert_eq!(app_missing.message.as_deref(), Some("missing resource 7")); + + let backend = ApiError::Backend; + let app_backend: AppError = backend.into(); + assert!(matches!(app_backend.kind, AppErrorKind::Service)); + assert!(app_backend.message.is_none()); + + let code: AppCode = ApiError::Backend.into(); + assert!(matches!(code, AppCode::Service)); +} diff --git a/tests/ui/app_error/pass/struct.rs b/tests/ui/app_error/pass/struct.rs new file mode 100644 index 0000000..05b64aa --- /dev/null +++ b/tests/ui/app_error/pass/struct.rs @@ -0,0 +1,18 @@ +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +#[error("missing flag: {name}")] +#[app_error(kind = AppErrorKind::BadRequest, code = AppCode::BadRequest, message)] +struct MissingFlag { + name: &'static str, +} + +fn main() { + let err = MissingFlag { name: "feature" }; + let app: AppError = err.into(); + assert!(matches!(app.kind, AppErrorKind::BadRequest)); + assert_eq!(app.message.as_deref(), Some("missing flag: feature")); + + let code: AppCode = MissingFlag { name: "other" }.into(); + assert!(matches!(code, AppCode::BadRequest)); +} diff --git a/tests/ui/backtrace/duplicate.rs b/tests/ui/backtrace/duplicate.rs new file mode 100644 index 0000000..2a35171 --- /dev/null +++ b/tests/ui/backtrace/duplicate.rs @@ -0,0 +1,12 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error("duplicate backtrace fields")] +struct DuplicateBacktrace { + #[backtrace] + first: std::backtrace::Backtrace, + #[backtrace] + second: std::backtrace::Backtrace +} + +fn main() {} diff --git a/tests/ui/backtrace/duplicate.stderr b/tests/ui/backtrace/duplicate.stderr new file mode 100644 index 0000000..522d31c --- /dev/null +++ b/tests/ui/backtrace/duplicate.stderr @@ -0,0 +1,5 @@ +error: multiple #[backtrace] fields are not supported + --> tests/ui/backtrace/duplicate.rs:8:5 + | +8 | #[backtrace] + | ^^^^^^^^^^^^ diff --git a/tests/ui/backtrace/invalid_type.rs b/tests/ui/backtrace/invalid_type.rs new file mode 100644 index 0000000..3bc51cb --- /dev/null +++ b/tests/ui/backtrace/invalid_type.rs @@ -0,0 +1,10 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error("invalid backtrace field")] +struct InvalidBacktrace { + #[backtrace] + trace: String +} + +fn main() {} diff --git a/tests/ui/backtrace/invalid_type.stderr b/tests/ui/backtrace/invalid_type.stderr new file mode 100644 index 0000000..d59b070 --- /dev/null +++ b/tests/ui/backtrace/invalid_type.stderr @@ -0,0 +1,5 @@ +error: fields with #[backtrace] must be std::backtrace::Backtrace or Option + --> tests/ui/backtrace/invalid_type.rs:6:5 + | +6 | #[backtrace] + | ^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/duplicate_fmt.rs b/tests/ui/formatter/fail/duplicate_fmt.rs new file mode 100644 index 0000000..f9aa4c9 --- /dev/null +++ b/tests/ui/formatter/fail/duplicate_fmt.rs @@ -0,0 +1,14 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error(fmt = crate::format_error, fmt = crate::format_error)] +struct DuplicateFmt; + +fn format_error( + _error: &DuplicateFmt, + f: &mut core::fmt::Formatter<'_> +) -> core::fmt::Result { + f.write_str("duplicate") +} + +fn main() {} diff --git a/tests/ui/formatter/fail/duplicate_fmt.stderr b/tests/ui/formatter/fail/duplicate_fmt.stderr new file mode 100644 index 0000000..5b08225 --- /dev/null +++ b/tests/ui/formatter/fail/duplicate_fmt.stderr @@ -0,0 +1,5 @@ +error: duplicate `fmt` handler specified + --> tests/ui/formatter/fail/duplicate_fmt.rs:4:36 + | +4 | #[error(fmt = crate::format_error, fmt = crate::format_error)] + | ^^^ diff --git a/tests/ui/formatter/fail/duplicate_named_arguments.rs b/tests/ui/formatter/fail/duplicate_named_arguments.rs new file mode 100644 index 0000000..e18b5b5 --- /dev/null +++ b/tests/ui/formatter/fail/duplicate_named_arguments.rs @@ -0,0 +1,9 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error("{value}", value = self.value, value = self.value)] +struct DuplicateNamedArgumentError { + value: &'static str, +} + +fn main() {} diff --git a/tests/ui/formatter/fail/duplicate_named_arguments.stderr b/tests/ui/formatter/fail/duplicate_named_arguments.stderr new file mode 100644 index 0000000..6d94b07 --- /dev/null +++ b/tests/ui/formatter/fail/duplicate_named_arguments.stderr @@ -0,0 +1,5 @@ +error: duplicate format argument `value` + --> tests/ui/formatter/fail/duplicate_named_arguments.rs:4:40 + | +4 | #[error("{value}", value = self.value, value = self.value)] + | ^^^^^ diff --git a/tests/ui/formatter/fail/implicit_after_named.rs b/tests/ui/formatter/fail/implicit_after_named.rs new file mode 100644 index 0000000..9913092 --- /dev/null +++ b/tests/ui/formatter/fail/implicit_after_named.rs @@ -0,0 +1,10 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error("{0}: {}", self.first, self.second)] +struct ImplicitAfterNamedError { + first: &'static str, + second: &'static str, +} + +fn main() {} diff --git a/tests/ui/formatter/fail/implicit_after_named.stderr b/tests/ui/formatter/fail/implicit_after_named.stderr new file mode 100644 index 0000000..d416399 --- /dev/null +++ b/tests/ui/formatter/fail/implicit_after_named.stderr @@ -0,0 +1,11 @@ +error: multiple unused formatting arguments + --> tests/ui/formatter/fail/implicit_after_named.rs:3:17 + | +3 | #[derive(Debug, Error)] + | ^^^^^ + | | + | multiple missing formatting specifiers + | argument never used + | argument never used + | + = note: this error originates in the derive macro `Error` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/formatter/fail/transparent_fmt.rs b/tests/ui/formatter/fail/transparent_fmt.rs new file mode 100644 index 0000000..c14eba3 --- /dev/null +++ b/tests/ui/formatter/fail/transparent_fmt.rs @@ -0,0 +1,11 @@ +use masterror::Error; + +fn format_unit(_f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + Ok(()) +} + +#[derive(Debug, Error)] +#[error(transparent, fmt = crate::format_unit)] +struct TransparentFormatterWrapper(#[from] std::io::Error); + +fn main() {} diff --git a/tests/ui/formatter/fail/transparent_fmt.stderr b/tests/ui/formatter/fail/transparent_fmt.stderr new file mode 100644 index 0000000..96b46aa --- /dev/null +++ b/tests/ui/formatter/fail/transparent_fmt.stderr @@ -0,0 +1,5 @@ +error: format arguments are not supported with #[error(transparent)] + --> tests/ui/formatter/fail/transparent_fmt.rs:8:20 + | +8 | #[error(transparent, fmt = crate::format_unit)] + | ^ diff --git a/tests/ui/formatter/fail/unsupported_flag.rs b/tests/ui/formatter/fail/unsupported_flag.rs new file mode 100644 index 0000000..b04dad4 --- /dev/null +++ b/tests/ui/formatter/fail/unsupported_flag.rs @@ -0,0 +1,9 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error("{value:##x}")] +struct UnsupportedFlag { + value: u32, +} + +fn main() {} diff --git a/tests/ui/formatter/fail/unsupported_flag.stderr b/tests/ui/formatter/fail/unsupported_flag.stderr new file mode 100644 index 0000000..d7acdb1 --- /dev/null +++ b/tests/ui/formatter/fail/unsupported_flag.stderr @@ -0,0 +1,5 @@ +error: placeholder spanning bytes 0..11 uses an unsupported formatter + --> tests/ui/formatter/fail/unsupported_flag.rs:4:9 + | +4 | #[error("{value:##x}")] + | ^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/unsupported_formatter.rs b/tests/ui/formatter/fail/unsupported_formatter.rs new file mode 100644 index 0000000..330f16f --- /dev/null +++ b/tests/ui/formatter/fail/unsupported_formatter.rs @@ -0,0 +1,9 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error("{value:y}")] +struct UnsupportedFormatter { + value: u32, +} + +fn main() {} diff --git a/tests/ui/formatter/fail/unsupported_formatter.stderr b/tests/ui/formatter/fail/unsupported_formatter.stderr new file mode 100644 index 0000000..5869420 --- /dev/null +++ b/tests/ui/formatter/fail/unsupported_formatter.stderr @@ -0,0 +1,5 @@ +error: placeholder spanning bytes 0..9 uses an unsupported formatter + --> tests/ui/formatter/fail/unsupported_formatter.rs:4:9 + | +4 | #[error("{value:y}")] + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_binary.rs b/tests/ui/formatter/fail/uppercase_binary.rs new file mode 100644 index 0000000..902cd3e --- /dev/null +++ b/tests/ui/formatter/fail/uppercase_binary.rs @@ -0,0 +1,9 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error("{value:B}")] +struct UppercaseBinary { + value: u8, +} + +fn main() {} diff --git a/tests/ui/formatter/fail/uppercase_binary.stderr b/tests/ui/formatter/fail/uppercase_binary.stderr new file mode 100644 index 0000000..bbe04b4 --- /dev/null +++ b/tests/ui/formatter/fail/uppercase_binary.stderr @@ -0,0 +1,5 @@ +error: placeholder spanning bytes 0..9 uses an unsupported formatter + --> tests/ui/formatter/fail/uppercase_binary.rs:4:9 + | +4 | #[error("{value:B}")] + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_pointer.rs b/tests/ui/formatter/fail/uppercase_pointer.rs new file mode 100644 index 0000000..4c36df8 --- /dev/null +++ b/tests/ui/formatter/fail/uppercase_pointer.rs @@ -0,0 +1,9 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error("{value:P}")] +struct UppercasePointer { + value: *const u8, +} + +fn main() {} diff --git a/tests/ui/formatter/fail/uppercase_pointer.stderr b/tests/ui/formatter/fail/uppercase_pointer.stderr new file mode 100644 index 0000000..2c30e71 --- /dev/null +++ b/tests/ui/formatter/fail/uppercase_pointer.stderr @@ -0,0 +1,5 @@ +error: placeholder spanning bytes 0..9 uses an unsupported formatter + --> tests/ui/formatter/fail/uppercase_pointer.rs:4:9 + | +4 | #[error("{value:P}")] + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/pass/all_formatters.rs b/tests/ui/formatter/pass/all_formatters.rs new file mode 100644 index 0000000..0c74be1 --- /dev/null +++ b/tests/ui/formatter/pass/all_formatters.rs @@ -0,0 +1,36 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error( + "display={pretty} debug={pretty:?} #debug={pretty:#?} x={value:x} X={value:X} \ + #x={value:#x} #X={value:#X} b={value:b} #b={value:#b} o={value:o} #o={value:#o} \ + e={float:e} #e={float:#e} E={float:E} #E={float:#E} p={ptr:p} #p={ptr:#p}" +)] +struct FormatterVariants { + value: u32, + float: f64, + ptr: *const u32, + pretty: PrettyDebugValue, +} + +#[derive(Debug)] +struct PrettyDebugValue { + label: &'static str, +} + +impl core::fmt::Display for PrettyDebugValue { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(self.label) + } +} + +fn main() { + let showcase = FormatterVariants { + value: 0x5A5Au32, + float: 1234.5, + ptr: core::ptr::null(), + pretty: PrettyDebugValue { label: "alpha" }, + }; + + let _ = showcase.to_string(); +} diff --git a/tests/ui/formatter/pass/display_dynamic_specs.rs b/tests/ui/formatter/pass/display_dynamic_specs.rs new file mode 100644 index 0000000..1acfae1 --- /dev/null +++ b/tests/ui/formatter/pass/display_dynamic_specs.rs @@ -0,0 +1,29 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error("{value:>width$}", value = .value, width = .width)] +struct DynamicWidthError { + value: &'static str, + width: usize, +} + +#[derive(Debug, Error)] +#[error("{value:.precision$}", value = .value, precision = .precision)] +struct DynamicPrecisionError { + value: f64, + precision: usize, +} + +fn main() { + let _ = DynamicWidthError { + value: "aligned", + width: 8, + } + .to_string(); + + let _ = DynamicPrecisionError { + value: 42.4242, + precision: 3, + } + .to_string(); +} diff --git a/tests/ui/formatter/pass/display_specs.rs b/tests/ui/formatter/pass/display_specs.rs new file mode 100644 index 0000000..45ca012 --- /dev/null +++ b/tests/ui/formatter/pass/display_specs.rs @@ -0,0 +1,33 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error("{value:>8}", value = .value)] +struct Alignment { + value: &'static str, +} + +#[derive(Debug, Error)] +#[error("{value:.3}", value = .value)] +struct Precision { + value: f64, +} + +#[derive(Debug, Error)] +#[error("{value:*<6}", value = .value)] +struct Fill { + value: &'static str, +} + +#[derive(Debug, Error)] +#[error("{value:#>4}", value = .value)] +struct HashFill { + value: &'static str, +} + +#[derive(Debug, Error)] +#[error("{value:#>+6}", value = .value)] +struct HashFillWithSign { + value: i32, +} + +fn main() {} diff --git a/tests/ui/formatter/pass/field_shortcuts.rs b/tests/ui/formatter/pass/field_shortcuts.rs new file mode 100644 index 0000000..bf54829 --- /dev/null +++ b/tests/ui/formatter/pass/field_shortcuts.rs @@ -0,0 +1,16 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error("{value}", value = .value)] +struct FieldShortcut { + value: &'static str, +} + +#[derive(Debug, Error)] +#[error("{}:{}", .0, .1)] +struct TupleShortcut(&'static str, &'static str); + +fn main() { + let _ = FieldShortcut { value: "alpha" }.to_string(); + let _ = TupleShortcut("left", "right").to_string(); +} diff --git a/tests/ui/formatter/pass/fmt_path.rs b/tests/ui/formatter/pass/fmt_path.rs new file mode 100644 index 0000000..715dfba --- /dev/null +++ b/tests/ui/formatter/pass/fmt_path.rs @@ -0,0 +1,53 @@ +use masterror::Error; + +fn format_unit(f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("unit") +} + +fn format_pair(left: &i32, right: &i32, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "pair={left}:{right}") +} + +fn format_struct_fields( + count: &usize, + label: &&'static str, + f: &mut core::fmt::Formatter<'_> +) -> core::fmt::Result { + write!(f, "struct={count}:{label}") +} + +#[derive(Debug, Error)] +#[error(fmt = crate::format_struct_fields)] +struct StructFormatter { + count: usize, + label: &'static str, +} + +#[derive(Debug, Error)] +enum EnumFormatter { + #[error(fmt = crate::format_unit)] + Unit, + #[error(fmt = crate::format_pair)] + Tuple(i32, i32), + #[error(fmt = crate::format_pair)] + Named { left: i32, right: i32 }, + #[error(fmt = crate::format_struct_fields)] + Struct { count: usize, label: &'static str } +} + +fn main() { + let _ = StructFormatter { + count: 1, + label: "alpha" + } + .to_string(); + + let _ = EnumFormatter::Unit.to_string(); + let _ = EnumFormatter::Tuple(10, 20).to_string(); + let _ = EnumFormatter::Named { left: 5, right: 15 }.to_string(); + let _ = EnumFormatter::Struct { + count: 2, + label: "beta" + } + .to_string(); +} diff --git a/tests/ui/formatter/pass/format_arguments.rs b/tests/ui/formatter/pass/format_arguments.rs new file mode 100644 index 0000000..c55aacf --- /dev/null +++ b/tests/ui/formatter/pass/format_arguments.rs @@ -0,0 +1,44 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error("{label}::{name}", label = self.label, name = self.name)] +struct NamedArgumentUsage { + label: &'static str, + name: &'static str, +} + +#[derive(Debug, Error)] +#[error("{1}::{0}", self.first, self.second)] +struct PositionalArgumentUsage { + first: &'static str, + second: &'static str, +} + +#[derive(Debug, Error)] +#[error("{}, {label}, {}", label = self.label, self.first, self.second)] +struct MixedImplicitUsage { + label: &'static str, + first: &'static str, + second: &'static str, +} + +fn main() { + let _ = NamedArgumentUsage { + label: "left", + name: "right", + } + .to_string(); + + let _ = PositionalArgumentUsage { + first: "positional-0", + second: "positional-1", + } + .to_string(); + + let _ = MixedImplicitUsage { + label: "tag", + first: "one", + second: "two", + } + .to_string(); +} diff --git a/tests/ui/formatter/pass/individual_formatters.rs b/tests/ui/formatter/pass/individual_formatters.rs new file mode 100644 index 0000000..06dc5e4 --- /dev/null +++ b/tests/ui/formatter/pass/individual_formatters.rs @@ -0,0 +1,70 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error("{value}")] +struct DisplayOnly { + value: &'static str, +} + +#[derive(Debug, Error)] +#[error("{value:?} {value:#?}")] +struct DebugPair { + value: &'static str, +} + +#[derive(Debug, Error)] +#[error("{value:x} {value:#x}")] +struct LowerHexPair { + value: u32, +} + +#[derive(Debug, Error)] +#[error("{value:X} {value:#X}")] +struct UpperHexPair { + value: u32, +} + +#[derive(Debug, Error)] +#[error("{value:b} {value:#b}")] +struct BinaryPair { + value: u16, +} + +#[derive(Debug, Error)] +#[error("{value:o} {value:#o}")] +struct OctalPair { + value: u16, +} + +#[derive(Debug, Error)] +#[error("{value:e} {value:#e}")] +struct LowerExpPair { + value: f64, +} + +#[derive(Debug, Error)] +#[error("{value:E} {value:#E}")] +struct UpperExpPair { + value: f64, +} + +#[derive(Debug, Error)] +#[error("{value:p} {value:#p}")] +struct PointerPair { + value: *const u32, +} + +fn main() { + let _ = DisplayOnly { value: "display" }.to_string(); + let _ = DebugPair { value: "debug" }.to_string(); + let _ = LowerHexPair { value: 0x5A5Au32 }.to_string(); + let _ = UpperHexPair { value: 0x5A5Au32 }.to_string(); + let _ = BinaryPair { value: 0b1010_1100u16 }.to_string(); + let _ = OctalPair { value: 0b1010_1100u16 }.to_string(); + let _ = LowerExpPair { value: 1234.5 }.to_string(); + let _ = UpperExpPair { value: 1234.5 }.to_string(); + let _ = PointerPair { + value: core::ptr::null::() + } + .to_string(); +} diff --git a/tests/ui/formatter/pass/nested_projection.rs b/tests/ui/formatter/pass/nested_projection.rs new file mode 100644 index 0000000..cdaa703 --- /dev/null +++ b/tests/ui/formatter/pass/nested_projection.rs @@ -0,0 +1,43 @@ +use masterror::Error; + +#[derive(Debug)] +struct Limits { + lo: i32, + hi: i32, +} + +#[derive(Debug, Error)] +#[error( + "range {lo}-{hi} suggestion {suggestion}", + lo = .limits.lo, + hi = .limits.hi, + suggestion = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) +)] +struct StructProjection { + limits: Limits, + suggestion: Option, +} + +#[derive(Debug)] +struct Payload { + data: &'static str, +} + +#[derive(Debug, Error)] +enum EnumProjection { + #[error("tuple data {data}", data = .0.data)] + Tuple(Payload), + #[error( + "named suggestion {value}", + value = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) + )] + Named { suggestion: Option }, +} + +fn main() { + let _ = StructProjection { + limits: Limits { lo: 0, hi: 3 }, + suggestion: Some(String::from("hint")), + }; + let _ = EnumProjection::Tuple(Payload { data: "payload" }); +} diff --git a/tests/ui/from/struct_multiple_fields.rs b/tests/ui/from/struct_multiple_fields.rs index a545ce7..c8c2d2f 100644 --- a/tests/ui/from/struct_multiple_fields.rs +++ b/tests/ui/from/struct_multiple_fields.rs @@ -5,6 +5,8 @@ use masterror::Error; struct BadStruct { #[from] left: DummyError, + #[backtrace] + trace: Option, right: DummyError, } diff --git a/tests/ui/from/variant_multiple_fields.rs b/tests/ui/from/variant_multiple_fields.rs index 3d35295..8a05e32 100644 --- a/tests/ui/from/variant_multiple_fields.rs +++ b/tests/ui/from/variant_multiple_fields.rs @@ -2,9 +2,14 @@ use masterror::Error; #[derive(Debug, Error)] enum BadEnum { - #[error("{0} - {1}")] - #[from] - Two(#[source] DummyError, DummyError), + #[error("{source:?} - {extra:?}")] + WithExtra { + #[from] + source: DummyError, + #[backtrace] + trace: Option, + extra: DummyError + } } #[derive(Debug, Error)] diff --git a/tests/ui/from/variant_multiple_fields.stderr b/tests/ui/from/variant_multiple_fields.stderr index e78cc01..fc68934 100644 --- a/tests/ui/from/variant_multiple_fields.stderr +++ b/tests/ui/from/variant_multiple_fields.stderr @@ -1,5 +1,5 @@ -error: not expected here; the #[from] attribute belongs on a specific field - --> tests/ui/from/variant_multiple_fields.rs:6:5 +error: deriving From requires no fields other than source and backtrace + --> tests/ui/from/variant_multiple_fields.rs:7:9 | -6 | #[from] - | ^^^^^^^ +7 | #[from] + | ^^^^^^^ diff --git a/tests/ui/transparent/arguments_not_supported.rs b/tests/ui/transparent/arguments_not_supported.rs new file mode 100644 index 0000000..1245e17 --- /dev/null +++ b/tests/ui/transparent/arguments_not_supported.rs @@ -0,0 +1,7 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error(transparent, code = 42)] +struct TransparentWithArgs(#[from] std::io::Error); + +fn main() {} diff --git a/tests/ui/transparent/arguments_not_supported.stderr b/tests/ui/transparent/arguments_not_supported.stderr new file mode 100644 index 0000000..72addf3 --- /dev/null +++ b/tests/ui/transparent/arguments_not_supported.stderr @@ -0,0 +1,5 @@ +error: format arguments are not supported with #[error(transparent)] + --> tests/ui/transparent/arguments_not_supported.rs:4:20 + | +4 | #[error(transparent, code = 42)] + | ^