diff --git a/.github/scripts/gen_readme.sh b/.github/scripts/gen_readme.sh new file mode 100755 index 0000000..2668be1 --- /dev/null +++ b/.github/scripts/gen_readme.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Deterministic env for local runs too +export TZ="${TZ:-UTC}" +export LC_ALL="${LC_ALL:-C.UTF-8}" +export NO_COLOR="${NO_COLOR:-1}" +export CARGO_TERM_COLOR="${CARGO_TERM_COLOR:-never}" +export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-0}" + +# Allow forcing toolchain via TOOLCHAIN env (e.g. "+1.78.0") +TOOLCHAIN="${TOOLCHAIN:-}" + +# If you use cargo-readme, prefer it +if command -v cargo-readme >/dev/null 2>&1; then + cargo ${TOOLCHAIN} readme > README.md + exit 0 +fi + +# If you have your own generator, call it here instead. +# Examples (uncomment the one you actually use): +# cargo ${TOOLCHAIN} xtask readme +# cargo ${TOOLCHAIN} run -p readme-gen +# cargo ${TOOLCHAIN} run --bin readme_gen +# cargo ${TOOLCHAIN} readme > README.md + +# Fallback: no-op to keep CI green if README is static +touch README.md + diff --git a/.github/workflows/reusable-ci.yml b/.github/workflows/reusable-ci.yml index f8faeb2..94d0e5f 100644 --- a/.github/workflows/reusable-ci.yml +++ b/.github/workflows/reusable-ci.yml @@ -11,14 +11,15 @@ jobs: ci: runs-on: ubuntu-latest env: - CARGO_LOCKED: "true" # don't mutate Cargo.lock during CI + CARGO_LOCKED: "true" CARGO_TERM_COLOR: always + steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: true - # Read MSRV (rust-version) from Cargo.toml - name: Read MSRV from Cargo.toml id: msrv shell: bash @@ -36,14 +37,12 @@ jobs: echo "msrv=${RV}" >> "$GITHUB_OUTPUT" echo "Using MSRV: $RV" - # Install MSRV for clippy/tests/package - name: Install Rust (${{ steps.msrv.outputs.msrv }}) uses: dtolnay/rust-toolchain@v1 with: toolchain: ${{ steps.msrv.outputs.msrv }} components: clippy - # Pin nightly for rustfmt because unstable_features = true in .rustfmt.toml - name: Install nightly rustfmt uses: dtolnay/rust-toolchain@v1 with: @@ -55,7 +54,6 @@ jobs: with: save-if: ${{ github.ref == 'refs/heads/main' }} - # Ensure Cargo.lock is present when CARGO_LOCKED=1 - name: Verify lockfile is committed shell: bash run: | @@ -65,6 +63,61 @@ jobs: exit 1 fi + # ---------- README: regenerate early, normalize, drift handling ---------- + - name: Regenerate README via build.rs (MSRV, deterministic) + shell: bash + run: | + set -euo pipefail + export TZ=UTC + export LC_ALL=C.UTF-8 + export NO_COLOR=1 + export CARGO_TERM_COLOR=never + export SOURCE_DATE_EPOCH=0 + cargo +${{ steps.msrv.outputs.msrv }} build --workspace -q || cargo +${{ steps.msrv.outputs.msrv }} build -q + + - name: Normalize README (ensure trailing newline) — bash only + if: hashFiles('README.md') != '' + shell: bash + run: | + set -euo pipefail + if [ -f README.md ] && [ -s README.md ]; then + last_byte="$(tail -c1 README.md 2>/dev/null || true)" + if [ "$last_byte" != $'\n' ]; then + printf '\n' >> README.md + fi + fi + + - name: README drift report (PR) + if: github.event_name == 'pull_request' + shell: bash + run: | + set -euo pipefail + if git diff --quiet -- README.md; then + echo "README is up to date (PR)." + else + echo "::warning::README differs on PR. Tests will use regenerated content." + git --no-pager diff -- README.md || true + fi + + - name: README drift autocommit (main) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + if git diff --quiet -- README.md; then + echo "README is up to date (main)." + exit 0 + fi + echo "Auto-committing refreshed README on main..." + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add README.md + git commit -m "chore(readme): auto-refresh [skip ci]" + git push + # ----------------------------------------------------------------------- + - name: Check formatting (nightly rustfmt) run: cargo +nightly-2025-08-01 fmt --all -- --check @@ -88,6 +141,17 @@ jobs: cargo +${{ steps.msrv.outputs.msrv }} test --workspace --no-fail-fast fi + # На PR возвращаем README к HEAD, чтобы дерево стало чистым перед упаковкой + - name: Restore README to HEAD on PR (keep tree clean) + if: github.event_name == 'pull_request' + shell: bash + run: | + set -euo pipefail + if ! git diff --quiet -- README.md; then + echo "Restoring README.md to HEAD to keep tree clean on PR..." + git restore --worktree --source=HEAD -- README.md || git checkout -- README.md + fi + - name: Ensure tree is clean before package shell: bash run: | @@ -100,3 +164,4 @@ jobs: - name: Package (dry-run) run: cargo +${{ steps.msrv.outputs.msrv }} package --locked + diff --git a/.hooks/pre-commit b/.hooks/pre-commit index ded56d9..3f40663 100755 --- a/.hooks/pre-commit +++ b/.hooks/pre-commit @@ -20,5 +20,20 @@ cargo test --workspace --all-features # echo "📦 Validating SQLx prepare..." # cargo sqlx prepare --check --workspace +# same deterministic env +export TZ=UTC +export LC_ALL=C.UTF-8 +export NO_COLOR=1 +export CARGO_TERM_COLOR=never +export SOURCE_DATE_EPOCH=0 + +# Generate +./.github/scripts/gen_readme.sh + +# Stage README if changed +if ! git diff --quiet -- README.md; then + git add README.md +fi + echo "✅ All checks passed!" diff --git a/CHANGELOG.md b/CHANGELOG.md index c21a1b5..75aa5b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,35 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +_No changes yet._ + +## [0.5.0] - 2025-09-23 + +### Added +- Re-exported `thiserror::Error` as `masterror::Error`, making it possible to + derive domain errors without an extra dependency. The derive supports + `#[from]` conversions, validates `#[error(transparent)]` wrappers, and mirrors + `thiserror`'s ergonomics. +- Added `BrowserConsoleError::context()` for retrieving browser-provided + diagnostics when console logging fails. + +### Changed +- README generation now pulls from crate metadata via the build script while + staying inert during `cargo package`, preventing dirty worktrees in release + workflows. + +### Documentation +- Documented deriving custom errors via `masterror::Error` and expanded the + browser console section with context-handling guidance. +- Added a release checklist and described the automated README sync process. + +### Tests +- Added regression tests covering derive behaviour (including `#[from]` and + transparent wrappers) and ensuring the README stays in sync with its + template. +- Added a guard test that enforces the `AppResult<_>` alias over raw + `Result<_, AppError>` usages within the crate. + ## [0.4.0] - 2025-09-15 ### Added - Optional `frontend` feature: @@ -113,6 +142,8 @@ All notable changes to this project will be documented in this file. - **MSRV:** 1.89 - **No unsafe:** the crate forbids `unsafe`. +[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 [0.3.4]: https://github.com/RAprogramm/masterror/releases/tag/v0.3.4 [0.3.3]: https://github.com/RAprogramm/masterror/releases/tag/v0.3.3 @@ -121,5 +152,4 @@ All notable changes to this project will be documented in this file. [0.3.0]: https://github.com/RAprogramm/masterror/releases/tag/v0.3.0 [0.2.1]: https://github.com/RAprogramm/masterror/releases/tag/v0.2.1 [0.2.0]: https://github.com/RAprogramm/masterror/releases/tag/v0.2.0 -[0.4.0]: https://github.com/RAprogramm/masterror/releases/tag/v0.4.0 diff --git a/Cargo.lock b/Cargo.lock index 72bb530..c367eb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -380,18 +380,18 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "bytestring" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e465647ae23b2823b0753f50decb2d5a86d2bb2cac04788fafd1f80e45378e5f" +checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" dependencies = [ "bytes", ] [[package]] name = "cc" -version = "1.2.36" +version = "1.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5252b3d2648e5eedbc1a6f501e3c795e07025c1e93bbf8bbdd6eef7f447a6d54" +checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" dependencies = [ "find-msvc-tools", "shlex", @@ -412,7 +412,7 @@ dependencies = [ "iana-time-zone", "num-traits", "serde", - "windows-link 0.2.0", + "windows-link", ] [[package]] @@ -436,9 +436,9 @@ dependencies = [ [[package]] name = "config" -version = "0.15.15" +version = "0.15.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0faa974509d38b33ff89282db9c3295707ccf031727c0de9772038ec526852ba" +checksum = "cef036f0ecf99baef11555578630e2cca559909b4c50822dbba828c252d21c49" dependencies = [ "async-trait", "convert_case", @@ -446,10 +446,10 @@ dependencies = [ "pathdiff", "ron", "rust-ini", - "serde", "serde-untagged", + "serde_core", "serde_json", - "toml", + "toml 0.9.6", "winnow", "yaml-rust2", ] @@ -779,14 +779,25 @@ dependencies = [ [[package]] name = "erased-serde" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7" +checksum = "259d404d09818dec19332e31d94558aeb442fea04c817006456c24b5460bbd4b" dependencies = [ "serde", + "serde_core", "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.0", +] + [[package]] name = "etcetera" version = "0.8.0" @@ -809,6 +820,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -983,7 +1000,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.5+wasi-0.2.4", + "wasi 0.14.7+wasi-0.2.4", ] [[package]] @@ -992,6 +1009,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "hashbrown" version = "0.12.3" @@ -1149,9 +1172,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ "base64 0.22.1", "bytes", @@ -1173,9 +1196,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1327,13 +1350,14 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.11.1" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "206a8042aec68fa4a62e8d3f7aa4ceb508177d9324faf261e1959e495b7a1921" +checksum = "92119844f513ffa41556430369ab02c295a3578af21cf945caa3e9e0c2481ac3" dependencies = [ "equivalent", "hashbrown 0.15.5", "serde", + "serde_core", ] [[package]] @@ -1371,9 +1395,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.78" +version = "0.3.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0b063578492ceec17683ef2f8c5e89121fbd0b172cbc280635ab7567db2738" +checksum = "6247da8b8658ad4e73a186e747fcc5fc2a29f979d6fe6269127fdb5fd08298d0" dependencies = [ "once_cell", "wasm-bindgen", @@ -1419,9 +1443,9 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ "bitflags", "libc", @@ -1438,6 +1462,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.0" @@ -1479,9 +1509,9 @@ checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "masterror" -version = "0.3.3" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e7c3a243a6f697e05d0b971c22d0ac029b9080c20b2bbc5f4a3f43ea6024a60" +checksum = "3d38aeb53944762378aa5219b9929f2f3346a25fdd9b61266c24a487200c87fd" dependencies = [ "http 1.3.1", "serde", @@ -1491,7 +1521,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.4.0" +version = "0.5.0" dependencies = [ "actix-web", "axum", @@ -1506,9 +1536,12 @@ dependencies = [ "sqlx", "telegram-webapp-sdk", "teloxide-core", + "tempfile", "thiserror", "tokio", + "toml 0.9.6", "tracing", + "trybuild", "utoipa", "validator", "wasm-bindgen", @@ -1730,9 +1763,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" +checksum = "21e0a3a33733faeaf8651dfee72dd0f388f0c8e5ad496a3478fa5a922f49cfa8" dependencies = [ "memchr", "thiserror", @@ -1741,9 +1774,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb056d9e8ea77922845ec74a1c4e8fb17e7c218cc4fc11a15c5d25e189aa40bc" +checksum = "bc58706f770acb1dbd0973e6530a3cff4746fb721207feb3a8a6064cd0b6c663" dependencies = [ "pest", "pest_generator", @@ -1751,9 +1784,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e404e638f781eb3202dc82db6760c8ae8a1eeef7fb3fa8264b2ef280504966" +checksum = "6d4f36811dfe07f7b8573462465d5cb8965fffc2e71ae377a33aecf14c2c9a2f" dependencies = [ "pest", "pest_meta", @@ -1764,9 +1797,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd1101f170f5903fde0914f899bb503d9ff5271d7ba76bbb70bea63690cc0d5" +checksum = "42919b05089acbd0a5dcd5405fb304d17d1053847b81163d09c4ad18ce8e8420" dependencies = [ "pest", "sha2", @@ -2159,6 +2192,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.0", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2203,27 +2249,29 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "semver" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" dependencies = [ + "serde_core", "serde_derive", ] [[package]] name = "serde-untagged" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34836a629bcbc6f1afdf0907a744870039b1e14c0561cb26094fa683b158eff3" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" dependencies = [ "erased-serde", "serde", + "serde_core", "typeid", ] @@ -2238,11 +2286,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "serde_core" +version = "1.0.225" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" +dependencies = [ + "serde_derive", +] + [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" dependencies = [ "proc-macro2", "quote", @@ -2251,35 +2308,46 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] name = "serde_path_to_error" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ "itoa", "serde", + "serde_core", ] [[package]] name = "serde_spanned" -version = "1.0.0" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2789234a13a53fc4be1b51ea1bab45a3c338bdb884862a257d10e5a74ae009e6" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2302,7 +2370,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.11.1", + "indexmap 2.11.3", "schemars 0.9.0", "schemars 1.0.4", "serde", @@ -2456,7 +2524,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap 2.11.1", + "indexmap 2.11.3", "log", "memchr", "once_cell", @@ -2690,18 +2758,24 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20f34339676cdcab560c9a82300c4c2581f68b9369aedf0fae86f2ff9565ff3e" +[[package]] +name = "target-triple" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac9aa371f599d22256307c24a9d748c041e548cbf599f35d890f9d365361790" + [[package]] name = "telegram-webapp-sdk" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80632ebd5e273b42ddff5f444be5d6e2d7976a283008853a3b3af6a183f936e3" +checksum = "fb72a0fd8e65f7c9279d164e9b00661e6685f74c0ba63e7f17b30662d5aed21b" dependencies = [ "base64 0.22.1", "ed25519-dalek", "hex", "hmac-sha256", "js-sys", - "masterror 0.3.3", + "masterror 0.3.5", "once_cell", "percent-encoding", "serde", @@ -2709,6 +2783,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "thiserror", + "toml 0.8.23", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -2746,6 +2821,28 @@ dependencies = [ "uuid", ] +[[package]] +name = "tempfile" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.61.0", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "2.0.16" @@ -2876,26 +2973,63 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.5" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + +[[package]] +name = "toml" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae2a4cf385da23d1d53bc15cdfa5c2109e93d8d362393c801e87da2f72f0e201" +dependencies = [ + "indexmap 2.11.3", + "serde_core", + "serde_spanned 1.0.1", + "toml_datetime 0.7.1", "toml_parser", + "toml_writer", "winnow", ] [[package]] name = "toml_datetime" -version = "0.7.0" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a197c0ec7d131bfc6f7e82c8442ba1595aeab35da7adbf05b6b73cd06a16b6be" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.11.3", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + [[package]] name = "toml_parser" version = "1.0.2" @@ -2905,6 +3039,18 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" + [[package]] name = "tower" version = "0.5.2" @@ -2988,6 +3134,21 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "trybuild" +version = "1.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ded9fdb81f30a5708920310bfcd9ea7482ff9cba5f54601f7a19a877d5c2392" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml 0.9.6", +] + [[package]] name = "typeid" version = "1.0.3" @@ -3075,7 +3236,7 @@ version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" dependencies = [ - "indexmap 2.11.1", + "indexmap 2.11.3", "serde", "serde_json", "utoipa-gen", @@ -3162,18 +3323,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.5+wasi-0.2.4" +version = "0.14.7+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4494f6290a82f5fe584817a676a34b9d6763e8d9d18204009fb31dceca98fd4" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" dependencies = [ "wasip2", ] [[package]] name = "wasip2" -version = "1.0.0+wasi-0.2.4" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03fa2761397e5bd52002cd7e73110c71af2109aca4e521a9f40473fe685b0a24" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ "wit-bindgen", ] @@ -3186,9 +3347,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.101" +version = "0.2.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e14915cadd45b529bb8d1f343c4ed0ac1de926144b746e2710f9cd05df6603b" +checksum = "4ad224d2776649cfb4f4471124f8176e54c1cca67a88108e30a0cd98b90e7ad3" dependencies = [ "cfg-if", "once_cell", @@ -3199,9 +3360,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.101" +version = "0.2.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e28d1ba982ca7923fd01448d5c30c6864d0a14109560296a162f80f305fb93bb" +checksum = "3a1364104bdcd3c03f22b16a3b1c9620891469f5e9f09bc38b2db121e593e732" dependencies = [ "bumpalo", "log", @@ -3213,9 +3374,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.51" +version = "0.4.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ca85039a9b469b38336411d6d6ced91f3fc87109a2a27b0c197663f5144dffe" +checksum = "9c0a08ecf5d99d5604a6666a70b3cde6ab7cc6142f5e641a8ef48fc744ce8854" dependencies = [ "cfg-if", "js-sys", @@ -3226,9 +3387,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.101" +version = "0.2.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c3d463ae3eff775b0c45df9da45d68837702ac35af998361e2c84e7c5ec1b0d" +checksum = "0d7ab4ca3e367bb1ed84ddbd83cc6e41e115f8337ed047239578210214e36c76" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3236,9 +3397,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.101" +version = "0.2.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa" +checksum = "4a518014843a19e2dbbd0ed5dfb6b99b23fb886b14e6192a00803a3e14c552b0" dependencies = [ "proc-macro2", "quote", @@ -3249,9 +3410,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.101" +version = "0.2.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f143854a3b13752c6950862c906306adb27c7e839f7414cec8fea35beab624c1" +checksum = "255eb0aa4cc2eea3662a00c2bbd66e93911b7361d5e0fcd62385acfd7e15dcee" dependencies = [ "unicode-ident", ] @@ -3271,9 +3432,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.78" +version = "0.3.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77e4b637749ff0d92b8fad63aa1f7cff3cbe125fd49c175cd6345e7272638b12" +checksum = "50462a022f46851b81d5441d1a6f5bac0b21a1d72d64bd4906fbdd4bf7230ec7" dependencies = [ "js-sys", "wasm-bindgen", @@ -3289,15 +3450,24 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.0", +] + [[package]] name = "windows-core" -version = "0.61.2" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.1.3", + "windows-link", "windows-result", "windows-strings", ] @@ -3324,12 +3494,6 @@ dependencies = [ "syn", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.0" @@ -3338,20 +3502,20 @@ checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" [[package]] name = "windows-result" -version = "0.3.4" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] name = "windows-strings" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -3381,6 +3545,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -3513,9 +3686,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.45.1" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" @@ -3525,9 +3698,9 @@ checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" [[package]] name = "yaml-rust2" -version = "0.10.3" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ce2a4ff45552406d02501cea6c18d8a7e50228e7736a872951fe2fe75c91be7" +checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" dependencies = [ "arraydeque", "encoding_rs", diff --git a/Cargo.toml b/Cargo.toml index 65a33e7..ecaf8ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.4.0" +version = "0.5.0" rust-version = "1.89" edition = "2024" description = "Application error types and response mapping" @@ -8,19 +8,20 @@ license = "MIT OR Apache-2.0" documentation = "https://docs.rs/masterror" repository = "https://github.com/RAprogramm/masterror" readme = "README.md" +build = "build.rs" categories = ["rust-patterns", "web-programming"] keywords = ["error", "api", "framework"] [features] default = [] -axum = ["dep:axum", "dep:serde_json"] # IntoResponse + JSON body +axum = ["dep:axum", "dep:serde_json"] # IntoResponse + JSON body actix = ["dep:actix-web", "dep:serde_json"] sqlx = ["dep:sqlx"] redis = ["dep:redis"] validator = ["dep:validator"] serde_json = ["dep:serde_json"] -config = ["dep:config"] # config::ConfigError -> AppError +config = ["dep:config"] # config::ConfigError -> AppError multipart = ["axum"] tokio = ["dep:tokio"] reqwest = ["dep:reqwest"] @@ -58,7 +59,7 @@ utoipa = { version = "5.4", optional = true } tokio = { version = "1", optional = true, features = ["time"] } reqwest = { version = "0.12", optional = true, default-features = false } teloxide-core = { version = "0.13", optional = true, default-features = false } -telegram-webapp-sdk = { version = "0.1", optional = true } +telegram-webapp-sdk = { version = "0.2", optional = true } wasm-bindgen = { version = "0.2", optional = true } js-sys = { version = "0.3", optional = true } serde-wasm-bindgen = { version = "0.6", optional = true } @@ -71,6 +72,92 @@ tokio = { version = "1", features = [ "net", "time", ], default-features = false } +trybuild = "1" + +toml = "0.9" +tempfile = "3" + +[build-dependencies] +serde = { version = "1", features = ["derive"] } +toml = "0.9" + +[package.metadata.masterror.readme] +feature_order = [ + "axum", + "actix", + "openapi", + "serde_json", + "sqlx", + "reqwest", + "redis", + "validator", + "config", + "tokio", + "multipart", + "teloxide", + "telegram-webapp-sdk", + "frontend", + "turnkey", +] +feature_snippet_group = 4 +conversion_lines = [ + "`std::io::Error` → Internal", + "`String` → BadRequest", + "`sqlx::Error` → NotFound/Database", + "`redis::RedisError` → Cache", + "`reqwest::Error` → Timeout/Network/ExternalApi", + "`axum::extract::multipart::MultipartError` → BadRequest", + "`validator::ValidationErrors` → Validation", + "`config::ConfigError` → Config", + "`tokio::time::error::Elapsed` → Timeout", + "`teloxide_core::RequestError` → RateLimited/Network/ExternalApi/Deserialization/Internal", + "`telegram_webapp_sdk::utils::validate_init_data::ValidationError` → TelegramAuth", +] + +[package.metadata.masterror.readme.features.axum] +description = "IntoResponse integration with structured JSON bodies" + +[package.metadata.masterror.readme.features.actix] +description = "Actix Web ResponseError and Responder implementations" + +[package.metadata.masterror.readme.features.openapi] +description = "Generate utoipa OpenAPI schema for ErrorResponse" + +[package.metadata.masterror.readme.features.serde_json] +description = "Attach structured JSON details to AppError" + +[package.metadata.masterror.readme.features.sqlx] +description = "Classify sqlx::Error variants into AppError kinds" + +[package.metadata.masterror.readme.features.redis] +description = "Map redis::RedisError into cache-aware AppError" + +[package.metadata.masterror.readme.features.validator] +description = "Convert validator::ValidationErrors into validation failures" + +[package.metadata.masterror.readme.features.config] +description = "Propagate config::ConfigError as configuration issues" + +[package.metadata.masterror.readme.features.multipart] +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" + +[package.metadata.masterror.readme.features."telegram-webapp-sdk"] +description = "Surface Telegram WebApp validation failures" + +[package.metadata.masterror.readme.features.frontend] +description = "Log to the browser console and convert to JsValue on WASM" + +[package.metadata.masterror.readme.features.turnkey] +description = "Ship Turnkey-specific error taxonomy and conversions" [lib] crate-type = ["cdylib", "rlib"] diff --git a/README.md b/README.md index a920457..d2cd794 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # masterror · Framework-agnostic application error types + + [![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) @@ -7,8 +10,8 @@ ![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) -Small, pragmatic error model for API-heavy Rust services. -Core is framework-agnostic; integrations are opt-in via feature flags. +Small, pragmatic error model for API-heavy Rust services. +Core is framework-agnostic; integrations are opt-in via feature flags. Stable categories, conservative HTTP mapping, no `unsafe`. - Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse` @@ -22,14 +25,17 @@ Stable categories, conservative HTTP mapping, no `unsafe`. ~~~toml [dependencies] -masterror = { version = "0.4", default-features = false } +masterror = { version = "0.5.0", default-features = false } # or with features: -# masterror = { version = "0.4", features = [ -# "axum", "actix", "serde_json", "openapi", -# "sqlx", "reqwest", "redis", "validator", "config", "tokio", "teloxide" +# masterror = { version = "0.5.0", features = [ +# "axum", "actix", "openapi", "serde_json", +# "sqlx", "reqwest", "redis", "validator", +# "config", "tokio", "multipart", "teloxide", +# "telegram-webapp-sdk", "frontend", "turnkey" # ] } ~~~ +*Since v0.5.0: derive custom errors via `#[derive(Error)]` (`use masterror::Error;`) and inspect browser logging failures with `BrowserConsoleError::context()`.* *Since v0.4.0: optional `frontend` feature for WASM/browser console logging.* *Since v0.3.0: stable `AppCode` enum and extended `ErrorResponse` with retry/authentication metadata.* @@ -43,7 +49,9 @@ masterror = { version = "0.4", default-features = false } - **Opt-in integrations.** Zero default features; you enable what you need. - **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. +- **Less boilerplate.** Built-in conversions, compact prelude, and the + `masterror::Error` re-export of `thiserror::Error` with `#[from]` / + `#[error(transparent)]` support. - **Consistent workspace.** Same error surface across crates. @@ -54,16 +62,18 @@ masterror = { version = "0.4", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.4", default-features = false } +masterror = { version = "0.5.0", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.4", features = [ -# "axum", "actix", "serde_json", "openapi", -# "sqlx", "reqwest", "redis", "validator", "config", "tokio", "teloxide" +# masterror = { version = "0.5.0", features = [ +# "axum", "actix", "openapi", "serde_json", +# "sqlx", "reqwest", "redis", "validator", +# "config", "tokio", "multipart", "teloxide", +# "telegram-webapp-sdk", "frontend", "turnkey" # ] } ~~~ -**MSRV:** 1.89 +**MSRV:** 1.89 **No unsafe:** forbidden by crate. @@ -96,92 +106,85 @@ fn do_work(flag: bool) -> AppResult<()> {
- Error response payload + Derive custom errors ~~~rust -use masterror::{AppError, AppErrorKind, AppCode, ErrorResponse}; -use std::time::Duration; - -let app_err = AppError::new(AppErrorKind::Unauthorized, "Token expired"); -let resp: ErrorResponse = (&app_err).into() - .with_retry_after_duration(Duration::from_secs(30)) - .with_www_authenticate(r#"Bearer realm="api", error="invalid_token""#); - -assert_eq!(resp.status, 401); -~~~ - -
+use std::io; -
- Web framework integrations +use masterror::Error; -
- Axum +#[derive(Debug, Error)] +#[error("I/O failed: {source}")] +pub struct DomainError { + #[from] + #[source] + source: io::Error, +} -~~~rust -// features = ["axum", "serde_json"] -use masterror::{AppError, AppResult}; -use axum::{routing::get, Router}; +#[derive(Debug, Error)] +#[error(transparent)] +pub struct WrappedDomainError( + #[from] + #[source] + DomainError +); -async fn handler() -> AppResult<&'static str> { - Err(AppError::forbidden("No access")) +fn load() -> Result<(), DomainError> { + Err(io::Error::other("disk offline").into()) } -let app = Router::new().route("/demo", get(handler)); +let err = load().unwrap_err(); +assert_eq!(err.to_string(), "I/O failed: disk offline"); + +let wrapped = WrappedDomainError::from(err); +assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); ~~~ +- `use masterror::Error;` re-exports `thiserror::Error`. +- `#[from]` automatically implements `From<...>` while ensuring wrapper shapes are + valid. +- `#[error(transparent)]` enforces single-field wrappers that forward + `Display`/`source` to the inner error. +
- Actix + Error response payload ~~~rust -// features = ["actix", "serde_json"] -use actix_web::{get, App, HttpServer, Responder}; -use masterror::prelude::*; +use masterror::{AppError, AppErrorKind, AppCode, ErrorResponse}; +use std::time::Duration; -#[get("/err")] -async fn err() -> AppResult<&'static str> { - Err(AppError::forbidden("No access")) -} +let app_err = AppError::new(AppErrorKind::Unauthorized, "Token expired"); +let resp: ErrorResponse = (&app_err).into() + .with_retry_after_duration(Duration::from_secs(30)) + .with_www_authenticate(r#"Bearer realm="api", error="invalid_token""#); -#[get("/payload")] -async fn payload() -> impl Responder { - ErrorResponse::new(422, AppCode::Validation, "Validation failed") - .expect("status") -} +assert_eq!(resp.status, 401); ~~~
-
-
- OpenAPI - -~~~toml -[dependencies] -masterror = { version = "0.4", features = ["openapi", "serde_json"] } -utoipa = "5" -~~~ - -
+ Web framework integrations
- Browser (WASM) + Axum ~~~rust -// features = ["frontend"] -use masterror::{AppError, AppErrorKind, AppResult}; -use masterror::frontend::{BrowserConsoleError, BrowserConsoleExt}; - -fn report() -> AppResult<(), BrowserConsoleError> { - let err = AppError::bad_request("missing field"); - let payload = err.to_js_value()?; +// features = ["axum", "serde_json"] +... assert!(payload.is_object()); #[cfg(target_arch = "wasm32")] - err.log_to_browser_console()?; + { + if let Err(console_err) = err.log_to_browser_console() { + eprintln!( + "failed to log to browser console: {:?}", + console_err.context() + ); + } + } Ok(()) } @@ -189,19 +192,29 @@ fn report() -> AppResult<(), BrowserConsoleError> { - On non-WASM targets `log_to_browser_console` returns `BrowserConsoleError::UnsupportedTarget`. +- `BrowserConsoleError::context()` exposes optional browser diagnostics for + logging/telemetry when console logging fails.
Feature flags -- `axum` — IntoResponse -- `actix` — ResponseError/Responder -- `openapi` — utoipa schema -- `serde_json` — JSON details -- `sqlx`, `redis`, `reqwest`, `validator`, `config`, `tokio`, `multipart`, `teloxide`, `telegram-webapp-sdk` -- `frontend` — convert errors into `JsValue` and log via `console.error` (WASM) -- `turnkey` — domain taxonomy and conversions for Turnkey errors +- `axum` — IntoResponse integration with structured JSON bodies +- `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 +- `reqwest` — Classify reqwest::Error as timeout/network/external API +- `redis` — Map redis::RedisError into cache-aware AppError +- `validator` — Convert validator::ValidationErrors into validation failures +- `config` — Propagate config::ConfigError as configuration issues +- `tokio` — Classify tokio::time::error::Elapsed as timeout +- `multipart` — Handle axum multipart extraction errors +- `teloxide` — Convert teloxide_core::RequestError into domain errors +- `telegram-webapp-sdk` — Surface Telegram WebApp validation failures +- `frontend` — Log to the browser console and convert to JsValue on WASM +- `turnkey` — Ship Turnkey-specific error taxonomy and conversions
@@ -228,13 +241,13 @@ fn report() -> AppResult<(), BrowserConsoleError> { Minimal core: ~~~toml -masterror = { version = "0.4", default-features = false } +masterror = { version = "0.5.0", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.4", features = [ +masterror = { version = "0.5.0", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -243,7 +256,7 @@ masterror = { version = "0.4", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.4", features = [ +masterror = { version = "0.5.0", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -274,25 +287,37 @@ assert_eq!(app.kind, AppErrorKind::RateLimited);
Migration 0.2 → 0.3 -- Use `ErrorResponse::new(status, AppCode::..., "msg")` instead of legacy +- Use `ErrorResponse::new(status, AppCode::..., "msg")` instead of legacy - New helpers: `.with_retry_after_secs`, `.with_retry_after_duration`, `.with_www_authenticate` -- `ErrorResponse::new_legacy` is temporary shim +- `ErrorResponse::new_legacy` is temporary shim
Versioning & MSRV -Semantic versioning. Breaking API/wire contract → major bump. +Semantic versioning. Breaking API/wire contract → major bump. MSRV = 1.89 (may raise in minor, never in patch).
+
+ Release checklist + +1. `cargo +nightly fmt --` +1. `cargo clippy -- -D warnings` +1. `cargo test --all` +1. `cargo build` (regenerates README.md from the template) +1. `cargo doc --no-deps` +1. `cargo package --locked` + +
+
Non-goals -- Not a general-purpose error aggregator like `anyhow` -- Not a replacement for your domain errors +- Not a general-purpose error aggregator like `anyhow` +- Not a replacement for your domain errors
diff --git a/README.template.md b/README.template.md new file mode 100644 index 0000000..62215a8 --- /dev/null +++ b/README.template.md @@ -0,0 +1,299 @@ +# masterror · Framework-agnostic application error types + + + +[![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-{{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) + +Small, pragmatic error model for API-heavy Rust services. +Core is framework-agnostic; integrations are opt-in via feature flags. +Stable categories, conservative HTTP mapping, no `unsafe`. + +- Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse` +- Optional Axum/Actix integration +- Optional OpenAPI schema (via `utoipa`) +- Conversions from `sqlx`, `reqwest`, `redis`, `validator`, `config`, `tokio` + +--- + +### TL;DR + +~~~toml +[dependencies] +masterror = { version = "{{CRATE_VERSION}}", default-features = false } +# or with features: +# masterror = { version = "{{CRATE_VERSION}}", features = [ +{{FEATURE_SNIPPET}} +# ] } +~~~ + +*Since v0.5.0: derive custom errors via `#[derive(Error)]` (`use masterror::Error;`) and inspect browser logging failures with `BrowserConsoleError::context()`.* +*Since v0.4.0: optional `frontend` feature for WASM/browser console logging.* +*Since v0.3.0: stable `AppCode` enum and extended `ErrorResponse` with retry/authentication metadata.* + +--- + +
+ Why this crate? + +- **Stable taxonomy.** Small set of `AppErrorKind` categories mapping conservatively to HTTP. +- **Framework-agnostic.** No assumptions, no `unsafe`, MSRV pinned. +- **Opt-in integrations.** Zero default features; you enable what you need. +- **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. +- **Consistent workspace.** Same error surface across crates. + +
+ +
+ Installation + +~~~toml +[dependencies] +# lean core +masterror = { version = "{{CRATE_VERSION}}", default-features = false } + +# with Axum/Actix + JSON + integrations +# masterror = { version = "{{CRATE_VERSION}}", features = [ +{{FEATURE_SNIPPET}} +# ] } +~~~ + +**MSRV:** {{MSRV}} +**No unsafe:** forbidden by crate. + +
+ +
+ Quick start + +Create an error: + +~~~rust +use masterror::{AppError, AppErrorKind}; + +let err = AppError::new(AppErrorKind::BadRequest, "Flag must be set"); +assert!(matches!(err.kind, AppErrorKind::BadRequest)); +~~~ + +With prelude: + +~~~rust +use masterror::prelude::*; + +fn do_work(flag: bool) -> AppResult<()> { + if !flag { + return Err(AppError::bad_request("Flag must be set")); + } + Ok(()) +} +~~~ + +
+ +
+ Derive custom errors + +~~~rust +use std::io; + +use masterror::Error; + +#[derive(Debug, Error)] +#[error("I/O failed: {source}")] +pub struct DomainError { + #[from] + #[source] + source: io::Error, +} + +#[derive(Debug, Error)] +#[error(transparent)] +pub struct WrappedDomainError( + #[from] + #[source] + DomainError +); + +fn load() -> Result<(), DomainError> { + Err(io::Error::other("disk offline").into()) +} + +let err = load().unwrap_err(); +assert_eq!(err.to_string(), "I/O failed: disk offline"); + +let wrapped = WrappedDomainError::from(err); +assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); +~~~ + +- `use masterror::Error;` re-exports `thiserror::Error`. +- `#[from]` automatically implements `From<...>` while ensuring wrapper shapes are + valid. +- `#[error(transparent)]` enforces single-field wrappers that forward + `Display`/`source` to the inner error. + +
+ +
+ Error response payload + +~~~rust +use masterror::{AppError, AppErrorKind, AppCode, ErrorResponse}; +use std::time::Duration; + +let app_err = AppError::new(AppErrorKind::Unauthorized, "Token expired"); +let resp: ErrorResponse = (&app_err).into() + .with_retry_after_duration(Duration::from_secs(30)) + .with_www_authenticate(r#"Bearer realm="api", error="invalid_token""#); + +assert_eq!(resp.status, 401); +~~~ + +
+ +
+ Web framework integrations + +
+ Axum + +~~~rust +// features = ["axum", "serde_json"] +... + assert!(payload.is_object()); + + #[cfg(target_arch = "wasm32")] + { + if let Err(console_err) = err.log_to_browser_console() { + eprintln!( + "failed to log to browser console: {:?}", + console_err.context() + ); + } + } + + Ok(()) +} +~~~ + +- On non-WASM targets `log_to_browser_console` returns + `BrowserConsoleError::UnsupportedTarget`. +- `BrowserConsoleError::context()` exposes optional browser diagnostics for + logging/telemetry when console logging fails. + +
+ +
+ Feature flags + +{{FEATURE_BULLETS}} + +
+ +
+ Conversions + +{{CONVERSION_BULLETS}} + +
+ +
+ Typical setups + +Minimal core: + +~~~toml +masterror = { version = "{{CRATE_VERSION}}", default-features = false } +~~~ + +API (Axum + JSON + deps): + +~~~toml +masterror = { version = "{{CRATE_VERSION}}", features = [ + "axum", "serde_json", "openapi", + "sqlx", "reqwest", "redis", "validator", "config", "tokio" +] } +~~~ + +API (Actix + JSON + deps): + +~~~toml +masterror = { version = "{{CRATE_VERSION}}", features = [ + "actix", "serde_json", "openapi", + "sqlx", "reqwest", "redis", "validator", "config", "tokio" +] } +~~~ + +
+ +
+ Turnkey + +~~~rust +// features = ["turnkey"] +use masterror::turnkey::{classify_turnkey_error, TurnkeyError, TurnkeyErrorKind}; +use masterror::{AppError, AppErrorKind}; + +// Classify a raw SDK/provider error +let kind = classify_turnkey_error("429 Too Many Requests"); +assert!(matches!(kind, TurnkeyErrorKind::RateLimited)); + +// Wrap into AppError +let e = TurnkeyError::new(TurnkeyErrorKind::RateLimited, "throttled upstream"); +let app: AppError = e.into(); +assert_eq!(app.kind, AppErrorKind::RateLimited); +~~~ + +
+ +
+ Migration 0.2 → 0.3 + +- Use `ErrorResponse::new(status, AppCode::..., "msg")` instead of legacy +- New helpers: `.with_retry_after_secs`, `.with_retry_after_duration`, `.with_www_authenticate` +- `ErrorResponse::new_legacy` is temporary shim + +
+ +
+ Versioning & MSRV + +Semantic versioning. Breaking API/wire contract → major bump. +MSRV = {{MSRV}} (may raise in minor, never in patch). + +
+ +
+ Release checklist + +1. `cargo +nightly fmt --` +1. `cargo clippy -- -D warnings` +1. `cargo test --all` +1. `cargo build` (regenerates README.md from the template) +1. `cargo doc --no-deps` +1. `cargo package --locked` + +
+ +
+ Non-goals + +- Not a general-purpose error aggregator like `anyhow` +- Not a replacement for your domain errors + +
+ +
+ License + +Apache-2.0 OR MIT, at your option. + +
diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..40e16de --- /dev/null +++ b/build.rs @@ -0,0 +1,85 @@ +use std::{ + env, + path::{Path, PathBuf}, + process +}; + +use crate::readme::{sync_readme, verify_readme_relaxed}; + +#[path = "build/readme.rs"] +mod readme; + +fn main() { + if let Err(err) = run() { + eprintln!("error: {err}"); + process::exit(1); + } +} + +fn run() -> Result<(), Box> { + println!("cargo:rerun-if-changed=Cargo.toml"); + println!("cargo:rerun-if-changed=README.template.md"); + println!("cargo:rerun-if-changed=build/readme.rs"); + + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); + + // Явный флаг, чтобы где угодно ослабить проверку (ремень безопасности для + // CI/verify) + if allow_readme_drift() { + return Ok(()); + } + + // В tarball-е (cargo package --verify) или вообще без .git — проверяем мягко и + // НЕ валимся. + if is_packaged_manifest(&manifest_dir) || !has_git_anywhere(&manifest_dir) { + if let Err(err) = verify_readme_relaxed(&manifest_dir) { + println!("cargo:warning={err}"); + } + return Ok(()); + } + + // В нормальном git-рабочем дереве — синхронизируем (жёсткий режим). + sync_readme(&manifest_dir)?; + Ok(()) +} + +// Твоя прежняя эвристика: target/package/... => packaged +fn is_packaged_manifest(manifest_dir: &Path) -> bool { + let mut seen_target = false; + for comp in manifest_dir.components() { + match comp { + std::path::Component::Normal(name) => { + if seen_target && name == "package" { + return true; + } + seen_target = name == "target"; + } + _ => { + seen_target = false; + } + } + } + false +} + +// Проверяем .git по цепочке вверх (workspace корень часто выше +// crate-директории) +fn has_git_anywhere(mut dir: &Path) -> bool { + loop { + if dir.join(".git").exists() { + return true; + } + match dir.parent() { + Some(p) => dir = p, + None => return false + } + } +} + +fn allow_readme_drift() -> bool { + has_env("MASTERROR_ALLOW_README_DRIFT") || has_env("MASTERROR_SKIP_README_CHECK") +} + +fn has_env(name: &str) -> bool { + env::var_os(name).map(|v| !v.is_empty()).unwrap_or(false) +} diff --git a/build/readme.rs b/build/readme.rs new file mode 100644 index 0000000..9d04ca4 --- /dev/null +++ b/build/readme.rs @@ -0,0 +1,367 @@ +#![allow(dead_code)] + +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, io, + path::{Path, PathBuf} +}; + +use serde::Deserialize; + +/// Error type describing issues while generating the README file. +#[derive(Debug)] +pub enum ReadmeError { + Io(io::Error), + Toml(toml::de::Error), + MissingMetadata(&'static str), + MissingFeatureMetadata(Vec), + UnknownFeatureInOrder(String), + DuplicateFeatureInOrder(String), + UnknownMetadataFeature(Vec), + InvalidSnippetGroup, + UnresolvedPlaceholder(String), + OutOfSync { path: PathBuf } +} + +impl std::fmt::Display for ReadmeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(err) => write!(f, "IO error: {err}"), + Self::Toml(err) => write!(f, "Failed to parse Cargo.toml: {err}"), + Self::MissingMetadata(path) => write!(f, "Missing metadata section {path}"), + Self::MissingFeatureMetadata(features) => { + write!(f, "Missing metadata for features: {}", features.join(", ")) + } + Self::UnknownFeatureInOrder(feature) => { + write!(f, "Feature order references unknown feature '{feature}'") + } + Self::DuplicateFeatureInOrder(feature) => { + write!( + f, + "Feature '{feature}' listed multiple times in feature_order" + ) + } + Self::UnknownMetadataFeature(features) => { + write!( + f, + "Metadata defined for unknown features: {}", + features.join(", ") + ) + } + Self::InvalidSnippetGroup => { + write!(f, "feature_snippet_group must be greater than zero") + } + Self::UnresolvedPlaceholder(name) => { + write!( + f, + "Template placeholder '{{{{{name}}}}}' was not substituted" + ) + } + Self::OutOfSync { + path + } => { + write!( + f, + "README at {} is out of sync; run `cargo build` in the repository root to refresh it", + path.display() + ) + } + } + } +} +impl std::error::Error for ReadmeError {} +impl From for ReadmeError { + fn from(v: io::Error) -> Self { + Self::Io(v) + } +} +impl From for ReadmeError { + fn from(v: toml::de::Error) -> Self { + Self::Toml(v) + } +} + +#[derive(Debug, Deserialize)] +struct Manifest { + package: Package, + #[serde(default)] + features: BTreeMap> +} +#[derive(Debug, Deserialize)] +struct Package { + version: String, + #[serde(rename = "rust-version")] + rust_version: Option, + #[serde(default)] + metadata: Option +} +#[derive(Debug, Deserialize)] +struct PackageMetadata { + #[serde(default)] + masterror: Option +} +#[derive(Debug, Deserialize)] +struct MasterrorMetadata { + #[serde(default)] + readme: Option +} +#[derive(Clone, Debug, Deserialize)] +struct ReadmeMetadata { + #[serde(default)] + feature_order: Vec, + #[serde(default)] + feature_snippet_group: Option, + #[serde(default)] + conversion_lines: Vec, + #[serde(default)] + features: BTreeMap +} +#[derive(Clone, Debug, Deserialize)] +struct FeatureMetadata { + description: String, + #[serde(default)] + extra: Vec +} +#[derive(Clone, Debug)] +struct FeatureDoc { + name: String, + description: String, + extra: Vec +} + +pub fn generate_readme(manifest_path: &Path, template_path: &Path) -> Result { + let manifest_raw = fs::read_to_string(manifest_path)?; + let manifest: Manifest = toml::from_str(&manifest_raw)?; + let Manifest { + package, + features + } = manifest; + let Package { + version, + rust_version, + metadata + } = package; + + let readme_meta = metadata + .and_then(|m| m.masterror) + .and_then(|m| m.readme) + .ok_or(ReadmeError::MissingMetadata( + "package.metadata.masterror.readme" + ))?; + + let feature_docs = collect_feature_docs(&features, &readme_meta)?; + let snippet_group = readme_meta.feature_snippet_group.unwrap_or(4); + if snippet_group == 0 { + return Err(ReadmeError::InvalidSnippetGroup); + } + + let template_raw = fs::read_to_string(template_path)?; + render_readme( + &template_raw, + &version, + rust_version.as_deref().unwrap_or("unknown"), + &feature_docs, + snippet_group, + &readme_meta.conversion_lines + ) +} + +#[cfg_attr(test, allow(dead_code))] +pub fn sync_readme(manifest_dir: &Path) -> Result<(), ReadmeError> { + let manifest_path = manifest_dir.join("Cargo.toml"); + let template_path = manifest_dir.join("README.template.md"); + let output_path = manifest_dir.join("README.md"); + let readme = generate_readme(&manifest_path, &template_path)?; + write_if_changed(&output_path, &readme) +} + +/// Strict verify (kept for local use if нужно) +pub(crate) fn verify_readme(manifest_dir: &Path) -> Result<(), ReadmeError> { + let manifest_path = manifest_dir.join("Cargo.toml"); + let template_path = manifest_dir.join("README.template.md"); + let output_path = manifest_dir.join("README.md"); + let generated = generate_readme(&manifest_path, &template_path)?; + let actual = fs::read_to_string(&output_path)?; + if actual == generated { + Ok(()) + } else { + Err(ReadmeError::OutOfSync { + path: output_path + }) + } +} + +/// Relaxed verify: normalize line endings and single trailing newline. +/// Используем в tarball/без .git, чтобы не падать на мелочах. +pub(crate) fn verify_readme_relaxed(manifest_dir: &Path) -> Result<(), ReadmeError> { + let manifest_path = manifest_dir.join("Cargo.toml"); + let template_path = manifest_dir.join("README.template.md"); + let output_path = manifest_dir.join("README.md"); + let generated = generate_readme(&manifest_path, &template_path)?; + let actual = fs::read_to_string(&output_path)?; + if normalize(&actual) == normalize(&generated) { + Ok(()) + } else { + Err(ReadmeError::OutOfSync { + path: output_path + }) + } +} + +fn normalize(s: &str) -> String { + // 1) CRLF -> LF, 2) убираем ровно один финальный '\n' + let mut t = s.replace("\r\n", "\n"); + if t.ends_with('\n') { + t.pop(); + } + t +} + +fn collect_feature_docs( + feature_table: &BTreeMap>, + readme_meta: &ReadmeMetadata +) -> Result, ReadmeError> { + let feature_names: BTreeSet = feature_table + .keys() + .filter(|n| n.as_str() != "default") + .cloned() + .collect(); + + let mut missing_docs = Vec::new(); + let mut docs_map = BTreeMap::new(); + for name in &feature_names { + if let Some(meta) = readme_meta.features.get(name) { + docs_map.insert( + name.clone(), + FeatureDoc { + name: name.clone(), + description: meta.description.clone(), + extra: meta.extra.clone() + } + ); + } else { + missing_docs.push(name.clone()); + } + } + if !missing_docs.is_empty() { + return Err(ReadmeError::MissingFeatureMetadata(missing_docs)); + } + + let unknown_metadata: Vec = readme_meta + .features + .keys() + .filter(|n| n.as_str() != "default" && !feature_names.contains(*n)) + .cloned() + .collect(); + if !unknown_metadata.is_empty() { + return Err(ReadmeError::UnknownMetadataFeature(unknown_metadata)); + } + + let mut ordered = Vec::new(); + for name in &readme_meta.feature_order { + if name == "default" { + continue; + } + if !feature_names.contains(name) { + return Err(ReadmeError::UnknownFeatureInOrder(name.clone())); + } + if let Some(doc) = docs_map.remove(name) { + ordered.push(doc); + } else { + return Err(ReadmeError::DuplicateFeatureInOrder(name.clone())); + } + } + ordered.extend(docs_map.into_values()); + Ok(ordered) +} + +fn render_readme( + template: &str, + version: &str, + rust_version: &str, + features: &[FeatureDoc], + snippet_group: usize, + conversions: &[String] +) -> Result { + let feature_bullets = render_feature_bullets(features); + let feature_snippet = render_feature_snippet(features, snippet_group); + let conversion_bullets = render_conversion_bullets(conversions); + + let mut rendered = template.replace("{{CRATE_VERSION}}", version); + rendered = rendered.replace("{{MSRV}}", rust_version); + rendered = rendered.replace("{{FEATURE_BULLETS}}", &feature_bullets); + rendered = rendered.replace("{{FEATURE_SNIPPET}}", &feature_snippet); + rendered = rendered.replace("{{CONVERSION_BULLETS}}", &conversion_bullets); + + if let Some(name) = find_placeholder(&rendered) { + return Err(ReadmeError::UnresolvedPlaceholder(name)); + } + Ok(rendered) +} + +fn render_feature_bullets(features: &[FeatureDoc]) -> String { + let mut lines = Vec::new(); + for feature in features { + lines.push(format!("- `{}` — {}", feature.name, feature.description)); + if !feature.extra.is_empty() { + for note in &feature.extra { + lines.push(format!(" - {note}")); + } + } + } + lines.join("\n") +} + +fn render_conversion_bullets(conversions: &[String]) -> String { + conversions + .iter() + .map(|e| format!("- {e}")) + .collect::>() + .join("\n") +} + +fn render_feature_snippet(features: &[FeatureDoc], group_size: usize) -> String { + if features.is_empty() { + return String::new(); + } + let mut items = Vec::with_capacity(features.len()); + for f in features { + items.push(format!("\"{}\"", f.name)); + } + let chunk = group_size; + let chunks = items.len().div_ceil(chunk); + let mut lines = Vec::with_capacity(chunks); + for (i, part) in items.chunks(chunk).enumerate() { + let mut line = String::from("# "); + line.push_str(&part.join(", ")); + if i + 1 != chunks { + line.push(','); + } + lines.push(line); + } + lines.join("\n") +} + +fn find_placeholder(rendered: &str) -> Option { + let start = rendered.find("{{")?; + let after = &rendered[start + 2..]; + if let Some(end) = after.find("}}") { + let name = after[..end].trim(); + Some(name.to_string()) + } else { + let snippet: String = after.chars().take(32).collect(); + Some(snippet) + } +} + +#[cfg_attr(test, allow(dead_code))] +fn write_if_changed(path: &Path, contents: &str) -> Result<(), ReadmeError> { + match fs::read_to_string(path) { + Ok(existing) if existing == contents => return Ok(()), + Ok(_) => {} + Err(err) if err.kind() != io::ErrorKind::NotFound => return Err(ReadmeError::Io(err)), + Err(_) => {} + } + fs::write(path, contents)?; + Ok(()) +} diff --git a/src/app_error.rs b/src/app_error.rs index 9db9fad..260d3b4 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -59,10 +59,9 @@ use std::borrow::Cow; -use thiserror::Error; use tracing::error; -use crate::{RetryAdvice, code::AppCode, kind::AppErrorKind}; +use crate::{Error, RetryAdvice, code::AppCode, kind::AppErrorKind}; /// Thin error wrapper: kind + optional message. /// @@ -347,8 +346,8 @@ mod tests { // AppError's Display is "{kind}", message must not appear. let e = AppError::new(AppErrorKind::Validation, "email invalid"); let shown = format!("{}", e); - // AppErrorKind::Validation Display text is defined on the enum via - // `thiserror::Error`. We only assert that message is not leaked. + // AppErrorKind::Validation Display text is defined on the enum via our + // `#[derive(Error)]`. We only assert that message is not leaked. assert!( !shown.contains("email invalid"), "Display must not include the public message" diff --git a/src/frontend.rs b/src/frontend.rs index 44ac396..b1b334f 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -1,3 +1,5 @@ +#![allow(unused_variables)] + //! Browser/WASM helpers for converting application errors into JavaScript //! values. //! @@ -37,12 +39,11 @@ use js_sys::{Function, Reflect}; #[cfg(target_arch = "wasm32")] use serde_wasm_bindgen::to_value; -use thiserror::Error; #[cfg(target_arch = "wasm32")] use wasm_bindgen::JsCast; use wasm_bindgen::JsValue; -use crate::{AppError, AppResult, ErrorResponse}; +use crate::{AppError, AppResult, Error, ErrorResponse}; /// Error returned when emitting to the browser console fails or is unsupported. #[derive(Debug, Error, PartialEq, Eq)] @@ -80,6 +81,48 @@ pub enum BrowserConsoleError { 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")))] @@ -182,6 +225,25 @@ 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::*; diff --git a/src/kind.rs b/src/kind.rs index 55662e9..e025012 100644 --- a/src/kind.rs +++ b/src/kind.rs @@ -38,11 +38,13 @@ #[cfg(feature = "axum")] use axum::http::StatusCode; +use crate::Error; + /// Canonical application error taxonomy. /// /// Keep it small, stable, and framework-agnostic. Each variant has a clear, /// documented meaning and a predictable mapping to an HTTP status code. -#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] pub enum AppErrorKind { // ── Generic, client-visible failures (4xx/5xx) ──────────────────────────── /// Resource does not exist or is not visible to the caller. diff --git a/src/lib.rs b/src/lib.rs index 1e0fa0f..cbb406a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -203,3 +203,59 @@ 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`. +/// +/// Supports `#[from]` conversions and `#[error(transparent)]` wrappers out of +/// the box while keeping compile-time validation of wrapper shapes. +/// +/// ``` +/// use std::error::Error as StdError; +/// +/// use masterror::Error; +/// +/// #[derive(Debug, Error)] +/// #[error("{code}: {message}")] +/// struct MiniError { +/// code: u16, +/// message: &'static str +/// } +/// +/// #[derive(Debug, Error)] +/// #[error("wrapper -> {0}")] +/// struct MiniWrapper( +/// #[from] +/// #[source] +/// MiniError +/// ); +/// +/// #[derive(Debug, Error)] +/// #[error(transparent)] +/// struct MiniTransparent(#[from] MiniError); +/// +/// let wrapped = MiniWrapper::from(MiniError { +/// code: 500, +/// message: "boom" +/// }); +/// assert_eq!(wrapped.to_string(), "wrapper -> 500: boom"); +/// assert_eq!( +/// StdError::source(&wrapped).map(|err| err.to_string()), +/// Some(String::from("500: boom")) +/// ); +/// +/// let expected_source = StdError::source(&MiniError { +/// code: 503, +/// message: "oops" +/// }) +/// .map(|err| err.to_string()); +/// +/// let transparent = MiniTransparent::from(MiniError { +/// code: 503, +/// message: "oops" +/// }); +/// assert_eq!(transparent.to_string(), "503: oops"); +/// assert_eq!( +/// StdError::source(&transparent).map(|err| err.to_string()), +/// expected_source +/// ); +/// ``` +pub use thiserror::Error; diff --git a/src/turnkey.rs b/src/turnkey.rs index 9cb9573..1173151 100644 --- a/src/turnkey.rs +++ b/src/turnkey.rs @@ -28,9 +28,7 @@ //! assert!(matches!(k, TurnkeyErrorKind::UniqueLabel)); //! ``` -use thiserror::Error; - -use crate::{AppError, AppErrorKind}; +use crate::{AppError, AppErrorKind, Error}; /// High-level, stable Turnkey error categories. /// diff --git a/tests/error_derive.rs b/tests/error_derive.rs new file mode 100644 index 0000000..3fc6c2f --- /dev/null +++ b/tests/error_derive.rs @@ -0,0 +1,222 @@ +use std::error::Error as StdError; + +use masterror::Error; + +#[derive(Debug, Error)] +#[error("{kind}: {message}")] +struct NamedError { + kind: &'static str, + message: &'static str, + #[source] + cause: Option +} + +#[derive(Debug, Error)] +#[error("leaf failure")] +struct LeafError; + +#[derive(Debug, Error)] +#[error("{0}")] +struct TransparentInner(#[source] LeafError); + +#[derive(Debug, Error)] +#[error(transparent)] +struct TransparentWrapper(TransparentInner); + +#[derive(Debug, Error)] +#[error(transparent)] +struct TransparentFromWrapper(#[from] TransparentInner); + +#[derive(Debug, Error)] +#[error("{0} -> {1:?}")] +struct TupleError(&'static str, u8); + +#[derive(Debug, Error)] +enum EnumError { + #[error("unit failure")] + Unit, + #[error("{code}")] + Code { + code: u16, + #[source] + cause: LeafError + }, + #[error("{0}: {1}")] + Pair(String, #[source] LeafError) +} + +#[derive(Debug, Error)] +#[error("primary failure")] +struct PrimaryError; + +#[derive(Debug, Error)] +#[error("secondary failure")] +struct SecondaryError; + +#[derive(Debug, Error)] +#[error("tuple wrapper -> {0}")] +struct TupleWrapper( + #[from] + #[source] + LeafError +); + +#[derive(Debug, Error)] +#[error("message: {message}")] +struct MessageWrapper { + message: String +} + +impl From for MessageWrapper { + fn from(message: String) -> Self { + Self { + message + } + } +} + +#[derive(Debug, Error)] +enum MixedFromError { + #[error("tuple variant {0}")] + Tuple( + #[from] + #[source] + LeafError + ), + #[error("variant attr {0}")] + VariantAttr( + #[from] + #[source] + PrimaryError + ), + #[error("named variant {source:?}")] + Named { + #[from] + #[source] + source: SecondaryError + } +} + +#[derive(Debug, Error)] +enum TransparentEnum { + #[error("opaque {0}")] + Opaque(&'static str), + #[error(transparent)] + TransparentVariant(#[from] TransparentInner) +} + +#[test] +fn named_struct_display_and_source() { + let err = NamedError { + kind: "validation", + message: "invalid email", + cause: Some(LeafError) + }; + assert_eq!(err.to_string(), "validation: invalid email"); + let source = StdError::source(&err).expect("source"); + assert_eq!(source.to_string(), "leaf failure"); +} + +#[test] +fn tuple_struct_supports_positional_formatting() { + let err = TupleError("alpha", 42); + assert_eq!(err.to_string(), "alpha -> 42"); + assert!(StdError::source(&err).is_none()); +} + +#[test] +fn enum_variants_cover_display_and_source() { + let unit = EnumError::Unit; + assert_eq!(unit.to_string(), "unit failure"); + assert!(StdError::source(&unit).is_none()); + + let code = EnumError::Code { + code: 503, + cause: LeafError + }; + assert_eq!(code.to_string(), "503"); + assert_eq!(StdError::source(&code).unwrap().to_string(), "leaf failure"); + + let pair = EnumError::Pair("left".into(), LeafError); + assert!(pair.to_string().starts_with("left")); + assert_eq!(StdError::source(&pair).unwrap().to_string(), "leaf failure"); +} + +#[test] +fn tuple_struct_from_wraps_source() { + let err = TupleWrapper::from(LeafError); + assert_eq!(err.to_string(), "tuple wrapper -> leaf failure"); + let source = StdError::source(&err).expect("source present"); + assert_eq!(source.to_string(), "leaf failure"); +} + +#[test] +fn named_struct_from_without_source() { + let err = MessageWrapper::from(String::from("payload")); + assert_eq!(err.to_string(), "message: payload"); + assert!(StdError::source(&err).is_none()); +} + +#[test] +fn enum_from_variants_generate_impls() { + let tuple = MixedFromError::from(LeafError); + assert!(matches!(&tuple, MixedFromError::Tuple(_))); + assert_eq!( + StdError::source(&tuple).unwrap().to_string(), + "leaf failure" + ); + + let variant_attr = MixedFromError::from(PrimaryError); + assert!(matches!(&variant_attr, MixedFromError::VariantAttr(_))); + assert_eq!( + StdError::source(&variant_attr).unwrap().to_string(), + "primary failure" + ); + + let named = MixedFromError::from(SecondaryError); + assert!(matches!( + &named, + MixedFromError::Named { + source: SecondaryError + } + )); + assert_eq!( + StdError::source(&named).unwrap().to_string(), + "secondary failure" + ); +} + +#[test] +fn transparent_struct_delegates_display_and_source() { + let inner = TransparentInner(LeafError); + let inner_display = inner.to_string(); + let inner_source = StdError::source(&inner).map(|err| err.to_string()); + let wrapper = TransparentWrapper(inner); + assert_eq!(wrapper.to_string(), inner_display); + assert_eq!( + StdError::source(&wrapper).map(|err| err.to_string()), + inner_source + ); +} + +#[test] +fn transparent_struct_from_impl() { + let wrapper = TransparentFromWrapper::from(TransparentInner(LeafError)); + assert_eq!(wrapper.to_string(), "leaf failure"); + assert_eq!( + StdError::source(&wrapper).map(|err| err.to_string()), + Some(String::from("leaf failure")) + ); +} + +#[test] +fn transparent_enum_variant_from_impl() { + let _unused = TransparentEnum::Opaque("noop"); + let variant = TransparentEnum::from(TransparentInner(LeafError)); + assert!(matches!(variant, TransparentEnum::TransparentVariant(_))); + assert_eq!(variant.to_string(), "leaf failure"); + assert_eq!( + StdError::source(&variant).map(|err| err.to_string()), + Some(String::from("leaf failure")) + ); +} diff --git a/tests/error_derive_from_trybuild.rs b/tests/error_derive_from_trybuild.rs new file mode 100644 index 0000000..377c5c2 --- /dev/null +++ b/tests/error_derive_from_trybuild.rs @@ -0,0 +1,13 @@ +use trybuild::TestCases; + +#[test] +fn from_attribute_compile_failures() { + let t = TestCases::new(); + t.compile_fail("tests/ui/from/*.rs"); +} + +#[test] +fn transparent_attribute_compile_failures() { + let t = TestCases::new(); + t.compile_fail("tests/ui/transparent/*.rs"); +} diff --git a/tests/readme_sync.rs b/tests/readme_sync.rs new file mode 100644 index 0000000..3117d2a --- /dev/null +++ b/tests/readme_sync.rs @@ -0,0 +1,83 @@ +#[path = "../build/readme.rs"] +mod readme; + +use std::{error::Error, fs, io, path::PathBuf}; + +use tempfile::tempdir; + +const MINIMAL_MANIFEST: &str = r#"[package] +name = "demo" +version = "1.2.3" +rust-version = "1.89" +edition = "2024" + +[features] +default = [] + +[package.metadata.masterror.readme] +feature_order = [] +conversion_lines = [] +feature_snippet_group = 2 + +[package.metadata.masterror.readme.features] +"#; + +const MINIMAL_TEMPLATE: &str = "# Demo\\n\\nVersion {{CRATE_VERSION}}\\nMSRV {{MSRV}}\\n\\nFeatures\\n{{FEATURE_BULLETS}}\\n\\nSnippet\\n{{FEATURE_SNIPPET}}\\n\\nConversions\\n{{CONVERSION_BULLETS}}\\n"; + +#[test] +fn readme_is_in_sync() -> Result<(), Box> { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let manifest_path = manifest_dir.join("Cargo.toml"); + let template_path = manifest_dir.join("README.template.md"); + let readme_path = manifest_dir.join("README.md"); + + let generated = readme::generate_readme(&manifest_path, &template_path)?; + let actual = fs::read_to_string(&readme_path)?; + + if actual != generated { + // Use std::io::Error::other to satisfy clippy::io-other-error + let msg = "README.md is out of date; run `cargo build` to regenerate"; + return Err(io::Error::other(msg).into()); + } + + Ok(()) +} + +#[test] +fn verify_readme_succeeds_when_in_sync() -> Result<(), Box> { + let tmp = tempdir()?; + let manifest_path = tmp.path().join("Cargo.toml"); + let template_path = tmp.path().join("README.template.md"); + let readme_path = tmp.path().join("README.md"); + + fs::write(&manifest_path, MINIMAL_MANIFEST)?; + fs::write(&template_path, MINIMAL_TEMPLATE)?; + let generated = readme::generate_readme(&manifest_path, &template_path)?; + fs::write(&readme_path, generated)?; + + readme::verify_readme(tmp.path()).map_err(|err| io::Error::other(err.to_string()))?; + Ok(()) +} + +#[test] +fn verify_readme_detects_out_of_sync() -> Result<(), Box> { + let tmp = tempdir()?; + let manifest_path = tmp.path().join("Cargo.toml"); + let template_path = tmp.path().join("README.template.md"); + let readme_path = tmp.path().join("README.md"); + + fs::write(&manifest_path, MINIMAL_MANIFEST)?; + fs::write(&template_path, MINIMAL_TEMPLATE)?; + fs::write(&readme_path, "outdated")?; + + match readme::verify_readme(tmp.path()) { + Err(readme::ReadmeError::OutOfSync { + path + }) => { + assert_eq!(path, readme_path); + Ok(()) + } + Err(err) => Err(io::Error::other(format!("unexpected error: {err}")).into()), + Ok(_) => Err(io::Error::other("expected mismatch error").into()) + } +} diff --git a/tests/ui/from/struct_multiple_fields.rs b/tests/ui/from/struct_multiple_fields.rs new file mode 100644 index 0000000..a545ce7 --- /dev/null +++ b/tests/ui/from/struct_multiple_fields.rs @@ -0,0 +1,15 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error("{left:?} - {right:?}")] +struct BadStruct { + #[from] + left: DummyError, + right: DummyError, +} + +#[derive(Debug, Error)] +#[error("dummy")] +struct DummyError; + +fn main() {} diff --git a/tests/ui/from/struct_multiple_fields.stderr b/tests/ui/from/struct_multiple_fields.stderr new file mode 100644 index 0000000..b8b63ce --- /dev/null +++ b/tests/ui/from/struct_multiple_fields.stderr @@ -0,0 +1,5 @@ +error: deriving From requires no fields other than source and backtrace + --> tests/ui/from/struct_multiple_fields.rs:6:5 + | +6 | #[from] + | ^^^^^^^ diff --git a/tests/ui/from/variant_multiple_fields.rs b/tests/ui/from/variant_multiple_fields.rs new file mode 100644 index 0000000..3d35295 --- /dev/null +++ b/tests/ui/from/variant_multiple_fields.rs @@ -0,0 +1,14 @@ +use masterror::Error; + +#[derive(Debug, Error)] +enum BadEnum { + #[error("{0} - {1}")] + #[from] + Two(#[source] DummyError, DummyError), +} + +#[derive(Debug, Error)] +#[error("dummy")] +struct DummyError; + +fn main() {} diff --git a/tests/ui/from/variant_multiple_fields.stderr b/tests/ui/from/variant_multiple_fields.stderr new file mode 100644 index 0000000..e78cc01 --- /dev/null +++ b/tests/ui/from/variant_multiple_fields.stderr @@ -0,0 +1,5 @@ +error: not expected here; the #[from] attribute belongs on a specific field + --> tests/ui/from/variant_multiple_fields.rs:6:5 + | +6 | #[from] + | ^^^^^^^ diff --git a/tests/ui/transparent/enum_variant_multiple_fields.rs b/tests/ui/transparent/enum_variant_multiple_fields.rs new file mode 100644 index 0000000..5565776 --- /dev/null +++ b/tests/ui/transparent/enum_variant_multiple_fields.rs @@ -0,0 +1,7 @@ +use masterror::Error; + +#[derive(Debug, Error)] +enum TransparentEnumFail { + #[error(transparent)] + Variant(String, String) +} diff --git a/tests/ui/transparent/enum_variant_multiple_fields.stderr b/tests/ui/transparent/enum_variant_multiple_fields.stderr new file mode 100644 index 0000000..d7b939b --- /dev/null +++ b/tests/ui/transparent/enum_variant_multiple_fields.stderr @@ -0,0 +1,12 @@ +error: #[error(transparent)] requires exactly one field + --> tests/ui/transparent/enum_variant_multiple_fields.rs:5:5 + | +5 | / #[error(transparent)] +6 | | Variant(String, String) + | |___________________________^ + +error[E0601]: `main` function not found in crate `$CRATE` + --> tests/ui/transparent/enum_variant_multiple_fields.rs:7:2 + | +7 | } + | ^ consider adding a `main` function to `$DIR/tests/ui/transparent/enum_variant_multiple_fields.rs` diff --git a/tests/ui/transparent/enum_variant_no_fields.rs b/tests/ui/transparent/enum_variant_no_fields.rs new file mode 100644 index 0000000..92f40e0 --- /dev/null +++ b/tests/ui/transparent/enum_variant_no_fields.rs @@ -0,0 +1,7 @@ +use masterror::Error; + +#[derive(Debug, Error)] +enum TransparentEnumUnit { + #[error(transparent)] + Variant +} diff --git a/tests/ui/transparent/enum_variant_no_fields.stderr b/tests/ui/transparent/enum_variant_no_fields.stderr new file mode 100644 index 0000000..06a21e0 --- /dev/null +++ b/tests/ui/transparent/enum_variant_no_fields.stderr @@ -0,0 +1,12 @@ +error: #[error(transparent)] requires exactly one field + --> tests/ui/transparent/enum_variant_no_fields.rs:5:5 + | +5 | / #[error(transparent)] +6 | | Variant + | |___________^ + +error[E0601]: `main` function not found in crate `$CRATE` + --> tests/ui/transparent/enum_variant_no_fields.rs:7:2 + | +7 | } + | ^ consider adding a `main` function to `$DIR/tests/ui/transparent/enum_variant_no_fields.rs` diff --git a/tests/ui/transparent/struct_multiple_fields.rs b/tests/ui/transparent/struct_multiple_fields.rs new file mode 100644 index 0000000..ddde691 --- /dev/null +++ b/tests/ui/transparent/struct_multiple_fields.rs @@ -0,0 +1,8 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error(transparent)] +struct TransparentMany { + first: String, + second: String +} diff --git a/tests/ui/transparent/struct_multiple_fields.stderr b/tests/ui/transparent/struct_multiple_fields.stderr new file mode 100644 index 0000000..d55036a --- /dev/null +++ b/tests/ui/transparent/struct_multiple_fields.stderr @@ -0,0 +1,11 @@ +error: #[error(transparent)] requires exactly one field + --> tests/ui/transparent/struct_multiple_fields.rs:4:1 + | +4 | #[error(transparent)] + | ^^^^^^^^^^^^^^^^^^^^^ + +error[E0601]: `main` function not found in crate `$CRATE` + --> tests/ui/transparent/struct_multiple_fields.rs:8:2 + | +8 | } + | ^ consider adding a `main` function to `$DIR/tests/ui/transparent/struct_multiple_fields.rs` diff --git a/tests/ui/transparent/struct_no_fields.rs b/tests/ui/transparent/struct_no_fields.rs new file mode 100644 index 0000000..a747343 --- /dev/null +++ b/tests/ui/transparent/struct_no_fields.rs @@ -0,0 +1,5 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error(transparent)] +struct TransparentUnit; diff --git a/tests/ui/transparent/struct_no_fields.stderr b/tests/ui/transparent/struct_no_fields.stderr new file mode 100644 index 0000000..d395ffa --- /dev/null +++ b/tests/ui/transparent/struct_no_fields.stderr @@ -0,0 +1,11 @@ +error: #[error(transparent)] requires exactly one field + --> tests/ui/transparent/struct_no_fields.rs:4:1 + | +4 | #[error(transparent)] + | ^^^^^^^^^^^^^^^^^^^^^ + +error[E0601]: `main` function not found in crate `$CRATE` + --> tests/ui/transparent/struct_no_fields.rs:5:24 + | +5 | struct TransparentUnit; + | ^ consider adding a `main` function to `$DIR/tests/ui/transparent/struct_no_fields.rs`