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
+
+
[](https://crates.io/crates/masterror)
[](https://docs.rs/masterror)
[](https://crates.io/crates/masterror)
@@ -7,8 +10,8 @@

[](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
+
+
+
+[](https://crates.io/crates/masterror)
+[](https://docs.rs/masterror)
+[](https://crates.io/crates/masterror)
+
+
+[](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`