diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9081435..a69bf52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,8 @@ env: RUST_BACKTRACE: short RUSTFLAGS: -D warnings # Crate publish order (dependencies first) - CRATES: "masterror-template masterror-derive masterror" + # template → derive → knowledge → masterror → cli + CRATES: "masterror-template masterror-derive masterror-knowledge masterror masterror-cli" jobs: # ════════════════════════════════════════════════════════════════════════════ @@ -58,13 +59,160 @@ jobs: echo "version=$MSRV" >> "$GITHUB_OUTPUT" echo "MSRV: $MSRV" + # ════════════════════════════════════════════════════════════════════════════ + # Detect changed crates (dependency-aware) + # ════════════════════════════════════════════════════════════════════════════ + + changes: + name: Detect Changes + runs-on: ubuntu-latest + outputs: + # Individual crate changes + template: ${{ steps.filter.outputs.template }} + derive: ${{ steps.filter.outputs.derive }} + knowledge: ${{ steps.filter.outputs.knowledge }} + masterror: ${{ steps.filter.outputs.masterror }} + cli: ${{ steps.filter.outputs.cli }} + # Dependency-aware: need to rebuild these + need-template: ${{ steps.deps.outputs.need-template }} + need-derive: ${{ steps.deps.outputs.need-derive }} + need-knowledge: ${{ steps.deps.outputs.need-knowledge }} + need-masterror: ${{ steps.deps.outputs.need-masterror }} + need-cli: ${{ steps.deps.outputs.need-cli }} + # Any library changed (for full workspace checks) + any-lib: ${{ steps.deps.outputs.any-lib }} + # CI/config changed (force full rebuild) + ci: ${{ steps.filter.outputs.ci }} + steps: + - uses: actions/checkout@v5 + + - name: Detect file changes + uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + template: + - 'masterror-template/**' + derive: + - 'masterror-derive/**' + knowledge: + - 'masterror-knowledge/**' + masterror: + - 'src/**' + - 'Cargo.toml' + - 'build.rs' + cli: + - 'masterror-cli/**' + ci: + - '.github/workflows/**' + - 'Cargo.lock' + - 'deny.toml' + - 'cliff.toml' + - '.cargo/**' + + - name: Compute dependency graph + id: deps + run: | + # Dependency graph: + # template ← derive ← masterror + # ↗ + # knowledge ← cli + # ↘ masterror (optional feature) + + TEMPLATE="${{ steps.filter.outputs.template }}" + DERIVE="${{ steps.filter.outputs.derive }}" + KNOWLEDGE="${{ steps.filter.outputs.knowledge }}" + MASTERROR="${{ steps.filter.outputs.masterror }}" + CLI="${{ steps.filter.outputs.cli }}" + CI="${{ steps.filter.outputs.ci }}" + + # Force all if CI config changed or workflow_dispatch + if [[ "$CI" == "true" ]] || [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "CI config changed or manual trigger - rebuilding all" + echo "need-template=true" >> "$GITHUB_OUTPUT" + echo "need-derive=true" >> "$GITHUB_OUTPUT" + echo "need-knowledge=true" >> "$GITHUB_OUTPUT" + echo "need-masterror=true" >> "$GITHUB_OUTPUT" + echo "need-cli=true" >> "$GITHUB_OUTPUT" + echo "any-lib=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # template: standalone + NEED_TEMPLATE="$TEMPLATE" + + # derive: depends on template + if [[ "$DERIVE" == "true" ]] || [[ "$TEMPLATE" == "true" ]]; then + NEED_DERIVE=true + else + NEED_DERIVE=false + fi + + # knowledge: standalone + NEED_KNOWLEDGE="$KNOWLEDGE" + + # masterror: depends on template, derive, optionally knowledge + if [[ "$MASTERROR" == "true" ]] || [[ "$TEMPLATE" == "true" ]] || \ + [[ "$DERIVE" == "true" ]] || [[ "$KNOWLEDGE" == "true" ]]; then + NEED_MASTERROR=true + else + NEED_MASTERROR=false + fi + + # cli: depends on knowledge + if [[ "$CLI" == "true" ]] || [[ "$KNOWLEDGE" == "true" ]]; then + NEED_CLI=true + else + NEED_CLI=false + fi + + # Any library changed? + if [[ "$NEED_TEMPLATE" == "true" ]] || [[ "$NEED_DERIVE" == "true" ]] || \ + [[ "$NEED_KNOWLEDGE" == "true" ]] || [[ "$NEED_MASTERROR" == "true" ]] || \ + [[ "$NEED_CLI" == "true" ]]; then + ANY_LIB=true + else + ANY_LIB=false + fi + + echo "need-template=$NEED_TEMPLATE" >> "$GITHUB_OUTPUT" + echo "need-derive=$NEED_DERIVE" >> "$GITHUB_OUTPUT" + echo "need-knowledge=$NEED_KNOWLEDGE" >> "$GITHUB_OUTPUT" + echo "need-masterror=$NEED_MASTERROR" >> "$GITHUB_OUTPUT" + echo "need-cli=$NEED_CLI" >> "$GITHUB_OUTPUT" + echo "any-lib=$ANY_LIB" >> "$GITHUB_OUTPUT" + + echo "Summary:" + echo " template: $TEMPLATE → need: $NEED_TEMPLATE" + echo " derive: $DERIVE → need: $NEED_DERIVE" + echo " knowledge: $KNOWLEDGE → need: $NEED_KNOWLEDGE" + echo " masterror: $MASTERROR → need: $NEED_MASTERROR" + echo " cli: $CLI → need: $NEED_CLI" + echo " any-lib: $ANY_LIB" + + # GitHub Step Summary + cat >> $GITHUB_STEP_SUMMARY << EOF + ## 🔍 Change Detection + + | Crate | Changed | Rebuild | + |-------|---------|---------| + | masterror-template | $([[ "$TEMPLATE" == "true" ]] && echo "✅" || echo "—") | $([[ "$NEED_TEMPLATE" == "true" ]] && echo "🔨" || echo "⏭️") | + | masterror-derive | $([[ "$DERIVE" == "true" ]] && echo "✅" || echo "—") | $([[ "$NEED_DERIVE" == "true" ]] && echo "🔨" || echo "⏭️") | + | masterror-knowledge | $([[ "$KNOWLEDGE" == "true" ]] && echo "✅" || echo "—") | $([[ "$NEED_KNOWLEDGE" == "true" ]] && echo "🔨" || echo "⏭️") | + | masterror | $([[ "$MASTERROR" == "true" ]] && echo "✅" || echo "—") | $([[ "$NEED_MASTERROR" == "true" ]] && echo "🔨" || echo "⏭️") | + | masterror-cli | $([[ "$CLI" == "true" ]] && echo "✅" || echo "—") | $([[ "$NEED_CLI" == "true" ]] && echo "🔨" || echo "⏭️") | + + **Legend:** ✅ = changed, 🔨 = will rebuild, ⏭️ = skipped, — = no changes + EOF + # ════════════════════════════════════════════════════════════════════════════ # STAGE 1: CHECKS (parallel matrix) # ════════════════════════════════════════════════════════════════════════════ check: name: Check (${{ matrix.rust }} / ${{ matrix.os }}) - needs: msrv + needs: [msrv, changes] + if: needs.changes.outputs.any-lib == 'true' runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -124,6 +272,8 @@ jobs: fmt: name: Format + needs: changes + if: needs.changes.outputs.any-lib == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -138,6 +288,8 @@ jobs: docs: name: Documentation + needs: changes + if: needs.changes.outputs.any-lib == 'true' runs-on: ubuntu-latest env: RUSTDOCFLAGS: -D warnings @@ -155,6 +307,8 @@ jobs: no-std: name: no_std (${{ matrix.name }}) + needs: changes + if: needs.changes.outputs.need-masterror == 'true' runs-on: ubuntu-latest strategy: fail-fast: false @@ -185,7 +339,7 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' }} - name: Check ${{ matrix.name }} - run: cargo check ${{ matrix.args }} + run: cargo check -p masterror ${{ matrix.args }} security: name: Security Audit @@ -225,8 +379,16 @@ jobs: test: name: Test Suite - needs: [check, fmt, no-std, security, reuse] - if: ${{ !inputs.skip_tests }} + needs: [changes, check, fmt, no-std, security, reuse] + if: | + always() && + !inputs.skip_tests && + needs.changes.outputs.any-lib == 'true' && + (needs.check.result == 'success' || needs.check.result == 'skipped') && + (needs.fmt.result == 'success' || needs.fmt.result == 'skipped') && + (needs['no-std'].result == 'success' || needs['no-std'].result == 'skipped') && + needs.security.result == 'success' && + needs.reuse.result == 'success' runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -262,7 +424,11 @@ jobs: coverage: name: Coverage - needs: test + needs: [changes, test] + if: | + always() && + needs.changes.outputs.any-lib == 'true' && + needs.test.result == 'success' runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -309,9 +475,13 @@ jobs: benchmarks: name: Benchmarks - needs: test + needs: [changes, test] runs-on: ubuntu-latest - if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' + if: | + always() && + needs.changes.outputs.need-masterror == 'true' && + needs.test.result == 'success' && + (github.event_name == 'pull_request' || github.ref == 'refs/heads/main') steps: - uses: actions/checkout@v5 @@ -340,12 +510,18 @@ jobs: changelog: name: Update Changelog - needs: [check, fmt, no-std, security, reuse] + needs: [changes, check, fmt, no-std, security, reuse] runs-on: ubuntu-latest if: | + always() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' && - !contains(github.event.head_commit.message || '', '[skip ci]') + !contains(github.event.head_commit.message || '', '[skip ci]') && + (needs.check.result == 'success' || needs.check.result == 'skipped') && + (needs.fmt.result == 'success' || needs.fmt.result == 'skipped') && + (needs['no-std'].result == 'success' || needs['no-std'].result == 'skipped') && + needs.security.result == 'success' && + needs.reuse.result == 'success' steps: - uses: actions/checkout@v5 with: @@ -412,10 +588,12 @@ jobs: release: name: Release - needs: [test, changelog] + needs: [changes, test, changelog] if: | always() && + needs.changes.outputs.any-lib == 'true' && (needs.test.result == 'success' || needs.test.result == 'skipped') && + (needs.changelog.result == 'success' || needs.changelog.result == 'skipped') && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' && !contains(github.event.head_commit.message || '', '[skip ci]') @@ -508,7 +686,7 @@ jobs: declare -A LOCAL REMOTE NEEDS_PUBLISH PUBLISHED_ANY=false - for crate in masterror-template masterror-derive masterror; do + for crate in $CRATES; do LOCAL[$crate]=$(get_local_version "$crate") REMOTE[$crate]=$(get_remote_version "$crate") @@ -527,16 +705,21 @@ jobs: # Dependency-aware publishing # ══════════════════════════════════════════════════════════════════ - # If derive changes, masterror should also be republished - # (it depends on derive, users need consistent versions) + # Dependency consistency warnings if [[ "${NEEDS_PUBLISH[masterror-derive]}" == "true" ]] && \ [[ "${NEEDS_PUBLISH[masterror]}" == "false" ]]; then warn "masterror-derive changed but masterror version unchanged" warn "Consider bumping masterror version for dependency consistency" fi - # Publish in order: template → derive → masterror - for crate in masterror-template masterror-derive masterror; do + if [[ "${NEEDS_PUBLISH[masterror-knowledge]}" == "true" ]] && \ + [[ "${NEEDS_PUBLISH[masterror-cli]}" == "false" ]]; then + warn "masterror-knowledge changed but masterror-cli version unchanged" + warn "Consider bumping masterror-cli version for dependency consistency" + fi + + # Publish in order: template → derive → knowledge → masterror → cli + for crate in $CRATES; do if [[ "${NEEDS_PUBLISH[$crate]}" == "true" ]]; then if publish_crate "$crate"; then PUBLISHED_ANY=true @@ -605,7 +788,9 @@ jobs: |-------|--------| | masterror-template | ${{ steps.publish.outputs.published == 'true' && '✅ Published' || '⏭️ Skipped' }} | | masterror-derive | ${{ steps.publish.outputs.published == 'true' && '✅ Published' || '⏭️ Skipped' }} | + | masterror-knowledge | ${{ steps.publish.outputs.published == 'true' && '✅ Published' || '⏭️ Skipped' }} | | masterror | ${{ steps.publish.outputs.published == 'true' && '✅ Published' || '⏭️ Skipped' }} | + | masterror-cli | ${{ steps.publish.outputs.published == 'true' && '✅ Published' || '⏭️ Skipped' }} | **Version:** `${{ steps.publish.outputs.version }}` EOF @@ -681,19 +866,48 @@ jobs: ci-success: name: CI Success - needs: [check, fmt, docs, no-std, security, reuse, test] + needs: [changes, check, fmt, docs, no-std, security, reuse, test] if: always() runs-on: ubuntu-latest steps: - name: Check all jobs run: | - results="${{ needs.check.result }} ${{ needs.fmt.result }} ${{ needs.docs.result }} ${{ needs.no-std.result }} ${{ needs.security.result }} ${{ needs.reuse.result }} ${{ needs.test.result }}" - - for r in $results; do - if [[ "$r" == "failure" ]]; then - echo "::error::One or more required jobs failed" - exit 1 - fi - done + echo "Job results:" + echo " changes: ${{ needs.changes.result }}" + echo " check: ${{ needs.check.result }}" + echo " fmt: ${{ needs.fmt.result }}" + echo " docs: ${{ needs.docs.result }}" + echo " no-std: ${{ needs['no-std'].result }}" + echo " security: ${{ needs.security.result }}" + echo " reuse: ${{ needs.reuse.result }}" + echo " test: ${{ needs.test.result }}" + + FAILED=false + + # Changes detection must succeed + [[ "${{ needs.changes.result }}" != "success" ]] && \ + echo "::error::Changes detection failed" && FAILED=true + + # Security and REUSE must always pass + [[ "${{ needs.security.result }}" == "failure" ]] && \ + echo "::error::Security audit failed" && FAILED=true + [[ "${{ needs.reuse.result }}" == "failure" ]] && \ + echo "::error::REUSE compliance failed" && FAILED=true + + # Other jobs: failure is not OK (skipped is fine) + [[ "${{ needs.check.result }}" == "failure" ]] && \ + echo "::error::Check job failed" && FAILED=true + [[ "${{ needs.fmt.result }}" == "failure" ]] && \ + echo "::error::Format job failed" && FAILED=true + [[ "${{ needs.docs.result }}" == "failure" ]] && \ + echo "::error::Docs job failed" && FAILED=true + [[ "${{ needs['no-std'].result }}" == "failure" ]] && \ + echo "::error::no-std job failed" && FAILED=true + [[ "${{ needs.test.result }}" == "failure" ]] && \ + echo "::error::Test job failed" && FAILED=true + + if [[ "$FAILED" == "true" ]]; then + exit 1 + fi - echo "✅ All CI checks passed!" + echo "✅ All CI checks passed (some may have been skipped due to no changes)" diff --git a/.hooks/pre-commit b/.hooks/pre-commit index eeacd39..ccc498a 100755 --- a/.hooks/pre-commit +++ b/.hooks/pre-commit @@ -20,6 +20,25 @@ else echo " - pip: pip install reuse" fi +echo "🔧 Linting GitHub Actions..." +if command -v actionlint &> /dev/null; then + actionlint +else + echo "⚠️ Warning: actionlint not installed, skipping Actions linting" + echo " Install with:" + echo " - Arch Linux: paru -S actionlint" + echo " - Go: go install github.com/rhysd/actionlint/cmd/actionlint@latest" + echo " - Homebrew: brew install actionlint" +fi + +echo "🔒 Checking no_std compatibility..." +cargo check --no-default-features -q +cargo check --features std -q +cargo check --no-default-features --features tracing -q +cargo check --no-default-features --features metrics -q +cargo check --no-default-features --features colored -q +cargo check --all-features -q + echo "🔍 Running clippy (all features, all targets)..." cargo clippy --workspace --all-targets --all-features -- -D warnings diff --git a/Cargo.lock b/Cargo.lock index a97000b..9187276 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -156,7 +156,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.6.1", + "socket2 0.6.2", "time", "tracing", "url", @@ -225,12 +225,56 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -258,6 +302,27 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "assert_cmd" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -435,6 +500,17 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -482,9 +558,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.53" +version = "1.2.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" +checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" dependencies = [ "find-msvc-tools", "shlex", @@ -544,6 +620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -552,8 +629,23 @@ version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -562,6 +654,12 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "combine" version = "4.6.7" @@ -788,9 +886,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.0-rc.11" +version = "0.2.0-rc.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d2bcc93d5cde6659e8649fc412894417ebc14dee54cfc6ee439c683a4a58342" +checksum = "a6dcdb44f2c3ee25689ca12a4c19e664fd09f97aeae0bc5043b2dbab6389e308" dependencies = [ "hybrid-array", ] @@ -972,6 +1070,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -992,10 +1096,31 @@ checksum = "ca14c221bd9052fd2da7c34a2eeb5ae54732db28be47c35937be71793d675422" dependencies = [ "block-buffer 0.11.0", "const-oid 0.10.2", - "crypto-common 0.2.0-rc.11", + "crypto-common 0.2.0-rc.12", "subtle", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1185,6 +1310,15 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "flume" version = "0.11.1" @@ -1551,9 +1685,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f471e0a81b2f90ffc0cb2f951ae04da57de8baa46fa99112b062a5173a5088d0" +checksum = "b41fb3dc24fe72c2e3a4685eed55917c2fb228851257f4a8f2d985da9443c3e5" dependencies = [ "typenum", ] @@ -1612,7 +1746,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.6.2", "tokio", "tower-service", "tracing", @@ -1838,6 +1972,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -1897,9 +2037,9 @@ checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" @@ -1964,7 +2104,7 @@ checksum = "a94d21414c1f4a51209ad204c1776a3d0765002c76c6abcb602a6f09f1e881c7" [[package]] name = "masterror" -version = "0.27.2" +version = "0.28.0" dependencies = [ "actix-web", "anyhow", @@ -1978,6 +2118,7 @@ dependencies = [ "log", "log-mdc", "masterror-derive", + "masterror-knowledge", "masterror-template", "metrics", "owo-colors", @@ -2005,6 +2146,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "masterror-cli" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "clap", + "dirs", + "masterror-knowledge", + "owo-colors", + "predicates", + "serde", + "serde_json", + "toml", +] + [[package]] name = "masterror-derive" version = "0.11.2" @@ -2015,6 +2171,24 @@ dependencies = [ "syn", ] +[[package]] +name = "masterror-knowledge" +version = "0.1.0" +dependencies = [ + "aho-corasick", + "arrayvec", +] + +[[package]] +name = "masterror-rustc" +version = "0.1.0" +dependencies = [ + "libc", + "masterror-knowledge", + "owo-colors", + "windows-sys 0.59.0", +] + [[package]] name = "masterror-template" version = "0.4.1" @@ -2122,6 +2296,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2182,9 +2362,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-integer" @@ -2242,6 +2422,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" @@ -2292,6 +2478,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -2532,6 +2724,36 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -2566,9 +2788,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -2585,9 +2807,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -2697,7 +2919,7 @@ dependencies = [ "itoa", "percent-encoding", "ryu", - "socket2 0.6.1", + "socket2 0.6.2", "url", "xxhash-rust", ] @@ -2720,6 +2942,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -3223,9 +3456,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", "windows-sys 0.60.2", @@ -3612,6 +3845,22 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix", + "windows-sys 0.60.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "2.0.18" @@ -3643,9 +3892,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.45" +version = "0.3.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" dependencies = [ "deranged", "itoa", @@ -3658,15 +3907,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.25" +version = "0.2.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" dependencies = [ "num-conv", "time-core", @@ -3728,7 +3977,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.1", + "socket2 0.6.2", "tokio-macros", "windows-sys 0.61.2", ] @@ -3826,7 +4075,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "socket2 0.6.1", + "socket2 0.6.2", "sync_wrapper", "tokio", "tokio-stream", @@ -4074,6 +4323,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "utoipa" version = "5.4.0" @@ -4157,6 +4412,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 3b7d835..3bf1709 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "masterror" -version = "0.27.2" +version = "0.28.0" rust-version = "1.92" edition = "2024" license = "MIT" @@ -42,6 +42,9 @@ include = [ members = [ "masterror-derive", "masterror-template", + "masterror-knowledge", + "masterror-cli", + "masterror-rustc", "examples/axum-rest-api", "examples/custom-domain-errors", "examples/sqlx-database", @@ -87,10 +90,12 @@ turnkey = ["std"] tonic = ["dep:tonic", "std"] openapi = ["dep:utoipa", "std"] benchmarks = ["std"] +knowledge = ["dep:masterror-knowledge"] [workspace.dependencies] masterror-derive = { version = "0.11" } masterror-template = { version = "0.4" } +masterror-knowledge = { version = "0.1" } [dependencies] masterror-derive = { version = "0.11" } @@ -147,6 +152,7 @@ tonic = { version = "0.14", optional = true } owo-colors = { version = "4", optional = true, default-features = false, features = [ "supports-colors", ] } +masterror-knowledge = { path = "masterror-knowledge", optional = true } [dev-dependencies] anyhow = { version = "1", default-features = false, features = ["std"] } @@ -200,6 +206,7 @@ feature_order = [ "tonic", "frontend", "turnkey", + "knowledge", "benchmarks", ] feature_snippet_group = 4 @@ -283,6 +290,9 @@ 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" +[package.metadata.masterror.readme.features.knowledge] +description = "Rust compiler error explanations and best practices (en/ru/ko)" + [package.metadata.masterror.readme.features.benchmarks] description = "Enable Criterion benchmarks and CI baseline tooling" extra = ["Primarily used for local profiling and continuous benchmarking runs"] diff --git a/README.ko.md b/README.ko.md index 0fe7d7d..16d27a2 100644 --- a/README.ko.md +++ b/README.ko.md @@ -25,6 +25,8 @@ SPDX-License-Identifier: MIT > 🇬🇧 [Read README in English](README.md) > 🇷🇺 [Читайте README на русском языке](README.ru.md) + **참고:** [masterror-cli](https://github.com/RAprogramm/masterror-cli) — Rust 컴파일러 오류를 상세한 해결책, 모범 사례 및 다국어 지원과 함께 설명하는 CLI 도구입니다. `cargo install masterror-cli` 또는 [AUR](https://aur.archlinux.org/packages/masterror-cli)에서 설치하세요. + > [!IMPORTANT] diff --git a/README.md b/README.md index bcafb01..6601cc5 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ SPDX-License-Identifier: MIT > 🇷🇺 [Читайте README на русском языке](README.ru.md) > 🇰🇷 [한국어 README](README.ko.md) + **See also:** [masterror-cli](https://github.com/RAprogramm/masterror-cli) — CLI tool that explains Rust compiler errors with detailed solutions, best practices, and multi-language support. Install via `cargo install masterror-cli` or from [AUR](https://aur.archlinux.org/packages/masterror-cli). + --- @@ -113,6 +115,7 @@ of redaction and metadata. | [`masterror`](https://crates.io/crates/masterror) | Core error types, metadata builders, transports, integrations and the prelude. | Application crates, services and libraries that want a stable error surface. | | [`masterror-derive`](masterror-derive/README.md) | Proc-macros backing `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]` and `#[provide]`. | Brought in automatically via `masterror`; depend directly only for macro hacking. | | [`masterror-template`](masterror-template/README.md) | Shared template parser used by the derive macros for formatter analysis. | Internal dependency; reuse when you need the template parser elsewhere. | +| [`masterror-knowledge`](masterror-knowledge/README.md) | Knowledge base with 31+ error explanations and 15 best practices in 3 languages. | Used by [masterror-cli](https://github.com/RAprogramm/masterror-cli); depend directly for custom tooling. |
@@ -159,15 +162,15 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.27.2", default-features = false } +masterror = { version = "0.28.0", default-features = false } # or with features: -# masterror = { version = "0.27.2", features = [ +# masterror = { version = "0.28.0", features = [ # "std", "axum", "actix", "openapi", # "serde_json", "tracing", "metrics", "backtrace", # "colored", "sqlx", "sqlx-migrate", "reqwest", # "redis", "validator", "config", "tokio", # "multipart", "teloxide", "init-data", "tonic", -# "frontend", "turnkey", "benchmarks" +# "frontend", "turnkey", "knowledge", "benchmarks" # ] } ~~~ @@ -640,7 +643,7 @@ Enable the `colored` feature for enhanced terminal output in local mode: ~~~toml [dependencies] -masterror = { version = "0.27.2", features = ["colored"] } +masterror = { version = "0.28.0", features = ["colored"] } ~~~ With `colored` enabled, errors display with syntax highlighting: diff --git a/README.ru.md b/README.ru.md index a1c46b9..dd377a8 100644 --- a/README.ru.md +++ b/README.ru.md @@ -25,6 +25,8 @@ SPDX-License-Identifier: MIT > 🇬🇧 [Read README in English](README.md) > 🇰🇷 [한국어 README](README.ko.md) + **См. также:** [masterror-cli](https://github.com/RAprogramm/masterror-cli) — CLI-инструмент для объяснения ошибок компилятора Rust с подробными решениями, лучшими практиками и поддержкой нескольких языков. Установка: `cargo install masterror-cli` или из [AUR](https://aur.archlinux.org/packages/masterror-cli). +
> [!IMPORTANT] diff --git a/README.template.md b/README.template.md index 381a842..98e09e8 100644 --- a/README.template.md +++ b/README.template.md @@ -26,6 +26,8 @@ SPDX-License-Identifier: MIT > 🇷🇺 [Читайте README на русском языке](README.ru.md) > 🇰🇷 [한국어 README](README.ko.md) + **See also:** [masterror-cli](https://github.com/RAprogramm/masterror-cli) — CLI tool that explains Rust compiler errors with detailed solutions, best practices, and multi-language support. Install via `cargo install masterror-cli` or from [AUR](https://aur.archlinux.org/packages/masterror-cli). + --- @@ -113,6 +115,7 @@ of redaction and metadata. | [`masterror`](https://crates.io/crates/masterror) | Core error types, metadata builders, transports, integrations and the prelude. | Application crates, services and libraries that want a stable error surface. | | [`masterror-derive`](masterror-derive/README.md) | Proc-macros backing `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]` and `#[provide]`. | Brought in automatically via `masterror`; depend directly only for macro hacking. | | [`masterror-template`](masterror-template/README.md) | Shared template parser used by the derive macros for formatter analysis. | Internal dependency; reuse when you need the template parser elsewhere. | +| [`masterror-knowledge`](masterror-knowledge/README.md) | Knowledge base with 31+ error explanations and 15 best practices in 3 languages. | Used by [masterror-cli](https://github.com/RAprogramm/masterror-cli); depend directly for custom tooling. |
diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 0000000..95691c8 --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2025-2026 RAprogramm +# +# SPDX-License-Identifier: MIT + +version = 1 + +[[annotations]] +path = ["**/Cargo.lock", "pkg/aur/.SRCINFO", "**/.cargo/config.toml"] +SPDX-FileCopyrightText = "2025-2026 RAprogramm " +SPDX-License-Identifier = "MIT" + +[[annotations]] +path = ["masterror-knowledge/src/errors/ownership/e0382.rs", "masterror-knowledge/src/errors/ownership/e0384.rs"] +SPDX-FileCopyrightText = "2025-2026 RAprogramm " +SPDX-License-Identifier = "MIT" diff --git a/images/masterror-knowledge.png b/images/masterror-knowledge.png new file mode 100644 index 0000000..31baba7 Binary files /dev/null and b/images/masterror-knowledge.png differ diff --git a/images/masterror-knowledge.png.license b/images/masterror-knowledge.png.license new file mode 100644 index 0000000..c6d90e7 --- /dev/null +++ b/images/masterror-knowledge.png.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025-2026 RAprogramm + +SPDX-License-Identifier: MIT diff --git a/masterror-cli/Cargo.toml b/masterror-cli/Cargo.toml new file mode 100644 index 0000000..9b0b998 --- /dev/null +++ b/masterror-cli/Cargo.toml @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: 2025-2026 RAprogramm +# +# SPDX-License-Identifier: MIT + +[package] +name = "masterror-cli" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "CLI tool for explaining Rust compiler errors in human-friendly language" +keywords = ["rust", "compiler", "errors", "explain", "cli"] +categories = ["command-line-utilities", "development-tools"] + +[[bin]] +name = "masterror" +path = "src/main.rs" + +[[bin]] +name = "cargo-masterror" +path = "src/main.rs" + +[features] +default = ["show-why", "show-fix", "show-link", "show-translation"] + +# Display sections +show-why = [] +show-fix = [] +show-link = [] +show-translation = [] + +# Show original compiler output before masterror block +show-original = [] + +[dependencies] +masterror-knowledge = { version = "0.1", path = "../masterror-knowledge" } + +# CLI framework +clap = { version = "4", features = ["derive", "env", "wrap_help"] } + +# Colored output +owo-colors = { version = "4", features = ["supports-colors"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.9" + +# Config paths +dirs = "6" + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" diff --git a/masterror-cli/src/commands/check.rs b/masterror-cli/src/commands/check.rs new file mode 100644 index 0000000..d84bbb3 --- /dev/null +++ b/masterror-cli/src/commands/check.rs @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Check command - run cargo check and explain errors. + +use std::{ + io::{BufRead, BufReader}, + process::{Command, Stdio} +}; + +use masterror_knowledge::Lang; + +use crate::{ + error::{AppError, Result}, + options::DisplayOptions, + output, + parser::CargoMessage +}; + +/// Allowed cargo arguments whitelist. +const ALLOWED_ARGS: &[&str] = &[ + "--release", + "--all-targets", + "--all-features", + "--no-default-features", + "-p", + "--package", + "--workspace", + "--lib", + "--bins", + "--bin", + "--examples", + "--example", + "--tests", + "--test", + "--benches", + "--bench", + "--features", + "-F", + "--target", + "--profile", + "-j", + "--jobs", + "-v", + "--verbose", + "-q", + "--quiet", + "--locked", + "--frozen", + "--offline" +]; + +/// Validate cargo arguments against whitelist. +fn validate_args(args: &[String]) -> Result<()> { + for arg in args { + if arg.starts_with('-') { + let is_allowed = ALLOWED_ARGS + .iter() + .any(|allowed| arg == *allowed || arg.starts_with(&format!("{allowed}="))); + if !is_allowed { + return Err(AppError::InvalidArgument { + arg: arg.clone() + }); + } + } + } + Ok(()) +} + +/// Run cargo check and explain errors. +pub fn run(lang: Lang, args: &[String], opts: &DisplayOptions) -> Result<()> { + validate_args(args)?; + + let msg_format = if opts.colored { + "--message-format=json-diagnostic-rendered-ansi" + } else { + "--message-format=json" + }; + + let mut cmd = Command::new("cargo") + .arg("check") + .arg(msg_format) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?; + + let stdout = cmd + .stdout + .take() + .ok_or_else(|| AppError::Io(std::io::Error::other("failed to capture stdout")))?; + let reader = BufReader::new(stdout); + + let mut error_count = 0; + + for line in reader.lines() { + let line = line?; + if let Ok(msg) = serde_json::from_str::(&line) + && msg.is_error() + { + error_count += 1; + output::print_error(lang, &msg, opts); + println!(); + } + } + + let status = cmd.wait()?; + + if error_count > 0 { + println!("Found {error_count} error(s). Run `masterror explain ` for details."); + } + + if !status.success() { + match status.code() { + Some(code) => { + return Err(AppError::CargoFailed { + code + }); + } + None => return Err(AppError::CargoSignaled) + } + } + + Ok(()) +} diff --git a/masterror-cli/src/commands/explain.rs b/masterror-cli/src/commands/explain.rs new file mode 100644 index 0000000..0a6dcf7 --- /dev/null +++ b/masterror-cli/src/commands/explain.rs @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Explain command - explain a specific error code or best practice. + +use masterror_knowledge::{ + BestPractice, ErrorEntry, ErrorRegistry, Lang, PracticeRegistry, UiMsg +}; +use owo_colors::OwoColorize; + +use crate::{ + error::{AppError, Result}, + options::DisplayOptions +}; + +/// Explain a specific error code (E0382) or best practice (RA001). +pub fn run(lang: Lang, code: &str, opts: &DisplayOptions) -> Result<()> { + let upper = code.to_uppercase(); + + if upper.starts_with("RA") { + let registry = PracticeRegistry::new(); + if let Some(practice) = registry.find(&upper) { + print_practice(lang, practice, opts); + return Ok(()); + } + } + + let registry = ErrorRegistry::new(); + if let Some(entry) = registry.find(code) { + print_error(lang, entry, opts); + return Ok(()); + } + + Err(AppError::UnknownErrorCode { + code: code.to_string() + }) +} + +fn print_error(lang: Lang, entry: &ErrorEntry, opts: &DisplayOptions) { + println!(); + + let title = entry.title.get(lang.code()); + if opts.colored { + println!("{} - {}", entry.code.yellow().bold(), title.bold()); + } else { + println!("{} - {title}", entry.code); + } + + let category = entry.category.name(lang.code()); + if opts.colored { + println!("{}: {}", UiMsg::Category.get(lang), category.dimmed()); + } else { + println!("{}: {category}", UiMsg::Category.get(lang)); + } + + println!(); + let why_label = UiMsg::LabelWhy.get(lang); + if opts.colored { + println!("{}", why_label.green().bold()); + } else { + println!("{why_label}"); + } + println!("{}", entry.explanation.get(lang.code())); + + if !entry.fixes.is_empty() { + println!(); + let fix_label = UiMsg::LabelFix.get(lang); + if opts.colored { + println!("{}", fix_label.green().bold()); + } else { + println!("{fix_label}"); + } + for (i, fix) in entry.fixes.iter().enumerate() { + println!(); + println!("{}. {}", i + 1, fix.description.get(lang.code())); + println!("```rust"); + println!("{}", fix.code); + println!("```"); + } + } + + if !entry.links.is_empty() { + println!(); + let link_label = UiMsg::LabelLink.get(lang); + if opts.colored { + println!("{}", link_label.cyan().bold()); + } else { + println!("{link_label}"); + } + for link in entry.links { + if opts.colored { + println!(" - {} {}", link.title, link.url.dimmed()); + } else { + println!(" - {} {}", link.title, link.url); + } + } + } + + println!(); +} + +/// Print best practice details. +pub fn print_practice(lang: Lang, practice: &BestPractice, opts: &DisplayOptions) { + println!(); + + let title = practice.title.get(lang.code()); + if opts.colored { + println!("{} - {}", practice.code.yellow().bold(), title.bold()); + } else { + println!("{} - {title}", practice.code); + } + + let category = practice.category.name(lang.code()); + if opts.colored { + println!("{}: {}", UiMsg::Category.get(lang), category.dimmed()); + } else { + println!("{}: {category}", UiMsg::Category.get(lang)); + } + + println!(); + let why_label = UiMsg::LabelWhyMatters.get(lang); + if opts.colored { + println!("{}", why_label.green().bold()); + } else { + println!("{why_label}"); + } + println!("{}", practice.explanation.get(lang.code())); + + println!(); + let how_label = UiMsg::LabelHowToApply.get(lang); + if opts.colored { + println!("{}", how_label.green().bold()); + } else { + println!("{how_label}"); + } + + println!(); + let avoid_label = UiMsg::LabelAvoid.get(lang); + if opts.colored { + println!("{}. {}", "1".cyan(), avoid_label.red()); + } else { + println!("1. {avoid_label}"); + } + println!("```rust"); + println!("{}", practice.bad_example); + println!("```"); + + println!(); + let prefer_label = UiMsg::LabelPrefer.get(lang); + if opts.colored { + println!("{}. {}", "2".cyan(), prefer_label.green()); + } else { + println!("2. {prefer_label}"); + } + println!("```rust"); + println!("{}", practice.good_example); + println!("```"); + + println!(); + let link_label = UiMsg::LabelLink.get(lang); + if opts.colored { + println!("{}", link_label.cyan().bold()); + } else { + println!("{link_label}"); + } + if opts.colored { + println!(" - RustManifest {}", practice.source.dimmed()); + } else { + println!(" - RustManifest {}", practice.source); + } + + println!(); +} diff --git a/masterror-cli/src/commands/init.rs b/masterror-cli/src/commands/init.rs new file mode 100644 index 0000000..59d9d0e --- /dev/null +++ b/masterror-cli/src/commands/init.rs @@ -0,0 +1,295 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Init command - create configuration file interactively. + +use std::io::{self, Write}; + +use masterror_knowledge::{Lang, UiMsg}; +use owo_colors::OwoColorize; + +use crate::{ + config::{Config, DisplayConfig, GeneralConfig}, + error::Result +}; + +/// Run init command - create configuration interactively. +pub fn run(_lang: Lang, colored: bool) -> Result<()> { + println!(); + print_welcome(colored); + println!(); + + // First ask language - this affects all subsequent prompts + let lang_value = prompt_language(colored)?; + let lang = masterror_knowledge::Lang::from_code(&lang_value); + + let color_value = prompt_colors(lang, colored)?; + let display = prompt_display(lang, colored)?; + let save_location = prompt_save_location(lang, colored)?; + + let config = Config { + general: GeneralConfig { + lang: lang_value, + colored: color_value + }, + display, + aliases: default_aliases() + }; + + let saved_path = match save_location { + SaveLocation::Global => { + config.save()?; + Config::path() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "~/.config/masterror/config.toml".to_string()) + } + SaveLocation::Local => { + let path = config.save_local()?; + path.display().to_string() + } + }; + + println!(); + print_success(lang, colored, &saved_path); + print_tips(lang, colored, &save_location); + + Ok(()) +} + +/// Check if first run and prompt for setup. +pub fn check_first_run(colored: bool) -> Result { + if !Config::is_first_run() { + return Ok(false); + } + + println!(); + if colored { + println!( + "{}", + "Welcome to masterror! Let's set up your preferences.".cyan() + ); + println!( + "{}", + "(This only happens once. Run `masterror init` to reconfigure later.)".dimmed() + ); + } else { + println!("Welcome to masterror! Let's set up your preferences."); + println!("(This only happens once. Run `masterror init` to reconfigure later.)"); + } + println!(); + + run(Lang::En, colored)?; + Ok(true) +} + +#[derive(Clone, Copy)] +enum SaveLocation { + Global, + Local +} + +fn print_welcome(colored: bool) { + let title = "masterror - Rust compiler error explainer"; + let subtitle = "Let's configure your preferences"; + + if colored { + println!("{}", title.bold().cyan()); + println!("{}", subtitle.dimmed()); + } else { + println!("{title}"); + println!("{subtitle}"); + } +} + +fn prompt_language(colored: bool) -> Result { + println!(); + if colored { + println!( + "{}", + "Select your language / Выберите язык / 언어 선택".bold() + ); + println!(" {} - English", "en".cyan()); + println!(" {} - Русский", "ru".cyan()); + println!(" {} - 한국어", "ko".cyan()); + } else { + println!("Select your language / Выберите язык / 언어 선택"); + println!(" en - English"); + println!(" ru - Русский"); + println!(" ko - 한국어"); + } + + if colored { + print!("{} [en/ru/ko] ({}): ", "Choice".bold(), "en".dimmed()); + } else { + print!("Choice [en/ru/ko] (en): "); + } + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let choice = input.trim().to_lowercase(); + + Ok( + if choice.is_empty() || !["en", "ru", "ko"].contains(&choice.as_str()) { + "en".to_string() + } else { + choice + } + ) +} + +fn prompt_colors(lang: Lang, colored: bool) -> Result { + let prompt = UiMsg::InitColorPrompt.get(lang); + + if colored { + print!("{} [y/n] ({}): ", prompt.bold(), "y".dimmed()); + } else { + print!("{prompt} [y/n] (y): "); + } + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + Ok(!matches!(input.trim().to_lowercase().as_str(), "n" | "no")) +} + +fn prompt_display(lang: Lang, colored: bool) -> Result { + let prompt = UiMsg::InitDisplayPrompt.get(lang); + println!(); + if colored { + println!("{}", prompt.bold()); + } else { + println!("{prompt}"); + } + + let translation = prompt_bool(lang, colored, UiMsg::InitShowTranslation, true)?; + let why = prompt_bool(lang, colored, UiMsg::InitShowWhy, true)?; + let fix = prompt_bool(lang, colored, UiMsg::InitShowFix, true)?; + let links = prompt_bool(lang, colored, UiMsg::InitShowLinks, true)?; + let original = prompt_bool(lang, colored, UiMsg::InitShowOriginal, false)?; + + Ok(DisplayConfig { + translation, + why, + fix, + links, + original + }) +} + +fn prompt_save_location(lang: Lang, colored: bool) -> Result { + println!(); + let global_label = UiMsg::InitSaveGlobal.get(lang); + let local_label = UiMsg::InitSaveLocal.get(lang); + + if colored { + println!("{}", UiMsg::InitSavePrompt.get(lang).bold()); + println!(" {} - {}", "1".cyan(), global_label); + println!(" {} - {}", "2".cyan(), local_label); + } else { + println!("{}", UiMsg::InitSavePrompt.get(lang)); + println!(" 1 - {global_label}"); + println!(" 2 - {local_label}"); + } + + if colored { + print!("{} [1/2] ({}): ", "Choice".bold(), "1".dimmed()); + } else { + print!("Choice [1/2] (1): "); + } + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + Ok(match input.trim() { + "2" => SaveLocation::Local, + _ => SaveLocation::Global + }) +} + +fn prompt_bool(lang: Lang, colored: bool, msg: UiMsg, default: bool) -> Result { + let label = msg.get(lang); + let default_str = if default { "y" } else { "n" }; + + if colored { + print!(" {} [y/n] ({}): ", label, default_str.dimmed()); + } else { + print!(" {label} [y/n] ({default_str}): "); + } + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let trimmed = input.trim().to_lowercase(); + + Ok(if trimmed.is_empty() { + default + } else { + matches!(trimmed.as_str(), "y" | "yes" | "д" | "да") + }) +} + +fn print_success(lang: Lang, colored: bool, path: &str) { + let msg = UiMsg::InitSuccess.get(lang); + + if colored { + println!("{} {}", msg.green(), path.dimmed()); + } else { + println!("{msg} {path}"); + } +} + +fn print_tips(lang: Lang, colored: bool, location: &SaveLocation) { + println!(); + let tip = UiMsg::InitTip.get(lang); + + if colored { + println!("{}", tip.dimmed()); + } else { + println!("{tip}"); + } + + match location { + SaveLocation::Global => { + let global_path = Config::path() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "~/.config/masterror/config.toml".to_string()); + if colored { + println!(" {} {}", "Global:".dimmed(), global_path.dimmed()); + } else { + println!(" Global: {global_path}"); + } + } + SaveLocation::Local => { + if colored { + println!(" {} .masterror.toml", "Local:".dimmed()); + } else { + println!(" Local: .masterror.toml"); + } + } + } + + println!(); + let usage = UiMsg::InitUsage.get(lang); + if colored { + println!("{}", usage.cyan()); + println!(" {} cargo masterror check", "$".dimmed()); + println!(" {} masterror explain E0382", "$".dimmed()); + } else { + println!("{usage}"); + println!(" $ cargo masterror check"); + println!(" $ masterror explain E0382"); + } + println!(); +} + +fn default_aliases() -> std::collections::HashMap { + let mut aliases = std::collections::HashMap::new(); + aliases.insert("c".to_string(), "check".to_string()); + aliases.insert("e".to_string(), "explain".to_string()); + aliases.insert("l".to_string(), "list".to_string()); + aliases.insert("p".to_string(), "practice".to_string()); + aliases +} diff --git a/masterror-cli/src/commands/list.rs b/masterror-cli/src/commands/list.rs new file mode 100644 index 0000000..6c4436e --- /dev/null +++ b/masterror-cli/src/commands/list.rs @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! List command - list all known error codes. + +use masterror_knowledge::{Category, ErrorRegistry, Lang}; +use owo_colors::OwoColorize; + +use crate::{ + error::{AppError, Result}, + options::DisplayOptions +}; + +/// List all known error codes. +pub fn run(lang: Lang, category: Option<&str>, opts: &DisplayOptions) -> Result<()> { + let registry = ErrorRegistry::new(); + + println!(); + if opts.colored { + println!("{}", "Known Rust Compiler Errors".bold()); + } else { + println!("Known Rust Compiler Errors"); + } + println!(); + + let mut entries: Vec<_> = if let Some(cat) = category { + let cat = parse_category(cat); + if let Some(c) = cat { + registry.by_category(c) + } else { + return Err(AppError::InvalidCategory { + name: category.unwrap_or("").to_string() + }); + } + } else { + registry.all().collect() + }; + + if entries.is_empty() { + println!(" No errors found."); + return Ok(()); + } + + entries.sort_by_key(|e| e.code); + + let mut current_cat: Option = None; + for entry in &entries { + if current_cat != Some(entry.category) { + current_cat = Some(entry.category); + println!(); + let cat_name = entry.category.name(lang.code()); + if opts.colored { + println!(" {}", cat_name.yellow().bold()); + } else { + println!(" {cat_name}"); + } + println!(); + } + + let title = entry.title.get(lang.code()); + if opts.colored { + println!(" {} - {title}", entry.code.cyan()); + } else { + println!(" {} - {title}", entry.code); + } + } + + println!(); + println!("Total: {} errors", entries.len()); + println!(); + println!("Use `masterror explain ` to see details."); + println!("Use `masterror practice` to see best practices."); + println!(); + + Ok(()) +} + +fn parse_category(s: &str) -> Option { + match s.to_lowercase().as_str() { + "ownership" | "own" => Some(Category::Ownership), + "borrowing" | "borrow" => Some(Category::Borrowing), + "lifetimes" | "lifetime" | "life" => Some(Category::Lifetimes), + "types" | "type" => Some(Category::Types), + "traits" | "trait" => Some(Category::Traits), + "resolution" | "resolve" | "names" => Some(Category::Resolution), + _ => None + } +} diff --git a/masterror-cli/src/commands/mod.rs b/masterror-cli/src/commands/mod.rs new file mode 100644 index 0000000..7f2c260 --- /dev/null +++ b/masterror-cli/src/commands/mod.rs @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! CLI commands. + +mod check; +mod explain; +pub mod init; +mod list; +pub mod practice; + +pub use check::run as check; +pub use explain::run as explain; +pub use init::run as init; +pub use list::run as list; diff --git a/masterror-cli/src/commands/practice.rs b/masterror-cli/src/commands/practice.rs new file mode 100644 index 0000000..e3ed893 --- /dev/null +++ b/masterror-cli/src/commands/practice.rs @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Practice command - show best practices from RustManifest. + +use masterror_knowledge::{Lang, PracticeCategory, PracticeRegistry}; +use owo_colors::OwoColorize; + +use super::explain::print_practice; +use crate::{ + error::{AppError, Result}, + options::DisplayOptions +}; + +/// List all best practices or filter by category. +pub fn list(lang: Lang, category: Option<&str>, opts: &DisplayOptions) -> Result<()> { + let registry = PracticeRegistry::new(); + + println!(); + if opts.colored { + println!("{}", "RustManifest Best Practices".bold()); + } else { + println!("RustManifest Best Practices"); + } + println!(); + + let practices: Vec<_> = if let Some(cat) = category { + let cat = parse_category(cat); + if let Some(c) = cat { + registry.by_category(c) + } else { + return Err(AppError::InvalidCategory { + name: category.unwrap_or("").to_string() + }); + } + } else { + registry.all().collect() + }; + + if practices.is_empty() { + println!(" No practices found."); + return Ok(()); + } + + let mut sorted = practices; + sorted.sort_by_key(|p| p.code); + + let mut current_cat: Option = None; + for practice in &sorted { + if current_cat != Some(practice.category) { + current_cat = Some(practice.category); + println!(); + if opts.colored { + println!(" {}", practice.category.name(lang.code()).yellow().bold()); + } else { + println!(" {}", practice.category.name(lang.code())); + } + println!(); + } + + let title = practice.title.get(lang.code()); + if opts.colored { + println!(" {} - {title}", practice.code.cyan()); + } else { + println!(" {} - {title}", practice.code); + } + } + + println!(); + println!("Total: {} practices", sorted.len()); + println!(); + println!("Use `masterror practice ` to see details."); + println!(); + + Ok(()) +} + +/// Show a specific best practice. +pub fn show(lang: Lang, code: &str, opts: &DisplayOptions) -> Result<()> { + let registry = PracticeRegistry::new(); + + let Some(practice) = registry.find(code) else { + return Err(AppError::UnknownPracticeCode { + code: code.to_string() + }); + }; + + print_practice(lang, practice, opts); + Ok(()) +} + +fn parse_category(s: &str) -> Option { + match s.to_lowercase().as_str() { + "error-handling" | "error_handling" | "errorhandling" | "errors" => { + Some(PracticeCategory::ErrorHandling) + } + "performance" | "perf" => Some(PracticeCategory::Performance), + "naming" | "names" => Some(PracticeCategory::Naming), + "documentation" | "docs" | "doc" => Some(PracticeCategory::Documentation), + "design" | "architecture" | "arch" => Some(PracticeCategory::Design), + "testing" | "tests" | "test" => Some(PracticeCategory::Testing), + "security" | "sec" => Some(PracticeCategory::Security), + _ => None + } +} diff --git a/masterror-cli/src/config.rs b/masterror-cli/src/config.rs new file mode 100644 index 0000000..b9b0cb0 --- /dev/null +++ b/masterror-cli/src/config.rs @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Configuration management for masterror-cli. +//! +//! Supports layered configuration (highest priority first): +//! 1. CLI arguments +//! 2. Environment variables +//! 3. Local project config (.masterror.toml in current directory) +//! 4. Global config (~/.config/masterror/config.toml) +//! 5. Default values + +use std::{collections::HashMap, env, fs, path::PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::error::{AppError, Result}; + +/// Global config file name. +const CONFIG_FILE: &str = "config.toml"; + +/// Local project config file name. +const LOCAL_CONFIG_FILE: &str = ".masterror.toml"; + +/// Config directory name. +const CONFIG_DIR: &str = "masterror"; + +/// Application configuration. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct Config { + /// General settings. + pub general: GeneralConfig, + /// Display settings. + pub display: DisplayConfig, + /// Command aliases. + pub aliases: HashMap +} + +/// General configuration options. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct GeneralConfig { + /// Language code (en, ru, ko). + pub lang: String, + /// Enable colored output. + pub colored: bool +} + +/// Display section toggles. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct DisplayConfig { + /// Show translated error message. + pub translation: bool, + /// Show "why this happens" explanation. + pub why: bool, + /// Show fix suggestions. + pub fix: bool, + /// Show documentation links. + pub links: bool, + /// Show original compiler output. + pub original: bool +} + +impl Default for GeneralConfig { + fn default() -> Self { + Self { + lang: "en".to_string(), + colored: true + } + } +} + +impl Default for DisplayConfig { + fn default() -> Self { + Self { + translation: true, + why: true, + fix: true, + links: true, + original: false + } + } +} + +impl Config { + /// Get global config directory path. + pub fn dir() -> Option { + dirs::config_dir().map(|p| p.join(CONFIG_DIR)) + } + + /// Get global config file path. + pub fn path() -> Option { + Self::dir().map(|p| p.join(CONFIG_FILE)) + } + + /// Get local project config path (.masterror.toml in current directory). + pub fn local_path() -> Option { + env::current_dir().ok().map(|p| p.join(LOCAL_CONFIG_FILE)) + } + + /// Check if this is the first run (no global config exists). + pub fn is_first_run() -> bool { + Self::path().is_none_or(|p| !p.exists()) + } + + /// Load config with layered priority: + /// 1. Local .masterror.toml (if exists) + /// 2. Global ~/.config/masterror/config.toml + /// 3. Defaults + pub fn load() -> Result { + // Try local config first + if let Some(local_path) = Self::local_path() + && local_path.exists() + { + let content = fs::read_to_string(&local_path)?; + let config: Config = toml::from_str(&content).map_err(|e| AppError::ConfigParse { + path: local_path.clone(), + message: e.message().to_string() + })?; + return Ok(config); + } + + // Try global config + let Some(path) = Self::path() else { + return Ok(Self::default()); + }; + + if !path.exists() { + return Ok(Self::default()); + } + + let content = fs::read_to_string(&path)?; + let config: Config = toml::from_str(&content).map_err(|e| AppError::ConfigParse { + path: path.clone(), + message: e.message().to_string() + })?; + + Ok(config) + } + + /// Save config to global file. + pub fn save(&self) -> Result<()> { + let Some(dir) = Self::dir() else { + return Err(AppError::Io(std::io::Error::other( + "could not determine config directory" + ))); + }; + + fs::create_dir_all(&dir)?; + + let path = dir.join(CONFIG_FILE); + let content = toml::to_string_pretty(self).map_err(|e| AppError::ConfigParse { + path: path.clone(), + message: e.to_string() + })?; + + fs::write(&path, content)?; + Ok(()) + } + + /// Save config to local project file (.masterror.toml). + pub fn save_local(&self) -> Result { + let path = env::current_dir()?.join(LOCAL_CONFIG_FILE); + let content = toml::to_string_pretty(self).map_err(|e| AppError::ConfigParse { + path: path.clone(), + message: e.to_string() + })?; + + fs::write(&path, &content)?; + Ok(path) + } + + /// Resolve command alias. + #[allow(dead_code)] + pub fn resolve_alias<'a>(&'a self, cmd: &'a str) -> &'a str { + self.aliases.get(cmd).map(String::as_str).unwrap_or(cmd) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = Config::default(); + assert_eq!(config.general.lang, "en"); + assert!(config.general.colored); + assert!(config.display.why); + assert!(config.display.fix); + } + + #[test] + fn test_toml_roundtrip() { + let config = Config::default(); + let toml = toml::to_string_pretty(&config).unwrap(); + let parsed: Config = toml::from_str(&toml).unwrap(); + assert_eq!(parsed.general.lang, config.general.lang); + } +} diff --git a/masterror-cli/src/error.rs b/masterror-cli/src/error.rs new file mode 100644 index 0000000..bfeb345 --- /dev/null +++ b/masterror-cli/src/error.rs @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Application error types for masterror-cli. + +use std::{fmt, io, path::PathBuf}; + +/// Application-wide error type. +#[derive(Debug)] +pub enum AppError { + /// I/O error (file operations, process spawning). + Io(io::Error), + /// JSON parsing error from cargo output. + Json(serde_json::Error), + /// Cargo check process failed with exit code. + CargoFailed { + /// Exit code from cargo process. + code: i32 + }, + /// Cargo check process was terminated by signal. + CargoSignaled, + /// Unknown error code requested. + UnknownErrorCode { + /// The requested error code. + code: String + }, + /// Unknown practice code requested. + UnknownPracticeCode { + /// The requested practice code. + code: String + }, + /// Invalid category name. + InvalidCategory { + /// The invalid category name. + name: String + }, + /// Invalid command-line argument. + InvalidArgument { + /// The invalid argument. + arg: String + }, + /// Config file parse error. + ConfigParse { + /// Path to config file. + path: PathBuf, + /// Error message. + message: String + }, + /// Error with additional context. + #[allow(dead_code)] + WithContext { + /// Context message. + context: String, + /// Original error. + source: Box + } +} + +impl fmt::Display for AppError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(e) => write!(f, "I/O error: {e}"), + Self::Json(e) => write!(f, "JSON parse error: {e}"), + Self::CargoFailed { + code + } => write!(f, "cargo check failed with exit code {code}"), + Self::CargoSignaled => write!(f, "cargo check was terminated by signal"), + Self::UnknownErrorCode { + code + } => write!(f, "unknown error code: {code}"), + Self::UnknownPracticeCode { + code + } => write!(f, "unknown practice code: {code}"), + Self::InvalidCategory { + name + } => write!(f, "invalid category: {name}"), + Self::InvalidArgument { + arg + } => write!(f, "invalid argument: {arg}"), + Self::ConfigParse { + path, + message + } => write!(f, "config error in {}: {message}", path.display()), + Self::WithContext { + context, + source + } => write!(f, "{context}: {source}") + } + } +} + +impl std::error::Error for AppError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(e) => Some(e), + Self::Json(e) => Some(e), + Self::WithContext { + source, .. + } => Some(source.as_ref()), + _ => None + } + } +} + +impl From for AppError { + fn from(err: io::Error) -> Self { + Self::Io(err) + } +} + +impl From for AppError { + fn from(err: serde_json::Error) -> Self { + Self::Json(err) + } +} + +/// Result type alias for AppError. +pub type Result = std::result::Result; + +/// Extension trait for adding context to Results. +/// +/// # Example +/// +/// ```ignore +/// use masterror::error::ResultExt; +/// +/// fs::read_to_string(path).context("reading config")?; +/// ``` +#[allow(dead_code)] +pub trait ResultExt { + /// Add static context to an error. + fn context(self, ctx: &'static str) -> Result; + + /// Add dynamic context to an error. + fn with_context(self, f: F) -> Result + where + F: FnOnce() -> S, + S: Into; +} + +impl ResultExt for std::result::Result +where + E: Into +{ + fn context(self, ctx: &'static str) -> Result { + self.map_err(|e| { + let inner = e.into(); + AppError::WithContext { + context: ctx.to_string(), + source: Box::new(inner) + } + }) + } + + fn with_context(self, f: F) -> Result + where + F: FnOnce() -> S, + S: Into + { + self.map_err(|e| { + let inner = e.into(); + AppError::WithContext { + context: f().into(), + source: Box::new(inner) + } + }) + } +} diff --git a/masterror-cli/src/main.rs b/masterror-cli/src/main.rs new file mode 100644 index 0000000..47b006a --- /dev/null +++ b/masterror-cli/src/main.rs @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! masterror CLI - Rust compiler error explainer. + +mod commands; +mod config; +mod error; +mod options; +mod output; +mod parser; +mod sections; + +use clap::{Parser, Subcommand}; +use masterror_knowledge::Lang; + +use crate::{config::Config, options::DisplayOptions}; + +#[derive(Parser)] +#[command(name = "masterror")] +#[command(author, version, about = "Rust compiler error explainer")] +struct Cli { + /// Language for explanations (en, ru, ko) + #[arg(short, long, env = "MASTERROR_LANG")] + lang: Option, + + /// Disable colored output + #[arg(long, env = "NO_COLOR")] + no_color: bool, + + #[command(subcommand)] + command: Commands +} + +#[derive(Subcommand)] +enum Commands { + /// Initialize configuration file + Init, + /// Run cargo check and explain errors + #[command(visible_alias = "c")] + Check { + #[arg(trailing_var_arg = true)] + args: Vec + }, + /// Explain a specific error code (E0382) or best practice (RA001) + #[command(visible_alias = "e")] + Explain { code: String }, + /// List all known error codes + #[command(visible_alias = "l")] + List { + #[arg(short, long)] + category: Option + }, + /// Show RustManifest best practices + #[command(visible_alias = "p")] + Practice { + /// Practice code (RA001-RA015) or empty for list + code: Option, + /// Filter by category + #[arg(short, long)] + category: Option + } +} + +fn main() { + let args: Vec = std::env::args().collect(); + let cli = if args.get(1).is_some_and(|a| a == "masterror") { + Cli::parse_from( + args.into_iter() + .enumerate() + .filter_map(|(i, a)| if i == 1 { None } else { Some(a) }) + ) + } else { + Cli::parse() + }; + + // Check for first run and run setup if needed (except for init command) + if !matches!(cli.command, Commands::Init) + && let Err(e) = commands::init::check_first_run(true) + { + eprintln!("Setup failed: {e}"); + std::process::exit(1); + } + + let config = Config::load().unwrap_or_default(); + let lang_str = cli.lang.as_deref().unwrap_or(&config.general.lang); + let lang = Lang::from_code(lang_str); + let colored = if cli.no_color { + false + } else { + config.general.colored + }; + + let opts = DisplayOptions { + colored, + show_translation: config.display.translation, + show_why: config.display.why, + show_fix: config.display.fix, + show_links: config.display.links, + show_original: config.display.original + }; + + let result = match &cli.command { + Commands::Init => commands::init(lang, opts.colored), + Commands::Check { + args + } => commands::check(lang, args, &opts), + Commands::Explain { + code + } => commands::explain(lang, code, &opts), + Commands::List { + category + } => commands::list(lang, category.as_deref(), &opts), + Commands::Practice { + code, + category + } => { + if let Some(c) = code { + commands::practice::show(lang, c, &opts) + } else { + commands::practice::list(lang, category.as_deref(), &opts) + } + } + }; + + if let Err(e) = result { + eprintln!("Error: {e}"); + std::process::exit(1); + } +} diff --git a/masterror-cli/src/options.rs b/masterror-cli/src/options.rs new file mode 100644 index 0000000..8256a87 --- /dev/null +++ b/masterror-cli/src/options.rs @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Display options for masterror output. + +/// What sections to show in masterror block. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DisplayOptions { + /// Enable colored output. + pub colored: bool, + /// Show translated error message. + pub show_translation: bool, + /// Show "why this happens" explanation. + pub show_why: bool, + /// Show fix suggestions. + pub show_fix: bool, + /// Show documentation links. + pub show_links: bool, + /// Show original compiler output. + pub show_original: bool +} + +impl DisplayOptions { + /// Default options as const value. + pub const DEFAULT: Self = Self { + colored: true, + show_translation: true, + show_why: true, + show_fix: true, + show_links: true, + show_original: false + }; + + /// Create new builder for constructing DisplayOptions. + #[allow(dead_code)] + pub const fn builder() -> DisplayOptionsBuilder { + DisplayOptionsBuilder::new() + } +} + +impl Default for DisplayOptions { + fn default() -> Self { + Self::DEFAULT + } +} + +/// Builder for constructing DisplayOptions with const support. +/// +/// # Example +/// +/// ```ignore +/// use masterror_cli::options::DisplayOptions; +/// +/// const OPTS: DisplayOptions = DisplayOptions::builder() +/// .colored(false) +/// .show_original(true) +/// .build(); +/// ``` +#[derive(Clone, Copy, Debug)] +#[allow(dead_code)] +pub struct DisplayOptionsBuilder { + colored: bool, + show_translation: bool, + show_why: bool, + show_fix: bool, + show_links: bool, + show_original: bool +} + +#[allow(dead_code)] +impl DisplayOptionsBuilder { + /// Create new builder with default values. + pub const fn new() -> Self { + Self { + colored: true, + show_translation: true, + show_why: true, + show_fix: true, + show_links: true, + show_original: false + } + } + + /// Set colored output. + pub const fn colored(mut self, value: bool) -> Self { + self.colored = value; + self + } + + /// Set show translation. + pub const fn show_translation(mut self, value: bool) -> Self { + self.show_translation = value; + self + } + + /// Set show why explanation. + pub const fn show_why(mut self, value: bool) -> Self { + self.show_why = value; + self + } + + /// Set show fix suggestions. + pub const fn show_fix(mut self, value: bool) -> Self { + self.show_fix = value; + self + } + + /// Set show documentation links. + pub const fn show_links(mut self, value: bool) -> Self { + self.show_links = value; + self + } + + /// Set show original compiler output. + pub const fn show_original(mut self, value: bool) -> Self { + self.show_original = value; + self + } + + /// Build the DisplayOptions. + pub const fn build(self) -> DisplayOptions { + DisplayOptions { + colored: self.colored, + show_translation: self.show_translation, + show_why: self.show_why, + show_fix: self.show_fix, + show_links: self.show_links, + show_original: self.show_original + } + } +} + +impl Default for DisplayOptionsBuilder { + fn default() -> Self { + Self::new() + } +} diff --git a/masterror-cli/src/output.rs b/masterror-cli/src/output.rs new file mode 100644 index 0000000..4e0167c --- /dev/null +++ b/masterror-cli/src/output.rs @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Terminal output formatting for errors. + +use masterror_knowledge::{ErrorEntry, ErrorRegistry, Lang, UiMsg}; +use owo_colors::OwoColorize; + +use crate::{options::DisplayOptions, parser::CargoMessage, sections}; + +const SEPARATOR: &str = "--- masterror ----------------------------------------"; +const SEPARATOR_END: &str = "------------------------------------------------------"; + +/// Print error with masterror explanation. +pub fn print_error(lang: Lang, msg: &CargoMessage, opts: &DisplayOptions) { + let rendered = msg.rendered_output(); + + if opts.show_original + && let Some(r) = rendered + { + print!("{}", r.trim_end()); + } + + let Some(code) = msg.error_code() else { + if opts.show_original { + println!(); + } + return; + }; + + let registry = ErrorRegistry::new(); + let Some(entry) = registry.find(code) else { + if opts.show_original { + println!(); + } + return; + }; + + println!(); + print_block(lang, entry, rendered, opts); +} + +fn print_block(lang: Lang, entry: &ErrorEntry, rendered: Option<&str>, opts: &DisplayOptions) { + if opts.colored { + println!("{}", SEPARATOR.dimmed()); + } else { + println!("{SEPARATOR}"); + } + + if opts.show_translation { + sections::translation::print(lang, rendered, opts.colored); + } + + if opts.show_why { + let label = UiMsg::LabelWhy.get(lang); + if opts.colored { + println!("{}", label.green().bold()); + } else { + println!("{label}"); + } + println!("{}", entry.explanation.get(lang.code())); + } + + if opts.show_fix { + sections::fix::print(lang, entry.fixes, opts.colored); + } + + if opts.show_links { + sections::link::print(lang, entry.links, opts.colored); + } + + if opts.colored { + println!("{}", SEPARATOR_END.dimmed()); + } else { + println!("{SEPARATOR_END}"); + } +} diff --git a/masterror-cli/src/parser.rs b/masterror-cli/src/parser.rs new file mode 100644 index 0000000..6b25c8f --- /dev/null +++ b/masterror-cli/src/parser.rs @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Cargo JSON output parser. + +use serde::Deserialize; + +/// Top-level cargo message. +#[derive(Deserialize)] +pub struct CargoMessage { + pub reason: String, + pub message: Option, + /// Full rendered compiler output. + pub rendered: Option +} + +/// Compiler diagnostic message. +#[derive(Deserialize)] +pub struct DiagnosticMessage { + pub level: String, + #[allow(dead_code)] + pub message: String, + pub code: Option, + pub rendered: Option +} + +/// Error code info. +#[derive(Deserialize)] +pub struct DiagnosticCode { + pub code: String +} + +impl CargoMessage { + /// Check if this is a compiler error message. + pub fn is_error(&self) -> bool { + self.reason == "compiler-message" + && self.message.as_ref().is_some_and(|m| m.level == "error") + } + + /// Get the error code if present. + pub fn error_code(&self) -> Option<&str> { + self.message + .as_ref() + .and_then(|m| m.code.as_ref()) + .map(|c| c.code.as_str()) + } + + /// Get the error message. + #[allow(dead_code)] + pub fn error_message(&self) -> Option<&str> { + self.message.as_ref().map(|m| m.message.as_str()) + } + + /// Get rendered output (from message or top-level). + pub fn rendered_output(&self) -> Option<&str> { + self.message + .as_ref() + .and_then(|m| m.rendered.as_deref()) + .or(self.rendered.as_deref()) + } +} diff --git a/masterror-cli/src/sections/fix.rs b/masterror-cli/src/sections/fix.rs new file mode 100644 index 0000000..113c62d --- /dev/null +++ b/masterror-cli/src/sections/fix.rs @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Fix section - shows fix suggestions with code examples. + +use masterror_knowledge::{FixSuggestion, Lang, UiMsg}; +use owo_colors::OwoColorize; + +/// Print fix suggestions with code examples. +pub fn print(lang: Lang, fixes: &[FixSuggestion], colored: bool) { + if fixes.is_empty() { + return; + } + + let label = UiMsg::LabelFix.get(lang); + + if colored { + println!("{}", label.green().bold()); + } else { + println!("{label}"); + } + + for (i, fix) in fixes.iter().enumerate() { + let desc = fix.description.get(lang.code()); + let num = i + 1; + if colored { + println!(" {}. {}", num.cyan(), desc); + println!(" {}", fix.code.dimmed()); + } else { + println!(" {num}. {desc}"); + println!(" {}", fix.code); + } + } +} diff --git a/masterror-cli/src/sections/link.rs b/masterror-cli/src/sections/link.rs new file mode 100644 index 0000000..d8e8843 --- /dev/null +++ b/masterror-cli/src/sections/link.rs @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Link section - shows documentation URLs. + +use masterror_knowledge::{DocLink, Lang, UiMsg}; +use owo_colors::OwoColorize; + +/// Print documentation links with titles. +pub fn print(lang: Lang, links: &[DocLink], colored: bool) { + if links.is_empty() { + return; + } + + let label = UiMsg::LabelLink.get(lang); + + if colored { + println!("{}", label.blue().bold()); + } else { + println!("{label}"); + } + + for link in links { + if colored { + println!(" {} {}", link.title.cyan(), link.url.underline().dimmed()); + } else { + println!(" {} {}", link.title, link.url); + } + } +} diff --git a/masterror-cli/src/sections/mod.rs b/masterror-cli/src/sections/mod.rs new file mode 100644 index 0000000..82c32d3 --- /dev/null +++ b/masterror-cli/src/sections/mod.rs @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Output sections for masterror block. + +pub mod fix; +pub mod link; +pub mod translation; diff --git a/masterror-cli/src/sections/translation.rs b/masterror-cli/src/sections/translation.rs new file mode 100644 index 0000000..c9a4758 --- /dev/null +++ b/masterror-cli/src/sections/translation.rs @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Translation section - shows full translated compiler error. + +use masterror_knowledge::{Lang, UiMsg, phrases::translate_rendered}; +use owo_colors::OwoColorize; + +/// Print full translated copy of compiler error. +pub fn print(lang: Lang, rendered: Option<&str>, colored: bool) { + if matches!(lang, Lang::En) { + return; + } + + let Some(rendered) = rendered else { + return; + }; + + let translated = translate_rendered(rendered, lang); + + let label = UiMsg::LabelTranslation.get(lang); + + if colored { + println!("{}", label.cyan().bold()); + } else { + println!("{label}"); + } + + for line in translated.lines() { + println!(" {line}"); + } +} diff --git a/masterror-knowledge/Cargo.toml b/masterror-knowledge/Cargo.toml new file mode 100644 index 0000000..fce37d1 --- /dev/null +++ b/masterror-knowledge/Cargo.toml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2025-2026 RAprogramm +# +# SPDX-License-Identifier: MIT + +[package] +name = "masterror-knowledge" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Knowledge base for Rust compiler errors and best practices" +keywords = ["rust", "compiler", "errors", "explain", "learning"] +categories = ["development-tools", "command-line-utilities"] + +[features] +default = ["lang-ru", "lang-ko"] + +# Languages +lang-ru = [] +lang-ko = [] + +[dependencies] +arrayvec = "0.7" +aho-corasick = "1" diff --git a/masterror-knowledge/README.md b/masterror-knowledge/README.md new file mode 100644 index 0000000..bf83138 --- /dev/null +++ b/masterror-knowledge/README.md @@ -0,0 +1,88 @@ + + +
+ masterror-knowledge + +

masterror-knowledge

+

Knowledge base for Rust compiler errors and best practices

+ + [![Crates.io](https://img.shields.io/crates/v/masterror-knowledge)](https://crates.io/crates/masterror-knowledge) + [![docs.rs](https://img.shields.io/docsrs/masterror-knowledge)](https://docs.rs/masterror-knowledge) + ![License](https://img.shields.io/badge/License-MIT-informational) +
+ +--- + +## Overview + +`masterror-knowledge` provides a comprehensive knowledge base of Rust compiler error explanations and best practices. It powers the [masterror-cli](https://github.com/RAprogramm/masterror-cli) tool, enabling developers to quickly understand and fix compiler errors. + +## Features + +- **31+ Error Explanations** — Detailed explanations for common Rust compiler errors (E0001-E0792) +- **15 RustManifest Best Practices** — Guidelines for writing idiomatic Rust code +- **Multi-language Support** — Available in English, Russian, and Korean +- **Zero Dependencies** — Lightweight, no runtime overhead +- **Compile-time Lookup** — Fast pattern matching using Aho-Corasick algorithm + +## Supported Languages + +| Feature | Language | +|---------|----------| +| (default) | English | +| `lang-ru` | Russian | +| `lang-ko` | Korean | + +## Installation + +```toml +[dependencies] +masterror-knowledge = "0.1" +``` + +With additional languages: + +```toml +[dependencies] +masterror-knowledge = { version = "0.1", features = ["lang-ru", "lang-ko"] } +``` + +## Usage + +```rust +use masterror_knowledge::{Lang, lookup_error, lookup_practice}; + +// Get explanation for error E0382 (borrow of moved value) +if let Some(explanation) = lookup_error("E0382", Lang::En) { + println!("{}", explanation); +} + +// Get best practice by ID +if let Some(practice) = lookup_practice("RM001", Lang::En) { + println!("{}", practice); +} +``` + +## Error Codes Covered + +The knowledge base includes explanations for errors related to: + +- **Ownership & Borrowing** — E0382, E0499, E0502, E0505, E0507 +- **Lifetimes** — E0106, E0621, E0759, E0792 +- **Type System** — E0277, E0308, E0412, E0425 +- **Traits** — E0046, E0119, E0277 +- **Patterns** — E0004, E0005, E0026, E0027 +- **And more...** + +## Related + +- [masterror](https://github.com/RAprogramm/masterror) — Framework-agnostic application error types +- [masterror-cli](https://github.com/RAprogramm/masterror-cli) — CLI tool using this knowledge base + +## License + +MIT diff --git a/masterror-knowledge/src/errors/borrowing.rs b/masterror-knowledge/src/errors/borrowing.rs new file mode 100644 index 0000000..d857c8b --- /dev/null +++ b/masterror-knowledge/src/errors/borrowing.rs @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Borrowing-related errors. + +mod e0499; +mod e0500; +mod e0501; +mod e0502; +mod e0503; +mod e0506; +mod e0508; +mod e0596; + +use super::ErrorEntry; + +static ENTRIES: &[&ErrorEntry] = &[ + &e0499::ENTRY, + &e0500::ENTRY, + &e0501::ENTRY, + &e0502::ENTRY, + &e0503::ENTRY, + &e0506::ENTRY, + &e0508::ENTRY, + &e0596::ENTRY +]; + +pub fn entries() -> &'static [&'static ErrorEntry] { + ENTRIES +} diff --git a/masterror-knowledge/src/errors/borrowing/e0499.rs b/masterror-knowledge/src/errors/borrowing/e0499.rs new file mode 100644 index 0000000..b43ca0b --- /dev/null +++ b/masterror-knowledge/src/errors/borrowing/e0499.rs @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0499: cannot borrow as mutable more than once + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0499", + title: LocalizedText::new( + "Cannot borrow as mutable more than once", + "Нельзя заимствовать как изменяемое более одного раза", + "가변으로 두 번 이상 빌릴 수 없음" + ), + category: Category::Borrowing, + explanation: LocalizedText::new( + "\ +Rust allows only ONE mutable reference to data at a time. This is stricter +than the immutable borrowing rule and prevents all aliased mutation. + +Why? Two mutable references to the same data could lead to: +- Data races in concurrent code +- Iterator invalidation +- Dangling pointers after reallocation + +This rule is checked at compile time, giving you fearless concurrency.", + "\ +Rust разрешает только ОДНУ изменяемую ссылку на данные одновременно. +Это строже правила неизменяемого заимствования. + +Почему? Две изменяемые ссылки на одни данные могут привести к: +- Гонкам данных в конкурентном коде +- Инвалидации итераторов +- Висячим указателям после реаллокации", + "\ +Rust는 데이터에 대해 한 번에 하나의 가변 참조만 허용합니다. +이는 불변 빌림 규칙보다 엄격합니다." + ), + fixes: &[ + FixSuggestion { + description: LocalizedText::new( + "Use scopes to limit borrow lifetime", + "Использовать области видимости", + "스코프를 사용하여 빌림 수명 제한" + ), + code: "{ let r1 = &mut x; *r1 += 1; } // r1 dropped\nlet r2 = &mut x;" + }, + FixSuggestion { + description: LocalizedText::new( + "Use RefCell for interior mutability", + "Использовать RefCell", + "내부 가변성을 위해 RefCell 사용" + ), + code: "use std::cell::RefCell;\nlet x = RefCell::new(value);" + } + ], + links: &[ + DocLink { + title: "Rust Book: Mutable References", + url: "https://doc.rust-lang.org/book/ch04-02-references-and-borrowing.html#mutable-references" + }, + DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0499.html" + } + ] +}; diff --git a/masterror-knowledge/src/errors/borrowing/e0500.rs b/masterror-knowledge/src/errors/borrowing/e0500.rs new file mode 100644 index 0000000..8721f48 --- /dev/null +++ b/masterror-knowledge/src/errors/borrowing/e0500.rs @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0500: closure requires unique access but X is already borrowed + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0500", + title: LocalizedText::new( + "Closure requires unique access but value is already borrowed", + "Замыкание требует уникальный доступ, но значение уже заимствовано", + "클로저가 고유 접근을 필요로 하지만 값이 이미 빌려짐" + ), + category: Category::Borrowing, + explanation: LocalizedText::new( + "\ +A closure that mutates a captured variable needs exclusive access to it. +But you've already borrowed the value elsewhere, creating a conflict. + +Closures that capture by mutable reference act like mutable borrows.", + "\ +Замыкание, изменяющее захваченную переменную, требует эксклюзивного доступа.", + "\ +캡처된 변수를 변경하는 클로저는 독점적인 접근이 필요합니다." + ), + fixes: &[ + FixSuggestion { + description: LocalizedText::new( + "End the borrow before the closure", + "Завершить заимствование перед замыканием", + "클로저 전에 빌림 종료" + ), + code: "{ let r = &x; use(r); }\nlet c = || x += 1;" + }, + FixSuggestion { + description: LocalizedText::new( + "Move the value into the closure", + "Переместить значение в замыкание", + "클로저로 값 이동" + ), + code: "let c = move || { x += 1; };" + } + ], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0500.html" + }] +}; diff --git a/masterror-knowledge/src/errors/borrowing/e0501.rs b/masterror-knowledge/src/errors/borrowing/e0501.rs new file mode 100644 index 0000000..34298ab --- /dev/null +++ b/masterror-knowledge/src/errors/borrowing/e0501.rs @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0501: cannot borrow X as mutable because previous closure requires unique +//! access + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0501", + title: LocalizedText::new( + "Cannot borrow because closure requires unique access", + "Нельзя заимствовать, так как замыкание требует уникальный доступ", + "클로저가 고유 접근을 필요로 하여 빌릴 수 없음" + ), + category: Category::Borrowing, + explanation: LocalizedText::new( + "\ +A closure has captured a variable mutably, and now you're trying to borrow +that same variable again. The closure's capture acts like a mutable borrow +that lasts for the closure's entire lifetime.", + "\ +Замыкание захватило переменную изменяемо, и теперь вы пытаетесь заимствовать +ту же переменную снова.", + "\ +클로저가 변수를 가변으로 캡처했고, 이제 같은 변수를 다시 빌리려고 합니다." + ), + fixes: &[FixSuggestion { + description: LocalizedText::new( + "Use the closure before borrowing again", + "Использовать замыкание перед повторным заимствованием", + "다시 빌리기 전에 클로저 사용" + ), + code: "let mut c = || x += 1;\nc(); // use closure\nlet r = &x; // now safe" + }], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0501.html" + }] +}; diff --git a/masterror-knowledge/src/errors/borrowing/e0502.rs b/masterror-knowledge/src/errors/borrowing/e0502.rs new file mode 100644 index 0000000..fa372c0 --- /dev/null +++ b/masterror-knowledge/src/errors/borrowing/e0502.rs @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0502: cannot borrow as mutable because also borrowed as immutable + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0502", + title: LocalizedText::new( + "Cannot borrow as mutable (already borrowed as immutable)", + "Нельзя заимствовать как изменяемое (уже заимствовано как неизменяемое)", + "가변으로 빌릴 수 없음 (이미 불변으로 빌림)" + ), + category: Category::Borrowing, + explanation: LocalizedText::new( + "\ +Rust enforces a strict borrowing rule: you can have EITHER one mutable +reference OR any number of immutable references, but never both at once. + +This prevents data races at compile time. If you could mutate data while +someone else is reading it, the reader might see inconsistent state. + +The immutable borrow is still \"active\" because it's used later in code.", + "\ +Rust применяет строгое правило: можно иметь ЛИБО одну изменяемую ссылку, +ЛИБО любое количество неизменяемых, но никогда обе одновременно. + +Это предотвращает гонки данных.", + "\ +Rust는 엄격한 빌림 규칙을 적용합니다: 하나의 가변 참조 또는 여러 불변 참조를 +가질 수 있지만, 동시에 둘 다 가질 수는 없습니다." + ), + fixes: &[ + FixSuggestion { + description: LocalizedText::new( + "End the immutable borrow before mutating", + "Завершить неизменяемое заимствование", + "변경 전에 불변 빌림 종료" + ), + code: "{ let r = &x; println!(\"{}\", r); } // r dropped\nx.push(1);" + }, + FixSuggestion { + description: LocalizedText::new( + "Clone before mutation", + "Клонировать перед изменением", + "변경 전에 복제" + ), + code: "let copy = x[0].clone();\nx.push(copy);" + } + ], + links: &[ + DocLink { + title: "Rust Book: References and Borrowing", + url: "https://doc.rust-lang.org/book/ch04-02-references-and-borrowing.html" + }, + DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0502.html" + } + ] +}; diff --git a/masterror-knowledge/src/errors/borrowing/e0503.rs b/masterror-knowledge/src/errors/borrowing/e0503.rs new file mode 100644 index 0000000..083a5c3 --- /dev/null +++ b/masterror-knowledge/src/errors/borrowing/e0503.rs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0503: cannot use X because it was mutably borrowed + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0503", + title: LocalizedText::new( + "Cannot use value because it was mutably borrowed", + "Нельзя использовать значение, так как оно изменяемо заимствовано", + "가변으로 빌려져서 값을 사용할 수 없음" + ), + category: Category::Borrowing, + explanation: LocalizedText::new( + "\ +While a mutable borrow is active, you cannot access the original value +in any way. This prevents you from observing partially modified state +or creating aliased mutable references. + +The mutable borrow has exclusive access until it ends.", + "\ +Пока активно изменяемое заимствование, вы не можете обращаться к +исходному значению никак.", + "\ +가변 빌림이 활성화된 동안 원래 값에 어떤 방식으로도 접근할 수 없습니다." + ), + fixes: &[FixSuggestion { + description: LocalizedText::new( + "End the mutable borrow first", + "Сначала завершить изменяемое заимствование", + "먼저 가변 빌림 종료" + ), + code: "{ let r = &mut x; modify(r); } // r dropped\nuse_value(&x);" + }], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0503.html" + }] +}; diff --git a/masterror-knowledge/src/errors/borrowing/e0506.rs b/masterror-knowledge/src/errors/borrowing/e0506.rs new file mode 100644 index 0000000..8c3a482 --- /dev/null +++ b/masterror-knowledge/src/errors/borrowing/e0506.rs @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0506: cannot assign to X because it is borrowed + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0506", + title: LocalizedText::new( + "Cannot assign because it is borrowed", + "Нельзя присвоить, так как значение заимствовано", + "빌려져 있어서 할당할 수 없음" + ), + category: Category::Borrowing, + explanation: LocalizedText::new( + "\ +You're trying to assign to a value while a borrow of it exists. +This would invalidate the existing reference. + +You must wait for all borrows to end before assigning a new value.", + "\ +Вы пытаетесь присвоить значение, пока существует его заимствование.", + "\ +빌림이 존재하는 동안 값에 할당하려고 합니다." + ), + fixes: &[FixSuggestion { + description: LocalizedText::new( + "End the borrow before assigning", + "Завершить заимствование перед присваиванием", + "할당 전에 빌림 종료" + ), + code: "{ let r = &x; use(r); } // borrow ends\nx = new_value;" + }], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0506.html" + }] +}; diff --git a/masterror-knowledge/src/errors/borrowing/e0508.rs b/masterror-knowledge/src/errors/borrowing/e0508.rs new file mode 100644 index 0000000..4b0449c --- /dev/null +++ b/masterror-knowledge/src/errors/borrowing/e0508.rs @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0508: cannot move out of type `[T]`, a non-copy slice + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0508", + title: LocalizedText::new( + "Cannot move out of type, a non-copy slice", + "Нельзя переместить из типа — это не-Copy срез", + "타입에서 이동할 수 없음, 비복사 슬라이스" + ), + category: Category::Borrowing, + explanation: LocalizedText::new( + "\ +You're trying to move a value out of a slice, but slices don't own their data. +They're just views into an array or Vec. + +Moving out would leave a \"hole\" in the slice, which isn't allowed.", + "\ +Вы пытаетесь переместить значение из среза, но срезы не владеют данными.", + "\ +슬라이스에서 값을 이동하려고 하지만, 슬라이스는 데이터를 소유하지 않습니다." + ), + fixes: &[ + FixSuggestion { + description: LocalizedText::new( + "Clone the element", + "Клонировать элемент", + "요소 복제" + ), + code: "let elem = slice[i].clone();" + }, + FixSuggestion { + description: LocalizedText::new( + "Use into_iter() on Vec", + "Использовать into_iter() на Vec", + "Vec에 into_iter() 사용" + ), + code: "for elem in vec.into_iter() { ... }" + } + ], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0508.html" + }] +}; diff --git a/masterror-knowledge/src/errors/borrowing/e0596.rs b/masterror-knowledge/src/errors/borrowing/e0596.rs new file mode 100644 index 0000000..3f17af3 --- /dev/null +++ b/masterror-knowledge/src/errors/borrowing/e0596.rs @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0596: cannot borrow as mutable, as it is not declared as mutable + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0596", + title: LocalizedText::new( + "Cannot borrow as mutable (not declared as mutable)", + "Нельзя заимствовать как изменяемое (не объявлено как изменяемое)", + "가변으로 빌릴 수 없음 (가변으로 선언되지 않음)" + ), + category: Category::Borrowing, + explanation: LocalizedText::new( + "\ +You're trying to get a mutable reference to something that wasn't declared +as mutable. To modify through a reference, the original binding must be `mut`. + +This is Rust's way of making mutation explicit and visible in the code.", + "\ +Вы пытаетесь получить изменяемую ссылку на то, что не было объявлено +как изменяемое. Для изменения через ссылку оригинал должен быть `mut`.", + "\ +가변으로 선언되지 않은 것에 대한 가변 참조를 얻으려고 합니다." + ), + fixes: &[ + FixSuggestion { + description: LocalizedText::new( + "Add mut to the variable declaration", + "Добавить mut к объявлению переменной", + "변수 선언에 mut 추가" + ), + code: "let mut x = vec![1, 2, 3];" + }, + FixSuggestion { + description: LocalizedText::new( + "Add mut to function parameter", + "Добавить mut к параметру функции", + "함수 매개변수에 mut 추가" + ), + code: "fn process(data: &mut Vec) { ... }" + } + ], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0596.html" + }] +}; diff --git a/masterror-knowledge/src/errors/lifetimes.rs b/masterror-knowledge/src/errors/lifetimes.rs new file mode 100644 index 0000000..8132ece --- /dev/null +++ b/masterror-knowledge/src/errors/lifetimes.rs @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Lifetime-related errors. + +mod e0106; +mod e0495; +mod e0515; +mod e0597; +mod e0621; +mod e0623; +mod e0700; +mod e0716; + +use super::ErrorEntry; + +static ENTRIES: &[&ErrorEntry] = &[ + &e0106::ENTRY, + &e0495::ENTRY, + &e0515::ENTRY, + &e0597::ENTRY, + &e0621::ENTRY, + &e0623::ENTRY, + &e0700::ENTRY, + &e0716::ENTRY +]; + +pub fn entries() -> &'static [&'static ErrorEntry] { + ENTRIES +} diff --git a/masterror-knowledge/src/errors/lifetimes/e0106.rs b/masterror-knowledge/src/errors/lifetimes/e0106.rs new file mode 100644 index 0000000..3d10310 --- /dev/null +++ b/masterror-knowledge/src/errors/lifetimes/e0106.rs @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0106: missing lifetime specifier + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0106", + title: LocalizedText::new( + "Missing lifetime specifier", + "Отсутствует спецификатор времени жизни", + "라이프타임 지정자 누락" + ), + category: Category::Lifetimes, + explanation: LocalizedText::new( + "\ +References in Rust have lifetimes - they describe how long the reference +is valid. Usually the compiler infers lifetimes, but sometimes you must +be explicit. + +Lifetime annotations don't change how long values live. They describe +relationships between references so the compiler can verify safety.", + "\ +Ссылки в Rust имеют времена жизни — они описывают, как долго ссылка +действительна. Обычно компилятор выводит времена жизни, но иногда нужно +указать явно.", + "\ +Rust의 참조에는 라이프타임이 있습니다 - 참조가 얼마나 오래 유효한지 설명합니다." + ), + fixes: &[ + FixSuggestion { + description: LocalizedText::new( + "Add explicit lifetime parameter", + "Добавить явный параметр времени жизни", + "명시적 라이프타임 매개변수 추가" + ), + code: "struct Foo<'a> { x: &'a str }" + }, + FixSuggestion { + description: LocalizedText::new( + "Use owned type instead", + "Использовать владеющий тип", + "소유 타입 사용" + ), + code: "struct Foo { x: String }" + }, + FixSuggestion { + description: LocalizedText::new( + "Use 'static for compile-time constants", + "Использовать 'static для констант", + "컴파일 시간 상수에 'static 사용" + ), + code: "fn get_str() -> &'static str { \"hello\" }" + } + ], + links: &[ + DocLink { + title: "Rust Book: Lifetimes", + url: "https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html" + }, + DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0106.html" + } + ] +}; diff --git a/masterror-knowledge/src/errors/lifetimes/e0495.rs b/masterror-knowledge/src/errors/lifetimes/e0495.rs new file mode 100644 index 0000000..a5ce0a0 --- /dev/null +++ b/masterror-knowledge/src/errors/lifetimes/e0495.rs @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0495: cannot infer an appropriate lifetime + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0495", + title: LocalizedText::new( + "Cannot infer an appropriate lifetime", + "Невозможно вывести подходящее время жизни", + "적절한 라이프타임을 추론할 수 없음" + ), + category: Category::Lifetimes, + explanation: LocalizedText::new( + "\ +The compiler found conflicting lifetime requirements and couldn't +determine which one to use.", + "\ +Компилятор обнаружил конфликтующие требования времён жизни.", + "\ +컴파일러가 충돌하는 라이프타임 요구사항을 찾았습니다." + ), + fixes: &[FixSuggestion { + description: LocalizedText::new( + "Add explicit lifetime bounds", + "Добавить явные ограничения времени жизни", + "명시적 라이프타임 바운드 추가" + ), + code: "fn process<'a, 'b: 'a>(x: &'a str, y: &'b str) -> &'a str" + }], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0495.html" + }] +}; diff --git a/masterror-knowledge/src/errors/lifetimes/e0515.rs b/masterror-knowledge/src/errors/lifetimes/e0515.rs new file mode 100644 index 0000000..f8ca6a2 --- /dev/null +++ b/masterror-knowledge/src/errors/lifetimes/e0515.rs @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0515: cannot return reference to temporary value + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0515", + title: LocalizedText::new( + "Cannot return reference to temporary value", + "Нельзя вернуть ссылку на временное значение", + "임시 값에 대한 참조를 반환할 수 없음" + ), + category: Category::Lifetimes, + explanation: LocalizedText::new( + "\ +You're trying to return a reference to a value that was created inside +the function. When the function returns, that value is dropped. + +The reference would point to freed memory - a dangling pointer.", + "\ +Вы пытаетесь вернуть ссылку на значение, созданное внутри функции. +При возврате из функции это значение будет уничтожено.", + "\ +함수 내에서 생성된 값에 대한 참조를 반환하려고 합니다." + ), + fixes: &[ + FixSuggestion { + description: LocalizedText::new( + "Return owned value instead of reference", + "Вернуть владеющее значение вместо ссылки", + "참조 대신 소유 값 반환" + ), + code: "fn create() -> String { String::from(\"hello\") }" + }, + FixSuggestion { + description: LocalizedText::new( + "Use a parameter lifetime", + "Использовать время жизни параметра", + "매개변수 라이프타임 사용" + ), + code: "fn longest<'a>(x: &'a str, y: &'a str) -> &'a str" + } + ], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0515.html" + }] +}; diff --git a/masterror-knowledge/src/errors/lifetimes/e0597.rs b/masterror-knowledge/src/errors/lifetimes/e0597.rs new file mode 100644 index 0000000..bf3f11f --- /dev/null +++ b/masterror-knowledge/src/errors/lifetimes/e0597.rs @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0597: value does not live long enough + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0597", + title: LocalizedText::new( + "Value does not live long enough", + "Значение живёт недостаточно долго", + "값이 충분히 오래 살지 않음" + ), + category: Category::Lifetimes, + explanation: LocalizedText::new( + "\ +You're creating a reference to something that will be destroyed before +the reference is used. This would create a dangling pointer. + +Rust prevents this at compile time. The referenced value must live at +least as long as the reference itself.", + "\ +Вы создаёте ссылку на что-то, что будет уничтожено до использования ссылки.", + "\ +참조가 사용되기 전에 파괴될 것에 대한 참조를 만들고 있습니다." + ), + fixes: &[ + FixSuggestion { + description: LocalizedText::new( + "Move value to outer scope", + "Переместить значение во внешнюю область", + "값을 외부 스코프로 이동" + ), + code: "let s = String::from(\"hello\"); // declare before use" + }, + FixSuggestion { + description: LocalizedText::new( + "Return owned value instead", + "Вернуть владеющее значение", + "소유 값 반환" + ), + code: "fn get() -> String { s.to_string() }" + } + ], + links: &[ + DocLink { + title: "Rust Book: Lifetimes", + url: "https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html" + }, + DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0597.html" + } + ] +}; diff --git a/masterror-knowledge/src/errors/lifetimes/e0621.rs b/masterror-knowledge/src/errors/lifetimes/e0621.rs new file mode 100644 index 0000000..b7247fc --- /dev/null +++ b/masterror-knowledge/src/errors/lifetimes/e0621.rs @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0621: explicit lifetime required in the type of X + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0621", + title: LocalizedText::new( + "Explicit lifetime required in the type", + "Требуется явное время жизни в типе", + "타입에 명시적 라이프타임이 필요함" + ), + category: Category::Lifetimes, + explanation: LocalizedText::new( + "\ +The compiler cannot infer lifetimes in this context. You need to add +explicit lifetime annotations to show how references relate.", + "\ +Компилятор не может вывести времена жизни в этом контексте.", + "\ +컴파일러가 이 컨텍스트에서 라이프타임을 추론할 수 없습니다." + ), + fixes: &[FixSuggestion { + description: LocalizedText::new( + "Add lifetime parameter to function", + "Добавить параметр времени жизни к функции", + "함수에 라이프타임 매개변수 추가" + ), + code: "fn process<'a>(data: &'a str) -> &'a str { data }" + }], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0621.html" + }] +}; diff --git a/masterror-knowledge/src/errors/lifetimes/e0623.rs b/masterror-knowledge/src/errors/lifetimes/e0623.rs new file mode 100644 index 0000000..152bb3b --- /dev/null +++ b/masterror-knowledge/src/errors/lifetimes/e0623.rs @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0623: lifetime mismatch + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0623", + title: LocalizedText::new( + "Lifetime mismatch", + "Несоответствие времён жизни", + "라이프타임 불일치" + ), + category: Category::Lifetimes, + explanation: LocalizedText::new( + "\ +Two lifetimes in your code don't match where they should.", + "\ +Два времени жизни в коде не совпадают там, где должны.", + "\ +코드에서 두 라이프타임이 일치해야 하는 곳에서 일치하지 않습니다." + ), + fixes: &[FixSuggestion { + description: LocalizedText::new( + "Ensure consistent lifetime annotations", + "Обеспечить согласованные аннотации", + "일관된 라이프타임 어노테이션 확보" + ), + code: "fn foo<'a>(x: &'a str) -> &'a str { x }" + }], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0623.html" + }] +}; diff --git a/masterror-knowledge/src/errors/lifetimes/e0700.rs b/masterror-knowledge/src/errors/lifetimes/e0700.rs new file mode 100644 index 0000000..f0ec8d0 --- /dev/null +++ b/masterror-knowledge/src/errors/lifetimes/e0700.rs @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0700: hidden type captures lifetime + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0700", + title: LocalizedText::new( + "Hidden type captures lifetime", + "Скрытый тип захватывает время жизни", + "숨겨진 타입이 라이프타임을 캡처함" + ), + category: Category::Lifetimes, + explanation: LocalizedText::new( + "\ +When using `impl Trait` return type, the hidden concrete type captures +a lifetime that isn't declared in the function signature.", + "\ +При использовании типа возврата `impl Trait` скрытый конкретный тип +захватывает время жизни, не объявленное в сигнатуре.", + "\ +`impl Trait` 반환 타입을 사용할 때, 숨겨진 구체적 타입이 선언되지 않은 라이프타임을 캡처합니다." + ), + fixes: &[FixSuggestion { + description: LocalizedText::new( + "Declare the captured lifetime", + "Объявить захваченное время жизни", + "캡처된 라이프타임 선언" + ), + code: "fn foo<'a>(x: &'a str) -> impl Iterator + 'a" + }], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0700.html" + }] +}; diff --git a/masterror-knowledge/src/errors/lifetimes/e0716.rs b/masterror-knowledge/src/errors/lifetimes/e0716.rs new file mode 100644 index 0000000..f12a054 --- /dev/null +++ b/masterror-knowledge/src/errors/lifetimes/e0716.rs @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0716: temporary value dropped while borrowed + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0716", + title: LocalizedText::new( + "Temporary value dropped while borrowed", + "Временное значение уничтожено во время заимствования", + "빌린 동안 임시 값이 삭제됨" + ), + category: Category::Lifetimes, + explanation: LocalizedText::new( + "\ +A temporary value was created, borrowed, and then immediately dropped. +The borrow outlives the temporary. + +Temporaries only live until the end of the statement by default.", + "\ +Было создано временное значение, заимствовано и сразу уничтожено.", + "\ +임시 값이 생성되고, 빌려지고, 즉시 삭제되었습니다." + ), + fixes: &[FixSuggestion { + description: LocalizedText::new( + "Bind temporary to a variable", + "Привязать временное значение к переменной", + "임시 값을 변수에 바인딩" + ), + code: "let value = create_value();\nlet reference = &value;" + }], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0716.html" + }] +}; diff --git a/masterror-knowledge/src/errors/mod.rs b/masterror-knowledge/src/errors/mod.rs new file mode 100644 index 0000000..df40cc0 --- /dev/null +++ b/masterror-knowledge/src/errors/mod.rs @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Rust compiler error explanations organized by category. + +pub mod borrowing; +pub mod lifetimes; +pub mod ownership; +pub mod raprogramm; +pub mod resolution; +pub mod traits; +pub mod types; + +use std::{collections::HashMap, sync::LazyLock}; + +use arrayvec::ArrayString; + +/// Global error registry singleton. +static ERROR_REGISTRY: LazyLock = LazyLock::new(ErrorRegistry::build); + +/// Link with title for documentation. +#[derive(Debug, Clone, Copy)] +pub struct DocLink { + /// Link display title. + pub title: &'static str, + /// URL to documentation. + pub url: &'static str +} + +/// Fix suggestion with code example. +#[derive(Debug, Clone, Copy)] +pub struct FixSuggestion { + /// Description of the fix approach. + pub description: LocalizedText, + /// Code example showing the fix. + pub code: &'static str +} + +/// Localized text with translations. +/// +/// All fields are `&'static str` for zero-copy access. +#[derive(Debug, Clone, Copy)] +pub struct LocalizedText { + /// English text (always present). + pub en: &'static str, + /// Russian translation. + pub ru: &'static str, + /// Korean translation. + pub ko: &'static str +} + +impl LocalizedText { + pub const fn new(en: &'static str, ru: &'static str, ko: &'static str) -> Self { + Self { + en, + ru, + ko + } + } + + pub fn get(&self, lang: &str) -> &'static str { + match lang { + "ru" => self.ru, + "ko" => self.ko, + _ => self.en + } + } +} + +/// Error category. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Category { + Ownership, + Borrowing, + Lifetimes, + Types, + Traits, + Resolution +} + +impl Category { + pub fn name(&self, lang: &str) -> &'static str { + match (self, lang) { + (Self::Ownership, "ru") => "Владение", + (Self::Ownership, "ko") => "소유권", + (Self::Ownership, _) => "Ownership", + + (Self::Borrowing, "ru") => "Заимствование", + (Self::Borrowing, "ko") => "빌림", + (Self::Borrowing, _) => "Borrowing", + + (Self::Lifetimes, "ru") => "Времена жизни", + (Self::Lifetimes, "ko") => "라이프타임", + (Self::Lifetimes, _) => "Lifetimes", + + (Self::Types, "ru") => "Типы", + (Self::Types, "ko") => "타입", + (Self::Types, _) => "Types", + + (Self::Traits, "ru") => "Трейты", + (Self::Traits, "ko") => "트레이트", + (Self::Traits, _) => "Traits", + + (Self::Resolution, "ru") => "Разрешение имён", + (Self::Resolution, "ko") => "이름 확인", + (Self::Resolution, _) => "Name Resolution" + } + } +} + +/// Complete error entry. +/// +/// Fields ordered by size (largest first) to minimize padding. +#[derive(Debug, Clone)] +pub struct ErrorEntry { + /// Error explanation text. + pub explanation: LocalizedText, + /// Short error title. + pub title: LocalizedText, + /// Suggested fixes. + pub fixes: &'static [FixSuggestion], + /// Documentation links. + pub links: &'static [DocLink], + /// Error code (E0382). + pub code: &'static str, + /// Error category. + pub category: Category +} + +/// Registry of all known errors. +pub struct ErrorRegistry { + errors: HashMap<&'static str, &'static ErrorEntry> +} + +impl ErrorRegistry { + /// Get the global registry instance. + pub fn new() -> &'static Self { + &ERROR_REGISTRY + } + + /// Build registry from all modules. + fn build() -> Self { + let mut errors = HashMap::with_capacity(34); + + for entry in ownership::entries() { + errors.insert(entry.code, *entry); + } + for entry in borrowing::entries() { + errors.insert(entry.code, *entry); + } + for entry in lifetimes::entries() { + errors.insert(entry.code, *entry); + } + for entry in types::entries() { + errors.insert(entry.code, *entry); + } + for entry in traits::entries() { + errors.insert(entry.code, *entry); + } + for entry in resolution::entries() { + errors.insert(entry.code, *entry); + } + + Self { + errors + } + } + + /// Find error by code. + /// + /// Accepts formats: "E0382", "e0382", "0382". + /// Uses stack-allocated buffer to avoid heap allocation. + pub fn find(&self, code: &str) -> Option<&'static ErrorEntry> { + // Fast path: try exact match first (covers "E0382" case) + if let Some(entry) = self.errors.get(code) { + return Some(*entry); + } + + // Normalize to uppercase with 'E' prefix using stack buffer + let mut buf: ArrayString<8> = ArrayString::new(); + + if !code.starts_with('E') && !code.starts_with('e') { + buf.push('E'); + } + + for c in code.chars().take(7) { + buf.push(c.to_ascii_uppercase()); + } + + self.errors.get(buf.as_str()).copied() + } + + /// Get all errors. + pub fn all(&self) -> impl Iterator + '_ { + self.errors.values().copied() + } + + /// Get errors by category. + pub fn by_category(&self, cat: Category) -> Vec<&'static ErrorEntry> { + self.errors + .values() + .filter(|e| e.category == cat) + .copied() + .collect() + } +} + +impl Default for &'static ErrorRegistry { + fn default() -> Self { + ErrorRegistry::new() + } +} diff --git a/masterror-knowledge/src/errors/ownership.rs b/masterror-knowledge/src/errors/ownership.rs new file mode 100644 index 0000000..516a81b --- /dev/null +++ b/masterror-knowledge/src/errors/ownership.rs @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Ownership-related errors. + +mod e0381; +mod e0382; +mod e0383; +mod e0384; +mod e0505; +mod e0507; +mod e0509; + +use super::ErrorEntry; + +static ENTRIES: &[&ErrorEntry] = &[ + &e0381::ENTRY, + &e0382::ENTRY, + &e0383::ENTRY, + &e0384::ENTRY, + &e0505::ENTRY, + &e0507::ENTRY, + &e0509::ENTRY +]; + +pub fn entries() -> &'static [&'static ErrorEntry] { + ENTRIES +} diff --git a/masterror-knowledge/src/errors/ownership/e0381.rs b/masterror-knowledge/src/errors/ownership/e0381.rs new file mode 100644 index 0000000..8518c29 --- /dev/null +++ b/masterror-knowledge/src/errors/ownership/e0381.rs @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0381: borrow of possibly-uninitialized variable + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0381", + title: LocalizedText::new( + "Borrow of possibly-uninitialized variable", + "Заимствование возможно неинициализированной переменной", + "초기화되지 않았을 수 있는 변수의 빌림" + ), + category: Category::Ownership, + explanation: LocalizedText::new( + "\ +Rust requires all variables to be initialized before use. You're trying +to use a variable that might not have been assigned a value yet. + +This prevents reading garbage memory. The compiler tracks initialization +through all possible code paths.", + "\ +Rust требует инициализации всех переменных перед использованием. +Вы пытаетесь использовать переменную, которая может быть не инициализирована. + +Это предотвращает чтение мусора из памяти.", + "\ +Rust는 사용 전에 모든 변수를 초기화해야 합니다. +아직 값이 할당되지 않았을 수 있는 변수를 사용하려고 합니다." + ), + fixes: &[ + FixSuggestion { + description: LocalizedText::new( + "Initialize the variable", + "Инициализировать переменную", + "변수 초기화" + ), + code: "let x = 0; // or any default value" + }, + FixSuggestion { + description: LocalizedText::new( + "Use Option for maybe-uninitialized", + "Использовать Option для возможно неинициализированных", + "초기화되지 않을 수 있는 경우 Option 사용" + ), + code: "let x: Option = None;\nif condition { x = Some(42); }" + } + ], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0381.html" + }] +}; diff --git a/masterror-knowledge/src/errors/ownership/e0382.rs b/masterror-knowledge/src/errors/ownership/e0382.rs new file mode 100644 index 0000000..f5371d6 --- /dev/null +++ b/masterror-knowledge/src/errors/ownership/e0382.rs @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0382: borrow of moved value + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0382", + title: LocalizedText::new( + "Borrow of moved value", + "Заимствование перемещённого значения", + "이동된 값의 빌림" + ), + category: Category::Ownership, + explanation: LocalizedText::new( + "\ +In Rust, each value has exactly one owner at a time. This is the foundation +of Rust's memory safety guarantees without garbage collection. + +When you assign a value to another variable or pass it to a function, +ownership MOVES to the new location. The original variable becomes invalid +and cannot be used anymore. + +This happens because Rust needs to know exactly when to free memory. +With one owner, there's no ambiguity about who is responsible for cleanup.", + "\ +В Rust каждое значение имеет ровно одного владельца. Это основа +гарантий безопасности памяти без сборщика мусора. + +Когда вы присваиваете значение другой переменной или передаёте в функцию, +владение ПЕРЕМЕЩАЕТСЯ. Исходная переменная становится недействительной. + +Rust должен точно знать, когда освобождать память. +С одним владельцем нет неоднозначности в том, кто отвечает за очистку.", + "\ +Rust에서 각 값은 정확히 하나의 소유자를 가집니다. 이것이 가비지 컬렉터 없이 +메모리 안전성을 보장하는 기반입니다. + +값을 다른 변수에 할당하거나 함수에 전달하면 소유권이 새 위치로 이동합니다. +원래 변수는 무효화되어 더 이상 사용할 수 없습니다." + ), + fixes: &[ + FixSuggestion { + description: LocalizedText::new( + "Clone the value (creates a deep copy)", + "Клонировать значение (глубокая копия)", + "값을 복제 (깊은 복사)" + ), + code: "let s2 = s.clone();" + }, + FixSuggestion { + description: LocalizedText::new( + "Borrow with a reference (no copy)", + "Заимствовать по ссылке (без копии)", + "참조로 빌림 (복사 없음)" + ), + code: "let s2 = &s;" + }, + FixSuggestion { + description: LocalizedText::new( + "Implement Copy trait (for small types)", + "Реализовать Copy (для маленьких типов)", + "Copy 트레이트 구현 (작은 타입용)" + ), + code: "#[derive(Copy, Clone)]" + } + ], + links: &[ + DocLink { + title: "Rust Book: Ownership", + url: "https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html" + }, + DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0382.html" + } + ] +}; diff --git a/masterror-knowledge/src/errors/ownership/e0383.rs b/masterror-knowledge/src/errors/ownership/e0383.rs new file mode 100644 index 0000000..ebf37f9 --- /dev/null +++ b/masterror-knowledge/src/errors/ownership/e0383.rs @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0383: partial reinitialization of uninitialized structure + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0383", + title: LocalizedText::new( + "Partial reinitialization of uninitialized structure", + "Частичная переинициализация неинициализированной структуры", + "초기화되지 않은 구조체의 부분 재초기화" + ), + category: Category::Ownership, + explanation: LocalizedText::new( + "\ +You're trying to partially reinitialize a struct that was moved from. +After a move, the entire struct is invalid - you can't assign to just +one field. + +You must reinitialize the entire struct.", + "\ +Вы пытаетесь частично переинициализировать структуру после перемещения. +После перемещения вся структура недействительна - нельзя присвоить +только одно поле. + +Нужно переинициализировать всю структуру.", + "\ +이동된 구조체를 부분적으로 재초기화하려고 합니다. +이동 후 전체 구조체가 무효화됩니다." + ), + fixes: &[FixSuggestion { + description: LocalizedText::new( + "Reinitialize the entire struct", + "Переинициализировать всю структуру", + "전체 구조체 재초기화" + ), + code: "s = MyStruct { field1: val1, field2: val2 };" + }], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0383.html" + }] +}; diff --git a/masterror-knowledge/src/errors/ownership/e0384.rs b/masterror-knowledge/src/errors/ownership/e0384.rs new file mode 100644 index 0000000..19696a4 --- /dev/null +++ b/masterror-knowledge/src/errors/ownership/e0384.rs @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0384: cannot assign twice to immutable variable + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0384", + title: LocalizedText::new( + "Cannot assign twice to immutable variable", + "Нельзя присвоить дважды неизменяемой переменной", + "불변 변수에 두 번 할당할 수 없음" + ), + category: Category::Ownership, + explanation: LocalizedText::new( + "\ +Variables in Rust are immutable by default. Once a value is bound to a name, +you cannot change it unless you explicitly mark it as mutable with `mut`. + +This is a deliberate design choice that makes code easier to reason about. +When you see a variable without `mut`, you know it won't change.", + "\ +Переменные в Rust неизменяемы по умолчанию. После привязки значения +к имени вы не можете его изменить без явного указания `mut`. + +Это осознанное решение, упрощающее понимание кода. +Если переменная без `mut`, она не изменится.", + "\ +Rust의 변수는 기본적으로 불변입니다. 값이 이름에 바인딩되면 +`mut`로 명시적으로 표시하지 않는 한 변경할 수 없습니다." + ), + fixes: &[ + FixSuggestion { + description: LocalizedText::new( + "Make the variable mutable", + "Сделать переменную изменяемой", + "변수를 가변으로 만들기" + ), + code: "let mut x = 5;\nx = 10;" + }, + FixSuggestion { + description: LocalizedText::new( + "Use shadowing (create new binding)", + "Использовать затенение (новая привязка)", + "섀도잉 사용 (새 바인딩 생성)" + ), + code: "let x = 5;\nlet x = 10; // shadows the first x" + } + ], + links: &[ + DocLink { + title: "Rust Book: Variables and Mutability", + url: "https://doc.rust-lang.org/book/ch03-01-variables-and-mutability.html" + }, + DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0384.html" + } + ] +}; diff --git a/masterror-knowledge/src/errors/ownership/e0505.rs b/masterror-knowledge/src/errors/ownership/e0505.rs new file mode 100644 index 0000000..e76eec9 --- /dev/null +++ b/masterror-knowledge/src/errors/ownership/e0505.rs @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0505: cannot move out of X because it is borrowed + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0505", + title: LocalizedText::new( + "Cannot move out because it is borrowed", + "Нельзя переместить, так как значение заимствовано", + "빌려져 있어서 이동할 수 없음" + ), + category: Category::Ownership, + explanation: LocalizedText::new( + "\ +You're trying to move a value while a borrow of it still exists. +This would invalidate the reference, creating a dangling pointer. + +The borrow must end (go out of scope) before you can move the value. + +Rust tracks the lifetime of all borrows to prevent this at compile time.", + "\ +Вы пытаетесь переместить значение, пока существует его заимствование. +Это сделает ссылку недействительной. + +Заимствование должно закончиться до перемещения значения.", + "\ +빌림이 존재하는 동안 값을 이동하려고 합니다. +이것은 참조를 무효화하여 댕글링 포인터를 만듭니다." + ), + fixes: &[ + FixSuggestion { + description: LocalizedText::new( + "End the borrow before moving", + "Завершить заимствование перед перемещением", + "이동 전에 빌림 종료" + ), + code: "{ let r = &x; use(r); } // borrow ends\nmove_value(x);" + }, + FixSuggestion { + description: LocalizedText::new( + "Clone before borrowing", + "Клонировать перед заимствованием", + "빌리기 전에 복제" + ), + code: "let cloned = x.clone();\nlet r = &cloned;\nmove_value(x);" + } + ], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0505.html" + }] +}; diff --git a/masterror-knowledge/src/errors/ownership/e0507.rs b/masterror-knowledge/src/errors/ownership/e0507.rs new file mode 100644 index 0000000..3df095f --- /dev/null +++ b/masterror-knowledge/src/errors/ownership/e0507.rs @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0507: cannot move out of borrowed content + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0507", + title: LocalizedText::new( + "Cannot move out of borrowed content", + "Нельзя переместить из заимствованного содержимого", + "빌린 내용에서 이동할 수 없음" + ), + category: Category::Ownership, + explanation: LocalizedText::new( + "\ +You're trying to take ownership of a value that you only have a reference to. +References are borrows - they don't own the data. + +Moving out of a reference would leave the original owner with invalid data, +violating Rust's memory safety guarantees. + +Common cases: +- Indexing into a Vec or array with `vec[i]` and trying to own the element +- Dereferencing a reference and trying to move the value +- Pattern matching on borrowed data with ownership patterns", + "\ +Вы пытаетесь забрать владение значением, на которое у вас только ссылка. +Ссылки - это заимствования, они не владеют данными. + +Перемещение из ссылки оставит исходного владельца с недействительными данными. + +Частые случаи: +- Индексация Vec с попыткой забрать элемент +- Разыменование ссылки с попыткой переместить +- Pattern matching на заимствованных данных", + "\ +참조만 있는 값의 소유권을 가져오려고 합니다. +참조는 빌림입니다 - 데이터를 소유하지 않습니다. + +참조에서 이동하면 원래 소유자가 무효한 데이터를 갖게 됩니다." + ), + fixes: &[ + FixSuggestion { + description: LocalizedText::new("Clone the value", "Клонировать значение", "값 복제"), + code: "let owned = borrowed.clone();" + }, + FixSuggestion { + description: LocalizedText::new( + "Use mem::take or mem::replace", + "Использовать mem::take или mem::replace", + "mem::take 또는 mem::replace 사용" + ), + code: "let owned = std::mem::take(&mut vec[i]);" + }, + FixSuggestion { + description: LocalizedText::new( + "Use swap_remove for Vec", + "Использовать swap_remove для Vec", + "Vec에 swap_remove 사용" + ), + code: "let owned = vec.swap_remove(i);" + } + ], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0507.html" + }] +}; diff --git a/masterror-knowledge/src/errors/ownership/e0509.rs b/masterror-knowledge/src/errors/ownership/e0509.rs new file mode 100644 index 0000000..4d39ab1 --- /dev/null +++ b/masterror-knowledge/src/errors/ownership/e0509.rs @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! E0509: cannot move out of type X, which implements the Drop trait + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0509", + title: LocalizedText::new( + "Cannot move out of type which implements Drop", + "Нельзя переместить из типа, реализующего Drop", + "Drop을 구현하는 타입에서 이동할 수 없음" + ), + category: Category::Ownership, + explanation: LocalizedText::new( + "\ +Types that implement Drop have custom cleanup logic that runs when they're +destroyed. Moving a field out would leave the struct in a partially valid +state, and Drop wouldn't know what to clean up. + +Rust prevents this to ensure Drop always sees a valid value.", + "\ +Типы с Drop имеют пользовательскую логику очистки при уничтожении. +Перемещение поля оставит структуру в частично валидном состоянии, +и Drop не будет знать, что очищать. + +Rust предотвращает это для гарантии валидности значения в Drop.", + "\ +Drop을 구현하는 타입은 파괴될 때 실행되는 사용자 정의 정리 로직이 있습니다. +필드를 이동하면 구조체가 부분적으로 유효한 상태가 됩니다." + ), + fixes: &[ + FixSuggestion { + description: LocalizedText::new("Clone the field", "Клонировать поле", "필드 복제"), + code: "let field = self.field.clone();" + }, + FixSuggestion { + description: LocalizedText::new( + "Use Option and take()", + "Использовать Option и take()", + "Option과 take() 사용" + ), + code: "struct S { field: Option }\nlet field = self.field.take();" + } + ], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0509.html" + }] +}; diff --git a/masterror-knowledge/src/errors/raprogramm.rs b/masterror-knowledge/src/errors/raprogramm.rs new file mode 100644 index 0000000..7e81024 --- /dev/null +++ b/masterror-knowledge/src/errors/raprogramm.rs @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Best practices from RAprogramm's RustManifest. +//! +//! These are not compiler errors but recommendations and patterns +//! from + +mod ra001; +mod ra002; +mod ra003; +mod ra004; +mod ra005; +mod ra006; +mod ra007; +mod ra008; +mod ra009; +mod ra010; +mod ra011; +mod ra012; +mod ra013; +mod ra014; +mod ra015; + +use std::{collections::HashMap, sync::LazyLock}; + +use arrayvec::ArrayString; + +pub use crate::errors::LocalizedText; + +/// Global practice registry singleton. +static PRACTICE_REGISTRY: LazyLock = LazyLock::new(PracticeRegistry::build); + +/// Best practice category. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PracticeCategory { + ErrorHandling, + Performance, + Naming, + Documentation, + Design, + Testing, + Security +} + +impl PracticeCategory { + pub fn name(&self, lang: &str) -> &'static str { + match (self, lang) { + (Self::ErrorHandling, "ru") => "Обработка ошибок", + (Self::ErrorHandling, "ko") => "오류 처리", + (Self::ErrorHandling, _) => "Error Handling", + + (Self::Performance, "ru") => "Производительность", + (Self::Performance, "ko") => "성능", + (Self::Performance, _) => "Performance", + + (Self::Naming, "ru") => "Именование", + (Self::Naming, "ko") => "명명", + (Self::Naming, _) => "Naming", + + (Self::Documentation, "ru") => "Документация", + (Self::Documentation, "ko") => "문서화", + (Self::Documentation, _) => "Documentation", + + (Self::Design, "ru") => "Проектирование", + (Self::Design, "ko") => "설계", + (Self::Design, _) => "Design", + + (Self::Testing, "ru") => "Тестирование", + (Self::Testing, "ko") => "테스트", + (Self::Testing, _) => "Testing", + + (Self::Security, "ru") => "Безопасность", + (Self::Security, "ko") => "보안", + (Self::Security, _) => "Security" + } + } +} + +/// A best practice recommendation. +#[derive(Debug, Clone)] +pub struct BestPractice { + pub code: &'static str, + pub title: LocalizedText, + pub category: PracticeCategory, + pub explanation: LocalizedText, + pub good_example: &'static str, + pub bad_example: &'static str, + pub source: &'static str +} + +static ENTRIES: &[&BestPractice] = &[ + &ra001::ENTRY, + &ra002::ENTRY, + &ra003::ENTRY, + &ra004::ENTRY, + &ra005::ENTRY, + &ra006::ENTRY, + &ra007::ENTRY, + &ra008::ENTRY, + &ra009::ENTRY, + &ra010::ENTRY, + &ra011::ENTRY, + &ra012::ENTRY, + &ra013::ENTRY, + &ra014::ENTRY, + &ra015::ENTRY +]; + +pub fn entries() -> &'static [&'static BestPractice] { + ENTRIES +} + +/// Registry for best practices. +pub struct PracticeRegistry { + practices: HashMap<&'static str, &'static BestPractice> +} + +impl PracticeRegistry { + /// Get the global registry instance. + pub fn new() -> &'static Self { + &PRACTICE_REGISTRY + } + + /// Build registry from all practices. + fn build() -> Self { + let mut practices = HashMap::with_capacity(15); + for entry in entries() { + practices.insert(entry.code, *entry); + } + Self { + practices + } + } + + /// Find practice by code (RA001, etc.). + /// + /// Accepts formats: "RA001", "ra001". + /// Uses stack-allocated buffer to avoid heap allocation. + pub fn find(&self, code: &str) -> Option<&'static BestPractice> { + // Fast path: try exact match first + if let Some(entry) = self.practices.get(code) { + return Some(*entry); + } + + // Normalize to uppercase using stack buffer + let mut buf: ArrayString<8> = ArrayString::new(); + for c in code.chars().take(8) { + buf.push(c.to_ascii_uppercase()); + } + + self.practices.get(buf.as_str()).copied() + } + + /// Get all practices. + pub fn all(&self) -> impl Iterator + '_ { + self.practices.values().copied() + } + + /// Get practices by category. + pub fn by_category(&self, cat: PracticeCategory) -> Vec<&'static BestPractice> { + self.practices + .values() + .filter(|p| p.category == cat) + .copied() + .collect() + } +} + +impl Default for &'static PracticeRegistry { + fn default() -> Self { + PracticeRegistry::new() + } +} diff --git a/masterror-knowledge/src/errors/raprogramm/ra001.rs b/masterror-knowledge/src/errors/raprogramm/ra001.rs new file mode 100644 index 0000000..774eed4 --- /dev/null +++ b/masterror-knowledge/src/errors/raprogramm/ra001.rs @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! RA001: Never use unwrap() in production code + +use crate::errors::raprogramm::{BestPractice, LocalizedText, PracticeCategory}; + +pub static ENTRY: BestPractice = BestPractice { + code: "RA001", + title: LocalizedText::new( + "Never use unwrap() in production code", + "Никогда не используйте unwrap() в продакшене", + "프로덕션 코드에서 unwrap() 사용 금지" + ), + category: PracticeCategory::ErrorHandling, + explanation: LocalizedText::new( + "\ +The Cloudflare November 2025 outage affected 330+ datacenters due to a single +.unwrap(). Configuration change exposed an error case that was never handled. +Result: ChatGPT, X, Canva offline for ~3 hours. + +Always use proper error propagation with Result and the ? operator. +Implement detailed error messages with map_err().", + "\ +Сбой Cloudflare в ноябре 2025 затронул 330+ дата-центров из-за одного .unwrap(). +Изменение конфигурации обнажило случай ошибки, который не был обработан. +Результат: ChatGPT, X, Canva недоступны ~3 часа. + +Всегда используйте правильное распространение ошибок с Result и оператором ?.", + "\ +2025년 11월 Cloudflare 장애는 단일 .unwrap()으로 인해 330개 이상의 데이터센터에 +영향을 미쳤습니다. 구성 변경으로 처리되지 않은 오류 케이스가 노출되었습니다." + ), + good_example: r#"let config = Config::from_file("config.toml") + .map_err(|e| format!("Failed to load config: {}", e))?;"#, + bad_example: r#"let config = Config::from_file("config.toml").unwrap();"#, + source: "https://github.com/RAprogramm/RustManifest#6-panic-avoidance-in-production" +}; diff --git a/masterror-knowledge/src/errors/raprogramm/ra002.rs b/masterror-knowledge/src/errors/raprogramm/ra002.rs new file mode 100644 index 0000000..52c5391 --- /dev/null +++ b/masterror-knowledge/src/errors/raprogramm/ra002.rs @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! RA002: Use ? operator for error propagation + +use crate::errors::raprogramm::{BestPractice, LocalizedText, PracticeCategory}; + +pub static ENTRY: BestPractice = BestPractice { + code: "RA002", + title: LocalizedText::new( + "Use ? operator for error propagation", + "Используйте оператор ? для распространения ошибок", + "오류 전파에 ? 연산자 사용" + ), + category: PracticeCategory::ErrorHandling, + explanation: LocalizedText::new( + "\ +The ? operator is the idiomatic way to handle errors in Rust. +It automatically converts errors and propagates them up the call stack. + +Use ok_or() or ok_or_else() to convert Option to Result with meaningful messages.", + "\ +Оператор ? — идиоматический способ обработки ошибок в Rust. +Он автоматически конвертирует ошибки и распространяет их вверх по стеку вызовов.", + "\ +? 연산자는 Rust에서 오류를 처리하는 관용적인 방법입니다." + ), + good_example: r#"let value = some_option.ok_or("Expected a value")?; +let data = fetch_data().map_err(|e| AppError::Network(e))?;"#, + bad_example: r#"let value = some_option.unwrap(); +let data = fetch_data().expect("fetch failed");"#, + source: "https://github.com/RAprogramm/RustManifest#5-best-practices" +}; diff --git a/masterror-knowledge/src/errors/raprogramm/ra003.rs b/masterror-knowledge/src/errors/raprogramm/ra003.rs new file mode 100644 index 0000000..14b249c --- /dev/null +++ b/masterror-knowledge/src/errors/raprogramm/ra003.rs @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! RA003: Avoid unnecessary clone() calls + +use crate::errors::raprogramm::{BestPractice, LocalizedText, PracticeCategory}; + +pub static ENTRY: BestPractice = BestPractice { + code: "RA003", + title: LocalizedText::new( + "Avoid unnecessary clone() calls", + "Избегайте ненужных вызовов clone()", + "불필요한 clone() 호출 피하기" + ), + category: PracticeCategory::Performance, + explanation: LocalizedText::new( + "\ +Cloning allocates memory and copies data. Often you can use references instead. +Only clone when you actually need ownership of the data. + +Common anti-patterns: +- Cloning just to satisfy the borrow checker (restructure instead) +- Cloning in a loop (clone once before the loop) +- Cloning when a reference would work", + "\ +Клонирование выделяет память и копирует данные. Часто можно использовать ссылки. +Клонируйте только когда действительно нужно владение данными.", + "\ +클론은 메모리를 할당하고 데이터를 복사합니다. 종종 참조를 대신 사용할 수 있습니다." + ), + good_example: r#"fn process(data: &str) { /* use reference */ } +let owned = expensive_data.clone(); // clone once +for item in &items { process(item); }"#, + bad_example: r#"for item in items { + process(item.clone()); // clones every iteration! +}"#, + source: "https://github.com/RAprogramm/RustManifest#3-code-quality" +}; diff --git a/masterror-knowledge/src/errors/raprogramm/ra004.rs b/masterror-knowledge/src/errors/raprogramm/ra004.rs new file mode 100644 index 0000000..ab1caae --- /dev/null +++ b/masterror-knowledge/src/errors/raprogramm/ra004.rs @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! RA004: Use descriptive, meaningful names + +use crate::errors::raprogramm::{BestPractice, LocalizedText, PracticeCategory}; + +pub static ENTRY: BestPractice = BestPractice { + code: "RA004", + title: LocalizedText::new( + "Use descriptive, meaningful names", + "Используйте описательные, значимые имена", + "설명적이고 의미 있는 이름 사용" + ), + category: PracticeCategory::Naming, + explanation: LocalizedText::new( + "\ +Names must reflect purpose. Avoid generic terms like 'create', 'handle', 'data'. +Descriptive names reduce ambiguity, facilitate easier onboarding, and improve +maintainability. + +Conventions: +- snake_case for variables and functions +- PascalCase for structs and enums +- SCREAMING_SNAKE_CASE for constants", + "\ +Имена должны отражать назначение. Избегайте общих терминов вроде 'create', 'handle', 'data'. +Описательные имена уменьшают неоднозначность и улучшают поддерживаемость.", + "\ +이름은 목적을 반영해야 합니다. 'create', 'handle', 'data' 같은 일반적인 용어를 피하세요." + ), + good_example: r#"fn create_user_handler(req: CreateUserRequest) -> Result +const MAX_RETRY_ATTEMPTS: u32 = 3; +struct UserAuthenticationService { ... }"#, + bad_example: r#"fn create(r: Request) -> Result +const MAX: u32 = 3; +struct Service { ... }"#, + source: "https://github.com/RAprogramm/RustManifest#2-naming-conventions" +}; diff --git a/masterror-knowledge/src/errors/raprogramm/ra005.rs b/masterror-knowledge/src/errors/raprogramm/ra005.rs new file mode 100644 index 0000000..0087cc2 --- /dev/null +++ b/masterror-knowledge/src/errors/raprogramm/ra005.rs @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! RA005: No inline comments - use docblocks only + +use crate::errors::raprogramm::{BestPractice, LocalizedText, PracticeCategory}; + +pub static ENTRY: BestPractice = BestPractice { + code: "RA005", + title: LocalizedText::new( + "No inline comments - use docblocks only", + "Никаких инлайн комментариев - только docblocks", + "인라인 주석 금지 - docblock만 사용" + ), + category: PracticeCategory::Documentation, + explanation: LocalizedText::new( + "\ +Avoid // and /* */ explanations in code. All documentation lives in docblocks: +/// for items, //! for modules. + +Standardized headings for IDE/LSP stability: +- # Overview - Short purpose statement +- # Examples - Minimal, compilable samples +- # Errors - Precise failure modes for Result types +- # Panics - Only if unavoidable +- # Safety - Required if unsafe code present", + "\ +Избегайте // и /* */ объяснений в коде. Вся документация живёт в docblocks: +/// для элементов, //! для модулей.", + "\ +코드에서 // 및 /* */ 설명을 피하세요. 모든 문서는 docblock에." + ), + good_example: r#"/// Fetches user data from the database. +/// +/// # Errors +/// Returns `DbError::NotFound` if user doesn't exist. +/// +/// # Examples +/// ``` +/// let user = fetch_user(42)?; +/// ``` +pub fn fetch_user(id: u64) -> Result"#, + bad_example: r#"// This function fetches user data from the database +// It returns an error if user is not found +pub fn fetch_user(id: u64) -> Result"#, + source: "https://github.com/RAprogramm/RustManifest#8-code-documentation" +}; diff --git a/masterror-knowledge/src/errors/raprogramm/ra006.rs b/masterror-knowledge/src/errors/raprogramm/ra006.rs new file mode 100644 index 0000000..ae7a5e1 --- /dev/null +++ b/masterror-knowledge/src/errors/raprogramm/ra006.rs @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! RA006: Entity naming - no -er suffixes + +use crate::errors::raprogramm::{BestPractice, LocalizedText, PracticeCategory}; + +pub static ENTRY: BestPractice = BestPractice { + code: "RA006", + title: LocalizedText::new( + "Entity naming: avoid -er, -or, -manager suffixes", + "Именование сущностей: избегайте суффиксов -er, -or, -manager", + "엔티티 명명: -er, -or, -manager 접미사 피하기" + ), + category: PracticeCategory::Naming, + explanation: LocalizedText::new( + "\ +Structures represent entities, not actions. The -er suffix encourages procedural +thinking that separates data from behavior, creating anemic domain models. +Entity naming naturally unifies data and operations. + +Transforms: +- ConfigLoader → Config +- MessageParser → Message +- RequestHandler → Request +- DataValidator → Data +- ConnectionManager → ConnectionPool + +Exceptions: Iterator, Builder, Visitor, Formatter (established patterns).", + "\ +Структуры представляют сущности, не действия. Суффикс -er поощряет процедурное +мышление, разделяющее данные и поведение. Именование сущностей объединяет их.", + "\ +구조체는 동작이 아닌 엔티티를 나타냅니다. -er 접미사는 절차적 사고를 장려합니다." + ), + good_example: r#"struct Config { ... } +struct Message { ... } +struct Request { ... } +struct ConnectionPool { ... }"#, + bad_example: r#"struct ConfigLoader { ... } +struct MessageParser { ... } +struct RequestHandler { ... } +struct ConnectionManager { ... }"#, + source: "https://github.com/RAprogramm/RustManifest/blob/main/STRUCTURE.md#1-entity-naming" +}; diff --git a/masterror-knowledge/src/errors/raprogramm/ra007.rs b/masterror-knowledge/src/errors/raprogramm/ra007.rs new file mode 100644 index 0000000..b585557 --- /dev/null +++ b/masterror-knowledge/src/errors/raprogramm/ra007.rs @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! RA007: Method naming - nouns for accessors, verbs for mutators + +use crate::errors::raprogramm::{BestPractice, LocalizedText, PracticeCategory}; + +pub static ENTRY: BestPractice = BestPractice { + code: "RA007", + title: LocalizedText::new( + "Method naming: nouns for accessors, verbs for mutators", + "Именование методов: существительные для accessors, глаголы для mutators", + "메서드 명명: accessor는 명사, mutator는 동사" + ), + category: PracticeCategory::Naming, + explanation: LocalizedText::new( + "\ +Method names reflect purpose through grammatical form: +- Accessors (nouns): name(), length(), value() — not get_name() +- Predicates (adjectives): empty(), valid(), published() — not is_empty() +- Mutators (verbs): save(), publish(), delete() + +The get_ prefix adds noise without information. Omitting verbs signals pure +accessors. Adjective predicates read more naturally than is_ constructions.", + "\ +Имена методов отражают назначение через грамматическую форму: +- Accessors: name(), length() — не get_name() +- Predicates: empty(), valid() — не is_empty() +- Mutators: save(), publish(), delete()", + "\ +메서드 이름은 문법적 형태로 목적을 반영합니다." + ), + good_example: r#"impl User { + fn name(&self) -> &str { &self.name } + fn empty(&self) -> bool { self.data.is_empty() } + fn save(&mut self) { ... } +}"#, + bad_example: r#"impl User { + fn get_name(&self) -> &str { &self.name } + fn is_empty(&self) -> bool { self.data.is_empty() } +}"#, + source: "https://github.com/RAprogramm/RustManifest/blob/main/STRUCTURE.md#2-method-naming" +}; diff --git a/masterror-knowledge/src/errors/raprogramm/ra008.rs b/masterror-knowledge/src/errors/raprogramm/ra008.rs new file mode 100644 index 0000000..9439153 --- /dev/null +++ b/masterror-knowledge/src/errors/raprogramm/ra008.rs @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! RA008: Structure size - maximum 4 fields + +use crate::errors::raprogramm::{BestPractice, LocalizedText, PracticeCategory}; + +pub static ENTRY: BestPractice = BestPractice { + code: "RA008", + title: LocalizedText::new( + "Structure size: maximum 4 fields", + "Размер структуры: максимум 4 поля", + "구조체 크기: 최대 4개 필드" + ), + category: PracticeCategory::Design, + explanation: LocalizedText::new( + "\ +A structure should have no more than 4 fields. More fields indicate multiple +responsibilities requiring composition. + +Problems with large structures: +- Complex testing with many combinations +- Changes ripple through unrelated code +- Purpose becomes unclear +- Parts cannot be reused independently + +Solution: Decompose into focused sub-structures.", + "\ +Структура должна иметь не более 4 полей. Больше полей указывает на +множественные ответственности, требующие композиции.", + "\ +구조체는 4개 이하의 필드를 가져야 합니다. 더 많은 필드는 분해가 필요함을 나타냅니다." + ), + good_example: r#"struct User { + identity: UserIdentity, + credentials: Credentials, + profile: UserProfile, + access: AccessControl, +}"#, + bad_example: r#"struct User { + id: u64, email: String, password_hash: String, + name: String, avatar: Option, bio: String, + created_at: DateTime, updated_at: DateTime, + role: Role, permissions: Vec, + last_login: Option, login_count: u32, + is_verified: bool, verification_token: Option, +}"#, + source: "https://github.com/RAprogramm/RustManifest/blob/main/STRUCTURE.md#3-structure-size" +}; diff --git a/masterror-knowledge/src/errors/raprogramm/ra009.rs b/masterror-knowledge/src/errors/raprogramm/ra009.rs new file mode 100644 index 0000000..91573b8 --- /dev/null +++ b/masterror-knowledge/src/errors/raprogramm/ra009.rs @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! RA009: Public API size - maximum 5 methods + +use crate::errors::raprogramm::{BestPractice, LocalizedText, PracticeCategory}; + +pub static ENTRY: BestPractice = BestPractice { + code: "RA009", + title: LocalizedText::new( + "Public API size: maximum 5 methods", + "Размер публичного API: максимум 5 методов", + "공개 API 크기: 최대 5개 메서드" + ), + category: PracticeCategory::Design, + explanation: LocalizedText::new( + "\ +A structure's public interface should have no more than 5 methods. +More methods signal the structure does too much. + +Large APIs indicate mixed responsibilities, forcing users to understand more, +expanding documentation and testing complexity. + +Solution: Extract secondary concerns into separate types. +Excludes: trait implementations (Display, Debug, From) and generic new().", + "\ +Публичный интерфейс структуры должен иметь не более 5 методов. +Больше методов означает, что структура делает слишком много.", + "\ +구조체의 공개 인터페이스는 5개 이하의 메서드를 가져야 합니다." + ), + good_example: r#"impl Document { + pub fn new() -> Self { ... } + pub fn load(path: &Path) -> Result { ... } + pub fn save(&self) -> Result<()> { ... } + pub fn content(&self) -> &str { ... } + pub fn metadata(&self) -> &Metadata { ... } +} + +// Rendering is separate +impl Renderer { ... } +// Export is separate +impl Exporter { ... }"#, + bad_example: r#"impl Document { + pub fn new() -> Self { ... } + pub fn load() -> Result { ... } + pub fn save() -> Result<()> { ... } + pub fn content(&self) -> &str { ... } + pub fn metadata(&self) -> &Metadata { ... } + pub fn render_html(&self) -> String { ... } + pub fn render_pdf(&self) -> Vec { ... } + pub fn export_json(&self) -> String { ... } + pub fn validate(&self) -> Result<()> { ... } + // ... 10+ more methods +}"#, + source: "https://github.com/RAprogramm/RustManifest/blob/main/STRUCTURE.md#4-public-api-size" +}; diff --git a/masterror-knowledge/src/errors/raprogramm/ra010.rs b/masterror-knowledge/src/errors/raprogramm/ra010.rs new file mode 100644 index 0000000..16397f6 --- /dev/null +++ b/masterror-knowledge/src/errors/raprogramm/ra010.rs @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! RA010: Constructors should only assign fields + +use crate::errors::raprogramm::{BestPractice, LocalizedText, PracticeCategory}; + +pub static ENTRY: BestPractice = BestPractice { + code: "RA010", + title: LocalizedText::new( + "Constructors: assignment only, no logic", + "Конструкторы: только присваивание, никакой логики", + "생성자: 할당만, 로직 없음" + ), + category: PracticeCategory::Design, + explanation: LocalizedText::new( + "\ +Constructors should only assign fields. All processing, validation, and I/O +belong in methods. + +Problems with logic in constructors: +- Constructors can fail, complicating object creation +- Work happens eagerly even if unused +- Inflexible creation paths +- Hard to test without real resources + +Benefits of assignment-only constructors: +- Infallible object creation +- Lazy evaluation of expensive work +- Multiple creation paths (from_data() for tests)", + "\ +Конструкторы должны только присваивать поля. Вся обработка, валидация и I/O +принадлежат методам.", + "\ +생성자는 필드만 할당해야 합니다. 모든 처리, 검증, I/O는 메서드에." + ), + good_example: r#"impl Server { + pub fn new(config: Config) -> Self { + Self { config, connection: None } + } + + pub fn connect(&mut self) -> Result<()> { + self.connection = Some(Connection::establish(&self.config)?); + Ok(()) + } +}"#, + bad_example: r#"impl Server { + pub fn new(config: Config) -> Result { + let connection = Connection::establish(&config)?; // I/O in constructor! + validate_config(&config)?; // Logic in constructor! + Ok(Self { config, connection }) + } +}"#, + source: "https://github.com/RAprogramm/RustManifest/blob/main/STRUCTURE.md#5-constructor-design" +}; diff --git a/masterror-knowledge/src/errors/raprogramm/ra011.rs b/masterror-knowledge/src/errors/raprogramm/ra011.rs new file mode 100644 index 0000000..a837c1b --- /dev/null +++ b/masterror-knowledge/src/errors/raprogramm/ra011.rs @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! RA011: Immutability first - prefer self over &mut self + +use crate::errors::raprogramm::{BestPractice, LocalizedText, PracticeCategory}; + +pub static ENTRY: BestPractice = BestPractice { + code: "RA011", + title: LocalizedText::new( + "Immutability first: prefer self over &mut self", + "Сначала неизменяемость: предпочитайте self вместо &mut self", + "불변성 우선: &mut self보다 self 선호" + ), + category: PracticeCategory::Design, + explanation: LocalizedText::new( + "\ +Prefer returning new objects over mutating existing ones. Use `self` instead +of `&mut self` where practical. + +Problems with mutable objects: +- Shared state bugs from unexpected modifications +- Thread safety requires synchronization +- Temporal coupling makes operation order matter +- Incomplete state during configuration + +Exceptions: Large data structures, I/O, performance-critical loops, Iterator::next", + "\ +Предпочитайте возврат новых объектов вместо изменения существующих. +Используйте `self` вместо `&mut self` где возможно.", + "\ +기존 객체를 변경하는 것보다 새 객체를 반환하는 것을 선호하세요." + ), + good_example: r#"Request::new(url) + .header("Content-Type", "application/json") + .body(payload) + .send()"#, + bad_example: r#"let mut req = Request::new(url); +req.set_header("Content-Type", "application/json"); +req.set_body(payload); +req.send()"#, + source: "https://github.com/RAprogramm/RustManifest/blob/main/STRUCTURE.md#7-immutability-first" +}; diff --git a/masterror-knowledge/src/errors/raprogramm/ra012.rs b/masterror-knowledge/src/errors/raprogramm/ra012.rs new file mode 100644 index 0000000..8274d8c --- /dev/null +++ b/masterror-knowledge/src/errors/raprogramm/ra012.rs @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! RA012: Constant encapsulation - associate with types + +use crate::errors::raprogramm::{BestPractice, LocalizedText, PracticeCategory}; + +pub static ENTRY: BestPractice = BestPractice { + code: "RA012", + title: LocalizedText::new( + "Encapsulate constants in their types", + "Инкапсулируйте константы в их типы", + "상수를 타입에 캡슐화" + ), + category: PracticeCategory::Design, + explanation: LocalizedText::new( + "\ +Constants belong to structures using them, not global scope. This improves +discoverability and prevents namespace pollution. + +Benefits: +- Clear discovery location +- Built-in documentation +- Automatic namespacing +- Encapsulation +- Easy refactoring", + "\ +Константы принадлежат структурам, которые их используют, а не глобальной области. +Это улучшает обнаруживаемость и предотвращает загрязнение пространства имён.", + "\ +상수는 전역 범위가 아닌 사용하는 구조체에 속합니다." + ), + good_example: r#"impl ConnectionPool { + pub const MAX_SIZE: usize = 100; +} + +impl Client { + pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +} + +// Usage: ConnectionPool::MAX_SIZE"#, + bad_example: r#"const MAX_POOL_SIZE: usize = 100; +const DEFAULT_CLIENT_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_RETRIES: u32 = 3; +const DEFAULT_PORT: u16 = 8080; +// ... scattered constants"#, + source: "https://github.com/RAprogramm/RustManifest/blob/main/STRUCTURE.md#8-constant-encapsulation" +}; diff --git a/masterror-knowledge/src/errors/raprogramm/ra013.rs b/masterror-knowledge/src/errors/raprogramm/ra013.rs new file mode 100644 index 0000000..306b810 --- /dev/null +++ b/masterror-knowledge/src/errors/raprogramm/ra013.rs @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! RA013: Testing with fakes over mocks + +use crate::errors::raprogramm::{BestPractice, LocalizedText, PracticeCategory}; + +pub static ENTRY: BestPractice = BestPractice { + code: "RA013", + title: LocalizedText::new( + "Use fakes over mocks for testing", + "Используйте fakes вместо mocks для тестирования", + "테스트에 mock보다 fake 사용" + ), + category: PracticeCategory::Testing, + explanation: LocalizedText::new( + "\ +Use simple fake implementations instead of mock libraries. Fakes provide real +behavior; mocks verify call sequences. + +| Aspect | Mocks | Fakes | +|--------|-------|-------| +| Coupling | High | Low | +| Maintenance | Breaks on refactoring | Survives changes | +| Behavior | Simulates | Provides real | +| Debugging | Cryptic | Standard | + +Mock appropriateness: Verifying external system interactions, ensuring methods +are NOT called, testing strict interaction ordering.", + "\ +Используйте простые fake-реализации вместо mock-библиотек. Fakes обеспечивают +реальное поведение; mocks проверяют последовательности вызовов.", + "\ +mock 라이브러리 대신 간단한 fake 구현을 사용하세요." + ), + good_example: r#"struct FakeDatabase { + users: HashMap, +} + +impl FakeDatabase { + fn new() -> Self { Self { users: HashMap::new() } } + fn insert(&mut self, user: User) { self.users.insert(user.id, user); } +} + +impl Database for FakeDatabase { + fn find_user(&self, id: u64) -> Option<&User> { + self.users.get(&id) + } +}"#, + bad_example: r#"#[test] +fn test_user_service() { + let mut mock = MockDatabase::new(); + mock.expect_find_user() + .with(eq(42)) + .times(1) + .returning(|_| Some(User::default())); + // Breaks when implementation changes +}"#, + source: "https://github.com/RAprogramm/RustManifest/blob/main/STRUCTURE.md#9-testing-with-fakes" +}; diff --git a/masterror-knowledge/src/errors/raprogramm/ra014.rs b/masterror-knowledge/src/errors/raprogramm/ra014.rs new file mode 100644 index 0000000..4301b08 --- /dev/null +++ b/masterror-knowledge/src/errors/raprogramm/ra014.rs @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! RA014: Pre-allocate with Vec::with_capacity + +use crate::errors::raprogramm::{BestPractice, LocalizedText, PracticeCategory}; + +pub static ENTRY: BestPractice = BestPractice { + code: "RA014", + title: LocalizedText::new( + "Pre-allocate with Vec::with_capacity", + "Предвыделяйте память с Vec::with_capacity", + "Vec::with_capacity로 사전 할당" + ), + category: PracticeCategory::Performance, + explanation: LocalizedText::new( + "\ +When you know the approximate size of a Vec, pre-allocate to avoid reallocations. +Each reallocation copies all existing elements to new memory. + +This is especially important in hot paths and loops.", + "\ +Когда знаете примерный размер Vec, предвыделяйте чтобы избежать реаллокаций. +Каждая реаллокация копирует все элементы в новую память.", + "\ +Vec의 대략적인 크기를 알 때, 재할당을 피하기 위해 사전 할당하세요." + ), + good_example: r#"let mut results = Vec::with_capacity(items.len()); +for item in items { + results.push(process(item)); +}"#, + bad_example: r#"let mut results = Vec::new(); // starts with 0 capacity +for item in items { + results.push(process(item)); // reallocates multiple times! +}"#, + source: "https://github.com/RAprogramm/RustManifest#9-code-review-methodology" +}; diff --git a/masterror-knowledge/src/errors/raprogramm/ra015.rs b/masterror-knowledge/src/errors/raprogramm/ra015.rs new file mode 100644 index 0000000..0bfd79c --- /dev/null +++ b/masterror-knowledge/src/errors/raprogramm/ra015.rs @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! RA015: Avoid O(n²) algorithms + +use crate::errors::raprogramm::{BestPractice, LocalizedText, PracticeCategory}; + +pub static ENTRY: BestPractice = BestPractice { + code: "RA015", + title: LocalizedText::new( + "Avoid O(n²) algorithms", + "Избегайте алгоритмов O(n²)", + "O(n²) 알고리즘 피하기" + ), + category: PracticeCategory::Performance, + explanation: LocalizedText::new( + "\ +Nested loops over the same data often indicate O(n²) complexity. +Use HashSet/HashMap for lookups, or sort + binary search. + +What looks fine with 100 items becomes unusable with 10,000.", + "\ +Вложенные циклы по одним данным часто указывают на O(n²) сложность. +Используйте HashSet/HashMap для поиска или сортировку + бинарный поиск.", + "\ +같은 데이터에 대한 중첩 루프는 종종 O(n²) 복잡도를 나타냅니다." + ), + good_example: r#"let seen: HashSet<_> = items.iter().collect(); +for item in other_items { + if seen.contains(&item) { // O(1) lookup + // ... + } +}"#, + bad_example: r#"for item in other_items { + for existing in &items { // O(n) for each = O(n²) total + if item == existing { + // ... + } + } +}"#, + source: "https://github.com/RAprogramm/RustManifest#9-code-review-methodology" +}; diff --git a/masterror-knowledge/src/errors/resolution.rs b/masterror-knowledge/src/errors/resolution.rs new file mode 100644 index 0000000..42f17d8 --- /dev/null +++ b/masterror-knowledge/src/errors/resolution.rs @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Name resolution errors. + +mod e0412; +mod e0425; +mod e0433; + +use super::ErrorEntry; + +static ENTRIES: &[&ErrorEntry] = &[&e0412::ENTRY, &e0425::ENTRY, &e0433::ENTRY]; + +pub fn entries() -> &'static [&'static ErrorEntry] { + ENTRIES +} diff --git a/masterror-knowledge/src/errors/resolution/e0412.rs b/masterror-knowledge/src/errors/resolution/e0412.rs new file mode 100644 index 0000000..32a9aa1 --- /dev/null +++ b/masterror-knowledge/src/errors/resolution/e0412.rs @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0412", + title: LocalizedText::new( + "Cannot find type in this scope", + "Не удаётся найти тип в этой области видимости", + "이 스코프에서 타입을 찾을 수 없음" + ), + category: Category::Resolution, + explanation: LocalizedText::new( + "The type you're referencing doesn't exist or isn't in scope.", + "Тип, на который вы ссылаетесь, не существует или не в области видимости.", + "참조하는 타입이 존재하지 않거나 스코프에 없습니다." + ), + fixes: &[FixSuggestion { + description: LocalizedText::new("Import the type", "Импортировать тип", "타입 import"), + code: "use crate::types::MyType;" + }], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0412.html" + }] +}; diff --git a/masterror-knowledge/src/errors/resolution/e0425.rs b/masterror-knowledge/src/errors/resolution/e0425.rs new file mode 100644 index 0000000..b973d07 --- /dev/null +++ b/masterror-knowledge/src/errors/resolution/e0425.rs @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0425", + title: LocalizedText::new( + "Cannot find value in this scope", + "Не удаётся найти значение в этой области видимости", + "이 스코프에서 값을 찾을 수 없음" + ), + category: Category::Resolution, + explanation: LocalizedText::new( + "You're using a variable, function, or constant that doesn't exist in scope.", + "Вы используете переменную, функцию или константу, которая не существует в текущей области.", + "스코프에 존재하지 않는 변수, 함수 또는 상수를 사용하고 있습니다." + ), + fixes: &[FixSuggestion { + description: LocalizedText::new( + "Declare the variable", + "Объявить переменную", + "변수 선언" + ), + code: "let x = 10;" + }], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0425.html" + }] +}; diff --git a/masterror-knowledge/src/errors/resolution/e0433.rs b/masterror-knowledge/src/errors/resolution/e0433.rs new file mode 100644 index 0000000..b9e4d19 --- /dev/null +++ b/masterror-knowledge/src/errors/resolution/e0433.rs @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0433", + title: LocalizedText::new( + "Failed to resolve: use of undeclared crate or module", + "Не удалось разрешить: необъявленный крейт или модуль", + "해결 실패: 선언되지 않은 크레이트 또는 모듈" + ), + category: Category::Resolution, + explanation: LocalizedText::new( + "Rust can't find the crate, module, or type you're trying to use.", + "Rust не может найти крейт, модуль или тип.", + "Rust가 크레이트, 모듈 또는 타입을 찾을 수 없습니다." + ), + fixes: &[ + FixSuggestion { + description: LocalizedText::new("Add use statement", "Добавить use", "use 문 추가"), + code: "use std::collections::HashMap;" + }, + FixSuggestion { + description: LocalizedText::new( + "Add dependency", + "Добавить зависимость", + "의존성 추가" + ), + code: "[dependencies]\nserde = \"1.0\"" + } + ], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0433.html" + }] +}; diff --git a/masterror-knowledge/src/errors/traits.rs b/masterror-knowledge/src/errors/traits.rs new file mode 100644 index 0000000..d402807 --- /dev/null +++ b/masterror-knowledge/src/errors/traits.rs @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Trait-related errors. + +mod e0038; +mod e0282; + +use super::ErrorEntry; + +static ENTRIES: &[&ErrorEntry] = &[&e0038::ENTRY, &e0282::ENTRY]; + +pub fn entries() -> &'static [&'static ErrorEntry] { + ENTRIES +} diff --git a/masterror-knowledge/src/errors/traits/e0038.rs b/masterror-knowledge/src/errors/traits/e0038.rs new file mode 100644 index 0000000..9055739 --- /dev/null +++ b/masterror-knowledge/src/errors/traits/e0038.rs @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0038", + title: LocalizedText::new( + "Trait cannot be made into an object", + "Трейт не может быть превращён в объект", + "트레이트를 객체로 만들 수 없음" + ), + category: Category::Traits, + explanation: LocalizedText::new( + "This trait is not object-safe - it can't be used as `dyn Trait`.", + "Этот трейт не объектно-безопасен - его нельзя использовать как `dyn Trait`.", + "이 트레이트는 객체 안전하지 않습니다." + ), + fixes: &[FixSuggestion { + description: LocalizedText::new("Use generics", "Использовать обобщения", "제네릭 사용"), + code: "fn process(item: T) { ... }" + }], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0038.html" + }] +}; diff --git a/masterror-knowledge/src/errors/traits/e0282.rs b/masterror-knowledge/src/errors/traits/e0282.rs new file mode 100644 index 0000000..1861285 --- /dev/null +++ b/masterror-knowledge/src/errors/traits/e0282.rs @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0282", + title: LocalizedText::new( + "Type annotations needed", + "Требуются аннотации типа", + "타입 어노테이션이 필요함" + ), + category: Category::Traits, + explanation: LocalizedText::new( + "The compiler cannot infer the type. Provide an explicit type annotation.", + "Компилятор не может вывести тип. Укажите явную аннотацию типа.", + "컴파일러가 타입을 추론할 수 없습니다." + ), + fixes: &[FixSuggestion { + description: LocalizedText::new( + "Add type annotation", + "Добавить аннотацию", + "타입 어노테이션 추가" + ), + code: "let numbers: Vec = input.parse().unwrap();" + }], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0282.html" + }] +}; diff --git a/masterror-knowledge/src/errors/types.rs b/masterror-knowledge/src/errors/types.rs new file mode 100644 index 0000000..8cf39da --- /dev/null +++ b/masterror-knowledge/src/errors/types.rs @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Type-related errors. + +mod e0277; +mod e0308; +mod e0599; + +use super::ErrorEntry; + +static ENTRIES: &[&ErrorEntry] = &[&e0277::ENTRY, &e0308::ENTRY, &e0599::ENTRY]; + +pub fn entries() -> &'static [&'static ErrorEntry] { + ENTRIES +} diff --git a/masterror-knowledge/src/errors/types/e0277.rs b/masterror-knowledge/src/errors/types/e0277.rs new file mode 100644 index 0000000..e054ab2 --- /dev/null +++ b/masterror-knowledge/src/errors/types/e0277.rs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0277", + title: LocalizedText::new( + "Trait bound not satisfied", + "Ограничение трейта не выполнено", + "트레이트 바운드가 충족되지 않음" + ), + category: Category::Types, + explanation: LocalizedText::new( + "A generic function or type requires a trait bound that your type doesn't satisfy.", + "Обобщённая функция или тип требует ограничение трейта, которому ваш тип не удовлетворяет.", + "제네릭 함수나 타입이 당신의 타입이 충족하지 않는 트레이트 바운드를 요구합니다." + ), + fixes: &[ + FixSuggestion { + description: LocalizedText::new( + "Derive the trait", + "Получить через derive", + "트레이트 derive" + ), + code: "#[derive(Hash, Eq, PartialEq)]" + }, + FixSuggestion { + description: LocalizedText::new( + "Implement manually", + "Реализовать вручную", + "수동 구현" + ), + code: "impl MyTrait for MyType { ... }" + } + ], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0277.html" + }] +}; diff --git a/masterror-knowledge/src/errors/types/e0308.rs b/masterror-knowledge/src/errors/types/e0308.rs new file mode 100644 index 0000000..a01246f --- /dev/null +++ b/masterror-knowledge/src/errors/types/e0308.rs @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0308", + title: LocalizedText::new("Mismatched types", "Несовпадение типов", "타입 불일치"), + category: Category::Types, + explanation: LocalizedText::new( + "Rust is statically typed and does NOT perform implicit type conversions.", + "Rust статически типизирован и НЕ выполняет неявные преобразования типов.", + "Rust는 정적 타입 언어이며 암시적 타입 변환을 수행하지 않습니다." + ), + fixes: &[ + FixSuggestion { + description: LocalizedText::new("Use parse()", "Использовать parse()", "parse() 사용"), + code: "let n: i32 = s.parse().unwrap();" + }, + FixSuggestion { + description: LocalizedText::new("Use 'as'", "Использовать 'as'", "'as' 사용"), + code: "let n = x as i32;" + } + ], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0308.html" + }] +}; diff --git a/masterror-knowledge/src/errors/types/e0599.rs b/masterror-knowledge/src/errors/types/e0599.rs new file mode 100644 index 0000000..84a2802 --- /dev/null +++ b/masterror-knowledge/src/errors/types/e0599.rs @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +use crate::errors::{Category, DocLink, ErrorEntry, FixSuggestion, LocalizedText}; + +pub static ENTRY: ErrorEntry = ErrorEntry { + code: "E0599", + title: LocalizedText::new( + "No method named X found for type", + "Метод не найден для типа", + "타입에서 메서드를 찾을 수 없음" + ), + category: Category::Types, + explanation: LocalizedText::new( + "You're calling a method that doesn't exist on this type. Check trait imports.", + "Вы вызываете метод, который не существует для этого типа. Проверьте импорт трейтов.", + "이 타입에 존재하지 않는 메서드를 호출하고 있습니다." + ), + fixes: &[FixSuggestion { + description: LocalizedText::new( + "Import the trait", + "Импортировать трейт", + "트레이트 import" + ), + code: "use std::io::Read;" + }], + links: &[DocLink { + title: "Error Code Reference", + url: "https://doc.rust-lang.org/error_codes/E0599.html" + }] +}; diff --git a/masterror-knowledge/src/i18n/messages.rs b/masterror-knowledge/src/i18n/messages.rs new file mode 100644 index 0000000..99ebba6 --- /dev/null +++ b/masterror-knowledge/src/i18n/messages.rs @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! UI messages for masterror-cli. + +use crate::define_messages; + +define_messages! { + pub enum UiMsg { + LabelTranslation { + en: "Translation:", + ru: "Перевод:", + ko: "번역:", + } + LabelWhy { + en: "Why this happens:", + ru: "Почему это происходит:", + ko: "왜 이런 일이 발생하나요:", + } + LabelFix { + en: "How to fix:", + ru: "Как исправить:", + ko: "해결 방법:", + } + LabelLink { + en: "Learn more:", + ru: "Подробнее:", + ko: "더 알아보기:", + } + LabelWhyMatters { + en: "Why this matters:", + ru: "Почему это важно:", + ko: "왜 중요한가:", + } + LabelHowToApply { + en: "How to apply:", + ru: "Как применять:", + ko: "적용 방법:", + } + LabelAvoid { + en: "Avoid", + ru: "Избегайте", + ko: "피하세요", + } + LabelPrefer { + en: "Prefer", + ru: "Предпочитайте", + ko: "선호하세요", + } + + CategoryOwnership { + en: "Ownership", + ru: "Владение", + ko: "소유권", + } + CategoryBorrowing { + en: "Borrowing", + ru: "Заимствование", + ko: "빌림", + } + CategoryLifetimes { + en: "Lifetimes", + ru: "Времена жизни", + ko: "라이프타임", + } + CategoryTypes { + en: "Types", + ru: "Типы", + ko: "타입", + } + CategoryTraits { + en: "Traits", + ru: "Трейты", + ko: "트레이트", + } + CategoryResolution { + en: "Name Resolution", + ru: "Разрешение имён", + ko: "이름 확인", + } + + CategoryErrorHandling { + en: "Error Handling", + ru: "Обработка ошибок", + ko: "에러 처리", + } + CategoryPerformance { + en: "Performance", + ru: "Производительность", + ko: "성능", + } + CategoryNaming { + en: "Naming", + ru: "Именование", + ko: "명명", + } + CategoryDocumentation { + en: "Documentation", + ru: "Документация", + ko: "문서화", + } + CategoryDesign { + en: "Design", + ru: "Дизайн", + ko: "설계", + } + CategoryTesting { + en: "Testing", + ru: "Тестирование", + ko: "테스트", + } + CategorySecurity { + en: "Security", + ru: "Безопасность", + ko: "보안", + } + + UnknownCode { + en: "Unknown code", + ru: "Неизвестный код", + ko: "알 수 없는 코드", + } + Category { + en: "Category", + ru: "Категория", + ko: "카테고리", + } + + InitTitle { + en: "masterror configuration", + ru: "Настройка masterror", + ko: "masterror 설정", + } + InitSuccess { + en: "Configuration saved to", + ru: "Конфигурация сохранена в", + ko: "설정이 저장됨:", + } + InitLangPrompt { + en: "Language", + ru: "Язык", + ko: "언어", + } + InitColorPrompt { + en: "Colored output", + ru: "Цветной вывод", + ko: "색상 출력", + } + InitDisplayPrompt { + en: "Display sections:", + ru: "Секции для отображения:", + ko: "표시 섹션:", + } + InitShowTranslation { + en: "Show translation", + ru: "Показывать перевод", + ko: "번역 표시", + } + InitShowWhy { + en: "Show explanation", + ru: "Показывать объяснение", + ko: "설명 표시", + } + InitShowFix { + en: "Show fix suggestions", + ru: "Показывать исправления", + ko: "수정 제안 표시", + } + InitShowLinks { + en: "Show documentation links", + ru: "Показывать ссылки", + ko: "문서 링크 표시", + } + InitShowOriginal { + en: "Show original compiler output", + ru: "Показывать оригинальный вывод", + ko: "원본 컴파일러 출력 표시", + } + InitSavePrompt { + en: "Where to save configuration?", + ru: "Где сохранить настройки?", + ko: "설정을 어디에 저장할까요?", + } + InitSaveGlobal { + en: "Global (~/.config/masterror/) - applies to all projects", + ru: "Глобально (~/.config/masterror/) - для всех проектов", + ko: "전역 (~/.config/masterror/) - 모든 프로젝트에 적용", + } + InitSaveLocal { + en: "Local (.masterror.toml) - only this project", + ru: "Локально (.masterror.toml) - только этот проект", + ko: "로컬 (.masterror.toml) - 이 프로젝트만", + } + InitTip { + en: "You can change settings anytime by editing:", + ru: "Вы можете изменить настройки в любой момент, отредактировав:", + ko: "언제든지 다음 파일을 편집하여 설정을 변경할 수 있습니다:", + } + InitUsage { + en: "Start using masterror:", + ru: "Начните использовать masterror:", + ko: "masterror 사용 시작:", + } + } +} diff --git a/masterror-knowledge/src/i18n/mod.rs b/masterror-knowledge/src/i18n/mod.rs new file mode 100644 index 0000000..a7c6afc --- /dev/null +++ b/masterror-knowledge/src/i18n/mod.rs @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Internationalization system for masterror-cli. +//! +//! Provides compile-time localization with zero runtime allocation. + +pub mod messages; +pub mod phrases; + +/// Supported languages. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum Lang { + /// English (default). + #[default] + En = 0, + /// Russian. + #[cfg(feature = "lang-ru")] + Ru = 1, + /// Korean. + #[cfg(feature = "lang-ko")] + Ko = 2 +} + +impl Lang { + /// Parse language from string, fallback to English. + /// + /// # Examples + /// + /// ``` + /// use masterror_knowledge::Lang; + /// + /// assert_eq!(Lang::from_code("ru"), Lang::Ru); + /// assert_eq!(Lang::from_code("unknown"), Lang::En); + /// ``` + pub fn from_code(s: &str) -> Self { + match s { + #[cfg(feature = "lang-ru")] + "ru" | "RU" | "Ru" => Self::Ru, + #[cfg(feature = "lang-ko")] + "ko" | "KO" | "Ko" => Self::Ko, + _ => Self::En + } + } + + /// Get language code as string. + pub const fn code(self) -> &'static str { + match self { + Self::En => "en", + #[cfg(feature = "lang-ru")] + Self::Ru => "ru", + #[cfg(feature = "lang-ko")] + Self::Ko => "ko" + } + } + + /// Get language display name. + pub const fn name(self) -> &'static str { + match self { + Self::En => "English", + #[cfg(feature = "lang-ru")] + Self::Ru => "Русский", + #[cfg(feature = "lang-ko")] + Self::Ko => "한국어" + } + } +} + +/// Define localized messages with compile-time validation. +/// +/// Creates an enum with localized strings accessible via `.get(lang)` method. +/// All strings are `&'static str` with zero runtime allocation. +/// +/// # Example +/// +/// ```ignore +/// define_messages! { +/// pub enum UiMsg { +/// LabelWhy { +/// en: "Why:", +/// ru: "Почему:", +/// ko: "왜:", +/// } +/// } +/// } +/// +/// let msg = UiMsg::LabelWhy.get(Lang::Ru); +/// assert_eq!(msg, "Почему:"); +/// ``` +#[macro_export] +macro_rules! define_messages { + ( + $vis:vis enum $name:ident { + $( + $key:ident { + en: $en:literal + $(, ru: $ru:literal)? + $(, ko: $ko:literal)? + $(,)? + } + )* + } + ) => { + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[repr(u16)] + $vis enum $name { + $($key,)* + } + + impl $name { + /// Total number of messages. + pub const COUNT: usize = { + let mut count = 0usize; + $(let _ = stringify!($key); count += 1;)* + count + }; + + /// Get localized string for this message key. + #[inline] + pub const fn get(self, lang: $crate::i18n::Lang) -> &'static str { + match self { + $( + Self::$key => { + match lang { + $crate::i18n::Lang::En => $en, + $( + #[cfg(feature = "lang-ru")] + $crate::i18n::Lang::Ru => $ru, + )? + $( + #[cfg(feature = "lang-ko")] + $crate::i18n::Lang::Ko => $ko, + )? + #[allow(unreachable_patterns)] + _ => $en, + } + } + )* + } + } + + /// Get all message keys as static slice. + pub const fn all() -> &'static [Self] { + &[$(Self::$key,)*] + } + } + }; +} + +pub use define_messages; diff --git a/masterror-knowledge/src/i18n/phrases.rs b/masterror-knowledge/src/i18n/phrases.rs new file mode 100644 index 0000000..f0db428 --- /dev/null +++ b/masterror-knowledge/src/i18n/phrases.rs @@ -0,0 +1,241 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Phrase translations for cargo compiler output. +//! +//! Uses Aho-Corasick algorithm for O(n+m) multi-pattern replacement. + +use std::sync::LazyLock; + +use aho_corasick::AhoCorasick; + +use super::Lang; + +/// Russian phrase translations (sorted alphabetically by English key). +#[cfg(feature = "lang-ru")] +static PHRASES_RU: &[(&str, &str)] = &[ + ("aborting due to", "прерывание из-за"), + ( + "as mutable because it is also borrowed as immutable", + "как изменяемое, т.к. уже заимствовано как неизменяемое" + ), + ( + "as mutable more than once at a time", + "как изменяемое больше одного раза одновременно" + ), + ( + "borrow of moved value", + "заимствование перемещённого значения" + ), + ( + "borrowed value does not live long enough", + "заимствованное значение живёт недостаточно долго" + ), + ("cannot borrow", "нельзя заимствовать"), + ("consider", "рассмотрите"), + ( + "consider cloning the value if the performance cost is acceptable", + "рассмотрите клонирование значения, если допустима потеря производительности" + ), + ("could not compile", "не удалось скомпилировать"), + ("does not live long enough", "живёт недостаточно долго"), + ( + "dropped here while still borrowed", + "удалено здесь, пока ещё заимствовано" + ), + ("due to", "из-за"), + ("error", "ошибка"), + ("expected", "ожидается"), + ( + "expected named lifetime parameter", + "ожидается именованный параметр времени жизни" + ), + ("expected type", "ожидаемый тип"), + ( + "first borrow later used here", + "первое заимствование используется здесь" + ), + ( + "first mutable borrow occurs here", + "первое изменяемое заимствование здесь" + ), + ( + "for more info about this issue", + "для информации об этой ошибке" + ), + ("found", "найдено"), + ("found type", "найденный тип"), + ("has type", "имеет тип"), + ("help", "подсказка"), + ( + "immutable borrow later used here", + "неизменяемое заимствование используется здесь" + ), + ( + "immutable borrow occurs here", + "неизменяемое заимствование здесь" + ), + ("mismatched types", "несовпадение типов"), + ( + "missing lifetime specifier", + "отсутствует спецификатор времени жизни" + ), + ("move occurs because", "перемещение происходит потому что"), + ( + "mutable borrow occurs here", + "изменяемое заимствование здесь" + ), + ("note", "примечание"), + ("previous error", "предыдущей ошибки"), + ("previous errors", "предыдущих ошибок"), + ("run with", "запустите с"), + ( + "second mutable borrow occurs here", + "второе изменяемое заимствование здесь" + ), + ( + "this error originates in the macro", + "эта ошибка возникла в макросе" + ), + ("this expression has type", "это выражение имеет тип"), + ( + "value borrowed here after move", + "значение заимствовано здесь после перемещения" + ), + ("value moved here", "значение перемещено здесь"), + ("warning", "предупреждение"), + ( + "which does not implement the `Copy` trait", + "который не реализует трейт `Copy`" + ) +]; + +/// Korean phrase translations (sorted alphabetically by English key). +#[cfg(feature = "lang-ko")] +static PHRASES_KO: &[(&str, &str)] = &[ + ("borrow of moved value", "이동된 값의 빌림"), + ("cannot borrow", "빌릴 수 없습니다"), + ("error", "에러"), + ("help", "도움말"), + ("mismatched types", "타입 불일치"), + ("note", "참고"), + ("warning", "경고") +]; + +/// Pre-built Aho-Corasick automaton for Russian translations. +/// +/// Patterns are sorted by length (longest first) to ensure correct replacement +/// when shorter patterns are substrings of longer ones. +#[cfg(feature = "lang-ru")] +static AC_RU: LazyLock<(AhoCorasick, Vec<&'static str>)> = LazyLock::new(|| { + let mut sorted: Vec<_> = PHRASES_RU.iter().collect(); + sorted.sort_by(|a, b| b.0.len().cmp(&a.0.len())); + + let patterns: Vec<_> = sorted.iter().map(|(k, _)| *k).collect(); + let replacements: Vec<_> = sorted.iter().map(|(_, v)| *v).collect(); + + let ac = AhoCorasick::new(&patterns).expect("valid patterns"); + (ac, replacements) +}); + +/// Pre-built Aho-Corasick automaton for Korean translations. +#[cfg(feature = "lang-ko")] +static AC_KO: LazyLock<(AhoCorasick, Vec<&'static str>)> = LazyLock::new(|| { + let mut sorted: Vec<_> = PHRASES_KO.iter().collect(); + sorted.sort_by(|a, b| b.0.len().cmp(&a.0.len())); + + let patterns: Vec<_> = sorted.iter().map(|(k, _)| *k).collect(); + let replacements: Vec<_> = sorted.iter().map(|(_, v)| *v).collect(); + + let ac = AhoCorasick::new(&patterns).expect("valid patterns"); + (ac, replacements) +}); + +/// Translate a phrase to the target language. +/// +/// Returns `None` if no translation exists or language is English. +pub fn translate_phrase(phrase: &str, lang: Lang) -> Option<&'static str> { + let phrases: &[(&str, &str)] = match lang { + Lang::En => return None, + #[cfg(feature = "lang-ru")] + Lang::Ru => PHRASES_RU, + #[cfg(feature = "lang-ko")] + Lang::Ko => PHRASES_KO, + #[allow(unreachable_patterns)] + _ => return None + }; + + phrases + .binary_search_by_key(&phrase, |(k, _)| *k) + .ok() + .map(|i| phrases[i].1) +} + +/// Translate full rendered compiler output. +/// +/// Uses pre-built Aho-Corasick automaton for O(n+m) replacement +/// instead of O(n*m) naive string replacement. +pub fn translate_rendered(rendered: &str, lang: Lang) -> String { + match lang { + Lang::En => rendered.to_string(), + #[cfg(feature = "lang-ru")] + Lang::Ru => { + let (ac, replacements) = &*AC_RU; + ac.replace_all(rendered, replacements) + } + #[cfg(feature = "lang-ko")] + Lang::Ko => { + let (ac, replacements) = &*AC_KO; + ac.replace_all(rendered, replacements) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(feature = "lang-ru")] + fn test_translate_phrase_ru() { + assert_eq!( + translate_phrase("borrow of moved value", Lang::Ru), + Some("заимствование перемещённого значения") + ); + assert_eq!(translate_phrase("unknown phrase", Lang::Ru), None); + } + + #[test] + fn test_translate_phrase_en() { + assert_eq!(translate_phrase("borrow of moved value", Lang::En), None); + } + + #[test] + #[cfg(feature = "lang-ru")] + fn test_phrases_sorted() { + for window in PHRASES_RU.windows(2) { + assert!( + window[0].0 < window[1].0, + "Phrases not sorted: {:?} should come before {:?}", + window[1].0, + window[0].0 + ); + } + } + + #[test] + #[cfg(feature = "lang-ru")] + fn test_translate_rendered_ru() { + let input = "error: borrow of moved value"; + let output = translate_rendered(input, Lang::Ru); + assert_eq!(output, "ошибка: заимствование перемещённого значения"); + } + + #[test] + fn test_translate_rendered_en_passthrough() { + let input = "error: borrow of moved value"; + let output = translate_rendered(input, Lang::En); + assert_eq!(output, input); + } +} diff --git a/masterror-knowledge/src/lib.rs b/masterror-knowledge/src/lib.rs new file mode 100644 index 0000000..3ac7174 --- /dev/null +++ b/masterror-knowledge/src/lib.rs @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Knowledge base for Rust compiler errors and best practices. +//! +//! This crate provides structured explanations for Rust compiler errors +//! with translations (en/ru/ko) and actionable fix suggestions. +//! +//! # Example +//! +//! ```rust,ignore +//! use masterror_knowledge::{ErrorRegistry, PracticeRegistry}; +//! +//! let registry = ErrorRegistry::new(); +//! if let Some(entry) = registry.find("E0502") { +//! println!("Error: {}", entry.title.en); +//! println!("Explanation: {}", entry.explanation.en); +//! } +//! +//! let practices = PracticeRegistry::new(); +//! if let Some(practice) = practices.find("RA001") { +//! println!("Practice: {}", practice.title.en); +//! } +//! ``` + +pub mod errors; +pub mod i18n; + +pub use errors::{ + Category, DocLink, ErrorEntry, ErrorRegistry, FixSuggestion, LocalizedText, + raprogramm::{BestPractice, PracticeCategory, PracticeRegistry} +}; +pub use i18n::{Lang, messages::UiMsg, phrases}; diff --git a/masterror-rustc/Cargo.toml b/masterror-rustc/Cargo.toml new file mode 100644 index 0000000..2d2665f --- /dev/null +++ b/masterror-rustc/Cargo.toml @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2025-2026 RAprogramm +# +# SPDX-License-Identifier: MIT + +[package] +name = "masterror-rustc" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "RUSTC_WRAPPER that adds translated explanations to Rust compiler errors" +keywords = ["rust", "compiler", "errors", "translation", "rustc"] +categories = ["command-line-utilities", "development-tools"] + +[[bin]] +name = "masterror-rustc" +path = "src/main.rs" + +[dependencies] +masterror-knowledge = { version = "0.1", path = "../masterror-knowledge", features = ["lang-ru", "lang-ko"] } +owo-colors = { version = "4", features = ["supports-colors"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_System_Console"] } diff --git a/masterror-rustc/src/main.rs b/masterror-rustc/src/main.rs new file mode 100644 index 0000000..5cec597 --- /dev/null +++ b/masterror-rustc/src/main.rs @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! RUSTC_WRAPPER that adds translated explanations to Rust compiler errors. +//! +//! # Usage +//! +//! Add to `.cargo/config.toml`: +//! ```toml +//! [build] +//! rustc-wrapper = "masterror-rustc" +//! ``` +//! +//! Or set environment variable: +//! ```bash +//! export RUSTC_WRAPPER=masterror-rustc +//! ``` +//! +//! Then use cargo as usual: +//! ```bash +//! cargo build +//! cargo run +//! cargo test +//! ``` + +use std::{ + env, + io::{BufRead, BufReader, Write}, + process::{Command, Stdio, exit} +}; + +use masterror_knowledge::{ErrorRegistry, Lang}; +use owo_colors::OwoColorize; + +fn main() { + let args: Vec = env::args().skip(1).collect(); + + if args.is_empty() { + eprintln!("masterror-rustc: no arguments provided"); + eprintln!("This is a RUSTC_WRAPPER, not meant to be called directly."); + eprintln!(); + eprintln!("Usage: Add to .cargo/config.toml:"); + eprintln!(" [build]"); + eprintln!(" rustc-wrapper = \"masterror-rustc\""); + exit(1); + } + + // Cargo passes: $RUSTC_WRAPPER $RUSTC + // First argument is the path to rustc, rest are rustc arguments + let rustc = &args[0]; + let rustc_args = &args[1..]; + + let lang = detect_lang(); + let colored = supports_color(); + let registry = ErrorRegistry::new(); + + let mut child = Command::new(rustc) + .args(rustc_args) + .stdout(Stdio::inherit()) + .stderr(Stdio::piped()) + .spawn() + .unwrap_or_else(|e| { + eprintln!("masterror-rustc: failed to run rustc: {e}"); + exit(1); + }); + + let stderr = child.stderr.take().expect("failed to capture stderr"); + let reader = BufReader::new(stderr); + + let mut stderr_handle = std::io::stderr().lock(); + + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(e) => { + eprintln!("masterror-rustc: read error: {e}"); + continue; + } + }; + + // Output original line + let _ = writeln!(stderr_handle, "{line}"); + + // Check for error code pattern: error[E0308] + if let Some(code) = extract_error_code(&line) + && let Some(explanation) = get_explanation(registry, &code, lang, colored) + { + let _ = writeln!(stderr_handle, "{explanation}"); + } + } + + drop(stderr_handle); + + let status = child.wait().unwrap_or_else(|e| { + eprintln!("masterror-rustc: failed to wait for rustc: {e}"); + exit(1); + }); + + exit(status.code().unwrap_or(1)); +} + +/// Extract error code from line like "error[E0308]: mismatched types" +fn extract_error_code(line: &str) -> Option { + let start = line.find("error[E")?; + let code_start = start + 6; // skip "error[" + let end = line[code_start..].find(']')?; + Some(line[code_start..code_start + end].to_string()) +} + +/// Get translated explanation for error code +fn get_explanation( + registry: &'static ErrorRegistry, + code: &str, + lang: Lang, + colored: bool +) -> Option { + let entry = registry.find(code)?; + let lang_code = lang.code(); + let mut output = String::new(); + + // Header + let title = entry.title.get(lang_code); + if colored { + output.push_str(&format!("\n {} {}\n", "💡".bold(), title.bold().cyan())); + } else { + output.push_str(&format!("\n 💡 {title}\n")); + } + + // Description + let desc = entry.explanation.get(lang_code); + if !desc.is_empty() { + for line in desc.lines() { + output.push_str(&format!(" {line}\n")); + } + } + + // Fix suggestions + if !entry.fixes.is_empty() { + output.push('\n'); + let fix_header = match lang.code() { + "ru" => "Как исправить:", + "ko" => "해결 방법:", + _ => "How to fix:" + }; + if colored { + output.push_str(&format!(" {}\n", fix_header.bold().green())); + } else { + output.push_str(&format!(" {fix_header}\n")); + } + for fix in entry.fixes { + let fix_desc = fix.description.get(lang_code); + output.push_str(&format!(" • {fix_desc}\n")); + } + } + + Some(output) +} + +/// Detect language from environment +fn detect_lang() -> Lang { + if let Ok(lang) = env::var("MASTERROR_LANG") { + return Lang::from_code(&lang); + } + + if let Ok(lang) = env::var("LANG") { + if lang.starts_with("ru") { + return Lang::from_code("ru"); + } + if lang.starts_with("ko") { + return Lang::from_code("ko"); + } + } + + Lang::En +} + +/// Check if terminal supports colors +fn supports_color() -> bool { + if env::var("NO_COLOR").is_ok() { + return false; + } + if env::var("CLICOLOR_FORCE").is_ok() { + return true; + } + is_stderr_tty() +} + +/// Check if stderr is a TTY +#[cfg(unix)] +fn is_stderr_tty() -> bool { + unsafe { libc::isatty(libc::STDERR_FILENO) != 0 } +} + +#[cfg(not(unix))] +fn is_stderr_tty() -> bool { + false +} diff --git a/pkg/aur/.SRCINFO b/pkg/aur/.SRCINFO new file mode 100644 index 0000000..fe1f3cd --- /dev/null +++ b/pkg/aur/.SRCINFO @@ -0,0 +1,14 @@ +pkgbase = masterror + pkgdesc = CLI tool for explaining Rust compiler errors in human-friendly language + pkgver = 0.1.0 + pkgrel = 1 + url = https://github.com/RAprogramm/masterror + arch = x86_64 + arch = aarch64 + license = MIT + makedepends = cargo + depends = gcc-libs + source = masterror-0.1.0.tar.gz::https://github.com/RAprogramm/masterror/archive/v0.1.0.tar.gz + sha256sums = SKIP + +pkgname = masterror diff --git a/pkg/aur/PKGBUILD b/pkg/aur/PKGBUILD new file mode 100644 index 0000000..0fbc002 --- /dev/null +++ b/pkg/aur/PKGBUILD @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: 2025-2026 RAprogramm +# +# SPDX-License-Identifier: MIT + +# Maintainer: RAprogramm +pkgname=masterror +pkgver=0.1.0 +pkgrel=1 +pkgdesc="CLI tool for explaining Rust compiler errors in human-friendly language" +arch=('x86_64' 'aarch64') +url="https://github.com/RAprogramm/masterror" +license=('MIT') +depends=('gcc-libs') +makedepends=('cargo') +source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz") +sha256sums=('SKIP') + +prepare() { + cd "$pkgname-$pkgver" + export RUSTUP_TOOLCHAIN=stable + cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" +} + +build() { + cd "$pkgname-$pkgver/masterror-cli" + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR=target + cargo build --frozen --release --all-features +} + +check() { + cd "$pkgname-$pkgver/masterror-cli" + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR=target + cargo test --frozen --release +} + +package() { + cd "$pkgname-$pkgver" + install -Dm755 "masterror-cli/target/release/masterror" "$pkgdir/usr/bin/masterror" + install -Dm755 "masterror-cli/target/release/cargo-masterror" "$pkgdir/usr/bin/cargo-masterror" + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" + install -Dm644 masterror-cli/README.md "$pkgdir/usr/share/doc/$pkgname/README.md" +} diff --git a/src/app_error.rs b/src/app_error.rs index fffd18a..87e20f6 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 RAprogramm +// SPDX-FileCopyrightText: 2025-2026 RAprogramm // // SPDX-License-Identifier: MIT @@ -69,6 +69,7 @@ mod constructors; mod context; mod core; +pub mod diagnostics; mod inline_vec; mod metadata; @@ -77,6 +78,7 @@ pub use core::{AppError, AppResult, DisplayMode, Error, ErrorChain, MessageEditP pub(crate) use core::{reset_backtrace_preference, set_backtrace_preference_override}; pub use context::Context; +pub use diagnostics::{DiagnosticVisibility, Diagnostics, DocLink, Hint, Suggestion}; pub(crate) use metadata::duration_to_string; pub use metadata::{Field, FieldRedaction, FieldValue, Metadata, field}; diff --git a/src/app_error/core/builder.rs b/src/app_error/core/builder.rs deleted file mode 100644 index 2af4589..0000000 --- a/src/app_error/core/builder.rs +++ /dev/null @@ -1,420 +0,0 @@ -// SPDX-FileCopyrightText: 2025 RAprogramm -// -// SPDX-License-Identifier: MIT - -use alloc::{borrow::Cow, string::String, sync::Arc}; -use core::error::Error as CoreError; -#[cfg(feature = "backtrace")] -use std::backtrace::Backtrace; - -#[cfg(feature = "serde_json")] -use serde::Serialize; -#[cfg(feature = "serde_json")] -use serde_json::{Value as JsonValue, to_value}; - -use super::{ - error::Error, - types::{CapturedBacktrace, ContextAttachment, MessageEditPolicy} -}; -use crate::{ - AppCode, AppErrorKind, RetryAdvice, - app_error::metadata::{Field, FieldRedaction, Metadata} -}; - -impl Error { - /// Create a new [`Error`] with a kind and message. - /// - /// This is equivalent to [`Error::with`], provided for API symmetry and to - /// keep doctests readable. - /// - /// # Examples - /// - /// ```rust - /// use masterror::{AppError, AppErrorKind}; - /// let err = AppError::new(AppErrorKind::BadRequest, "invalid payload"); - /// assert!(err.message.is_some()); - /// ``` - #[must_use] - pub fn new(kind: AppErrorKind, msg: impl Into>) -> Self { - Self::with(kind, msg) - } - - /// Create an error with the given kind and message. - /// - /// Prefer named helpers (e.g. [`Error::not_found`]) where it clarifies - /// intent. - /// - /// # Examples - /// - /// ```rust - /// use masterror::{AppError, AppErrorKind}; - /// let err = AppError::with(AppErrorKind::Validation, "bad input"); - /// assert_eq!(err.kind, AppErrorKind::Validation); - /// ``` - #[must_use] - pub fn with(kind: AppErrorKind, msg: impl Into>) -> Self { - let err = Self::new_raw(kind, Some(msg.into())); - err.emit_telemetry(); - err - } - - /// Create a message-less error with the given kind. - /// - /// Useful when the kind alone conveys sufficient information to the client. - /// - /// # Examples - /// - /// ```rust - /// use masterror::{AppError, AppErrorKind}; - /// let err = AppError::bare(AppErrorKind::NotFound); - /// assert!(err.message.is_none()); - /// ``` - #[must_use] - pub fn bare(kind: AppErrorKind) -> Self { - let err = Self::new_raw(kind, None); - err.emit_telemetry(); - err - } - - /// Override the machine-readable [`AppCode`]. - /// - /// # Examples - /// - /// ```rust - /// use masterror::{AppCode, AppError, AppErrorKind}; - /// let err = AppError::new(AppErrorKind::BadRequest, "test").with_code(AppCode::NotFound); - /// assert_eq!(err.code, AppCode::NotFound); - /// ``` - #[must_use] - pub fn with_code(mut self, code: AppCode) -> Self { - self.code = code; - self.mark_dirty(); - self - } - - /// Attach retry advice to the error. - /// - /// When mapped to HTTP, this becomes the `Retry-After` header. - /// - /// # Examples - /// - /// ```rust - /// use masterror::{AppError, AppErrorKind}; - /// let err = AppError::new(AppErrorKind::RateLimited, "slow down").with_retry_after_secs(60); - /// assert_eq!(err.retry.map(|r| r.after_seconds), Some(60)); - /// ``` - #[must_use] - pub fn with_retry_after_secs(mut self, secs: u64) -> Self { - self.retry = Some(RetryAdvice { - after_seconds: secs - }); - self.mark_dirty(); - self - } - - /// Attach a `WWW-Authenticate` challenge string. - /// - /// # Examples - /// - /// ```rust - /// use masterror::{AppError, AppErrorKind}; - /// let err = AppError::new(AppErrorKind::Unauthorized, "auth required") - /// .with_www_authenticate("Bearer realm=\"api\""); - /// assert!(err.www_authenticate.is_some()); - /// ``` - #[must_use] - pub fn with_www_authenticate(mut self, value: impl Into) -> Self { - self.www_authenticate = Some(value.into()); - self.mark_dirty(); - self - } - - /// Attach additional metadata to the error. - /// - /// # Examples - /// - /// ```rust - /// use masterror::{AppError, AppErrorKind, field}; - /// let err = AppError::new(AppErrorKind::Validation, "bad field") - /// .with_field(field::str("field_name", "email")); - /// assert!(err.metadata().get("field_name").is_some()); - /// ``` - #[must_use] - pub fn with_field(mut self, field: Field) -> Self { - self.metadata.insert(field); - self.mark_dirty(); - self - } - - /// Extend metadata from an iterator of fields. - /// - /// # Examples - /// - /// ```rust - /// use masterror::{AppError, AppErrorKind, field}; - /// let fields = vec![field::str("key1", "value1"), field::str("key2", "value2")]; - /// let err = AppError::new(AppErrorKind::BadRequest, "test").with_fields(fields); - /// assert!(err.metadata().get("key1").is_some()); - /// ``` - #[must_use] - pub fn with_fields(mut self, fields: impl IntoIterator) -> Self { - self.metadata.extend(fields); - self.mark_dirty(); - self - } - - /// Override the redaction policy for a stored metadata field. - /// - /// # Examples - /// - /// ```rust - /// use masterror::{AppError, AppErrorKind, FieldRedaction, field}; - /// - /// let err = AppError::new(AppErrorKind::Internal, "test") - /// .with_field(field::str("password", "secret")) - /// .redact_field("password", FieldRedaction::Redact); - /// ``` - #[must_use] - pub fn redact_field(mut self, name: &'static str, redaction: FieldRedaction) -> Self { - self.metadata.set_redaction(name, redaction); - self.mark_dirty(); - self - } - - /// Replace metadata entirely. - /// - /// # Examples - /// - /// ```rust - /// use masterror::{AppError, AppErrorKind, Metadata}; - /// - /// let metadata = Metadata::new(); - /// let err = AppError::new(AppErrorKind::Internal, "test").with_metadata(metadata); - /// ``` - #[must_use] - pub fn with_metadata(mut self, metadata: Metadata) -> Self { - self.metadata = metadata; - self.mark_dirty(); - self - } - - /// Mark the message as redactable. - /// - /// # Examples - /// - /// ```rust - /// use masterror::{AppError, AppErrorKind, MessageEditPolicy}; - /// - /// let err = AppError::new(AppErrorKind::Internal, "secret").redactable(); - /// assert_eq!(err.edit_policy, MessageEditPolicy::Redact); - /// ``` - #[must_use] - pub fn redactable(mut self) -> Self { - self.edit_policy = MessageEditPolicy::Redact; - self.mark_dirty(); - self - } - - /// Attach upstream diagnostics using [`with_source`](Self::with_source) or - /// an existing [`Arc`]. - /// - /// This is the preferred alias for capturing upstream errors. It accepts - /// either an owned error implementing [`core::error::Error`] or a - /// shared [`Arc`] produced by other APIs, reusing the allocation when - /// possible. - /// - /// # Examples - /// - /// ```rust - /// # #[cfg(feature = "std")] { - /// use masterror::AppError; - /// - /// let err = AppError::service("downstream degraded") - /// .with_context(std::io::Error::new(std::io::ErrorKind::Other, "boom")); - /// assert!(err.source_ref().is_some()); - /// # } - /// ``` - #[must_use] - pub fn with_context(self, context: impl Into) -> Self { - match context.into() { - ContextAttachment::Owned(source) => { - match source.downcast::>() { - Ok(shared) => self.with_source_arc(*shared), - Err(source) => self.with_source_arc(Arc::from(source)) - } - } - ContextAttachment::Shared(source) => self.with_source_arc(source) - } - } - - /// Attach a source error for diagnostics. - /// - /// Prefer [`with_context`](Self::with_context) when capturing upstream - /// diagnostics without additional `Arc` allocations. - /// - /// # Examples - /// - /// ```rust - /// # #[cfg(feature = "std")] { - /// use masterror::{AppError, AppErrorKind}; - /// - /// let io_err = std::io::Error::new(std::io::ErrorKind::Other, "boom"); - /// let err = AppError::internal("boom").with_source(io_err); - /// assert!(err.source_ref().is_some()); - /// # } - /// ``` - #[must_use] - pub fn with_source(mut self, source: impl CoreError + Send + Sync + 'static) -> Self { - self.source = Some(Arc::new(source)); - self.mark_dirty(); - self - } - - /// Attach a shared source error without cloning the underlying `Arc`. - /// - /// # Examples - /// - /// ```rust - /// # #[cfg(feature = "std")] { - /// use std::sync::Arc; - /// - /// use masterror::{AppError, AppErrorKind}; - /// - /// let source = Arc::new(std::io::Error::new(std::io::ErrorKind::Other, "boom")); - /// let err = AppError::internal("boom").with_source_arc(source.clone()); - /// assert!(err.source_ref().is_some()); - /// assert_eq!(Arc::strong_count(&source), 2); - /// # } - /// ``` - #[must_use] - pub fn with_source_arc(mut self, source: Arc) -> Self { - self.source = Some(source); - self.mark_dirty(); - self - } - - /// Attach a captured backtrace. - /// - /// # Examples - /// - /// ```rust - /// # #[cfg(feature = "backtrace")] - /// # { - /// use std::backtrace::Backtrace; - /// - /// use masterror::AppError; - /// - /// let bt = Backtrace::capture(); - /// let err = AppError::internal("test").with_backtrace(bt); - /// # } - /// ``` - #[must_use] - pub fn with_backtrace(mut self, backtrace: CapturedBacktrace) -> Self { - #[cfg(feature = "backtrace")] - { - self.set_backtrace_slot(Arc::new(backtrace)); - } - #[cfg(not(feature = "backtrace"))] - { - self.set_backtrace_slot(backtrace); - } - self.mark_dirty(); - self - } - - /// Attach a shared backtrace without cloning. - /// - /// Internal method for sharing backtraces between errors. - #[cfg(feature = "backtrace")] - pub(crate) fn with_shared_backtrace(mut self, backtrace: Arc) -> Self { - self.set_backtrace_slot(backtrace); - self.mark_dirty(); - self - } - - /// Attach structured JSON details for the client payload. - /// - /// The details are omitted from responses when the error has been marked as - /// [`redactable`](Self::redactable). - /// - /// # Examples - /// - /// ```rust - /// # #[cfg(feature = "serde_json")] - /// # { - /// use masterror::{AppError, AppErrorKind}; - /// use serde_json::json; - /// - /// let err = AppError::new(AppErrorKind::Validation, "invalid input") - /// .with_details_json(json!({"field": "email"})); - /// assert!(err.details.is_some()); - /// # } - /// ``` - #[must_use] - #[cfg(feature = "serde_json")] - pub fn with_details_json(mut self, details: JsonValue) -> Self { - self.details = Some(details); - self.mark_dirty(); - self - } - - /// Serialize and attach structured details. - /// - /// Returns [`crate::AppError`] with [`crate::AppErrorKind::BadRequest`] if - /// serialization fails. - /// - /// # Examples - /// - /// ```rust - /// # #[cfg(feature = "serde_json")] - /// # { - /// use masterror::{AppError, AppErrorKind}; - /// use serde::Serialize; - /// - /// #[derive(Serialize)] - /// struct Extra { - /// reason: &'static str - /// } - /// - /// let err = AppError::new(AppErrorKind::BadRequest, "invalid") - /// .with_details(Extra { - /// reason: "missing" - /// }) - /// .expect("details should serialize"); - /// assert!(err.details.is_some()); - /// # } - /// ``` - #[cfg(feature = "serde_json")] - #[allow(clippy::result_large_err)] - pub fn with_details(self, payload: T) -> crate::AppResult - where - T: Serialize - { - let details = to_value(payload).map_err(|err| Self::bad_request(err.to_string()))?; - Ok(self.with_details_json(details)) - } - - /// Attach plain-text details for client payloads. - /// - /// The text is omitted from responses when the error is - /// [`redactable`](Self::redactable). - /// - /// # Examples - /// - /// ```rust - /// # #[cfg(not(feature = "serde_json"))] - /// # { - /// use masterror::{AppError, AppErrorKind}; - /// - /// let err = AppError::new(AppErrorKind::Internal, "boom").with_details_text("retry later"); - /// assert!(err.details.is_some()); - /// # } - /// ``` - #[must_use] - #[cfg(not(feature = "serde_json"))] - pub fn with_details_text(mut self, details: impl Into) -> Self { - self.details = Some(details.into()); - self.mark_dirty(); - self - } -} diff --git a/src/app_error/core/builder/constructors.rs b/src/app_error/core/builder/constructors.rs new file mode 100644 index 0000000..93328bf --- /dev/null +++ b/src/app_error/core/builder/constructors.rs @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Core constructors for creating `AppError` instances. + +use alloc::borrow::Cow; + +use crate::{AppErrorKind, app_error::core::error::Error}; + +impl Error { + /// Create a new [`Error`] with a kind and message. + /// + /// This is equivalent to [`Error::with`], provided for API symmetry and to + /// keep doctests readable. + /// + /// # Examples + /// + /// ```rust + /// use masterror::{AppError, AppErrorKind}; + /// let err = AppError::new(AppErrorKind::BadRequest, "invalid payload"); + /// assert!(err.message.is_some()); + /// ``` + #[must_use] + pub fn new(kind: AppErrorKind, msg: impl Into>) -> Self { + Self::with(kind, msg) + } + + /// Create an error with the given kind and message. + /// + /// Prefer named helpers (e.g. [`Error::not_found`]) where it clarifies + /// intent. + /// + /// # Examples + /// + /// ```rust + /// use masterror::{AppError, AppErrorKind}; + /// let err = AppError::with(AppErrorKind::Validation, "bad input"); + /// assert_eq!(err.kind, AppErrorKind::Validation); + /// ``` + #[must_use] + pub fn with(kind: AppErrorKind, msg: impl Into>) -> Self { + let err = Self::new_raw(kind, Some(msg.into())); + err.emit_telemetry(); + err + } + + /// Create a message-less error with the given kind. + /// + /// Useful when the kind alone conveys sufficient information to the client. + /// + /// # Examples + /// + /// ```rust + /// use masterror::{AppError, AppErrorKind}; + /// let err = AppError::bare(AppErrorKind::NotFound); + /// assert!(err.message.is_none()); + /// ``` + #[must_use] + pub fn bare(kind: AppErrorKind) -> Self { + let err = Self::new_raw(kind, None); + err.emit_telemetry(); + err + } +} diff --git a/src/app_error/core/builder/context.rs b/src/app_error/core/builder/context.rs new file mode 100644 index 0000000..654fe8d --- /dev/null +++ b/src/app_error/core/builder/context.rs @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Source and context attachment methods for `AppError`. + +use alloc::sync::Arc; +use core::error::Error as CoreError; +#[cfg(feature = "backtrace")] +use std::backtrace::Backtrace; + +use crate::app_error::core::{ + error::Error, + types::{CapturedBacktrace, ContextAttachment} +}; + +impl Error { + /// Attach upstream diagnostics using [`with_source`](Self::with_source) or + /// an existing [`Arc`]. + /// + /// This is the preferred alias for capturing upstream errors. It accepts + /// either an owned error implementing [`core::error::Error`] or a + /// shared [`Arc`] produced by other APIs, reusing the allocation when + /// possible. + /// + /// # Examples + /// + /// ```rust + /// # #[cfg(feature = "std")] { + /// use masterror::AppError; + /// + /// let err = AppError::service("downstream degraded") + /// .with_context(std::io::Error::new(std::io::ErrorKind::Other, "boom")); + /// assert!(err.source_ref().is_some()); + /// # } + /// ``` + #[must_use] + pub fn with_context(self, context: impl Into) -> Self { + match context.into() { + ContextAttachment::Owned(source) => { + match source.downcast::>() { + Ok(shared) => self.with_source_arc(*shared), + Err(source) => self.with_source_arc(Arc::from(source)) + } + } + ContextAttachment::Shared(source) => self.with_source_arc(source) + } + } + + /// Attach a source error for diagnostics. + /// + /// Prefer [`with_context`](Self::with_context) when capturing upstream + /// diagnostics without additional `Arc` allocations. + /// + /// # Examples + /// + /// ```rust + /// # #[cfg(feature = "std")] { + /// use masterror::{AppError, AppErrorKind}; + /// + /// let io_err = std::io::Error::new(std::io::ErrorKind::Other, "boom"); + /// let err = AppError::internal("boom").with_source(io_err); + /// assert!(err.source_ref().is_some()); + /// # } + /// ``` + #[must_use] + pub fn with_source(mut self, source: impl CoreError + Send + Sync + 'static) -> Self { + self.source = Some(Arc::new(source)); + self.mark_dirty(); + self + } + + /// Attach a shared source error without cloning the underlying `Arc`. + /// + /// # Examples + /// + /// ```rust + /// # #[cfg(feature = "std")] { + /// use std::sync::Arc; + /// + /// use masterror::{AppError, AppErrorKind}; + /// + /// let source = Arc::new(std::io::Error::new(std::io::ErrorKind::Other, "boom")); + /// let err = AppError::internal("boom").with_source_arc(source.clone()); + /// assert!(err.source_ref().is_some()); + /// assert_eq!(Arc::strong_count(&source), 2); + /// # } + /// ``` + #[must_use] + pub fn with_source_arc(mut self, source: Arc) -> Self { + self.source = Some(source); + self.mark_dirty(); + self + } + + /// Attach a captured backtrace. + /// + /// # Examples + /// + /// ```rust + /// # #[cfg(feature = "backtrace")] + /// # { + /// use std::backtrace::Backtrace; + /// + /// use masterror::AppError; + /// + /// let bt = Backtrace::capture(); + /// let err = AppError::internal("test").with_backtrace(bt); + /// # } + /// ``` + #[must_use] + pub fn with_backtrace(mut self, backtrace: CapturedBacktrace) -> Self { + #[cfg(feature = "backtrace")] + { + self.set_backtrace_slot(Arc::new(backtrace)); + } + #[cfg(not(feature = "backtrace"))] + { + self.set_backtrace_slot(backtrace); + } + self.mark_dirty(); + self + } + + /// Attach a shared backtrace without cloning. + /// + /// Internal method for sharing backtraces between errors. + #[cfg(feature = "backtrace")] + pub(crate) fn with_shared_backtrace(mut self, backtrace: Arc) -> Self { + self.set_backtrace_slot(backtrace); + self.mark_dirty(); + self + } +} diff --git a/src/app_error/core/builder/details.rs b/src/app_error/core/builder/details.rs new file mode 100644 index 0000000..1e63011 --- /dev/null +++ b/src/app_error/core/builder/details.rs @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Details attachment methods for `AppError`. + +#[cfg(not(feature = "serde_json"))] +use alloc::string::String; + +#[cfg(feature = "serde_json")] +use serde::Serialize; +#[cfg(feature = "serde_json")] +use serde_json::{Value as JsonValue, to_value}; + +use crate::app_error::core::error::Error; + +impl Error { + /// Attach structured JSON details for the client payload. + /// + /// The details are omitted from responses when the error has been marked as + /// [`redactable`](Self::redactable). + /// + /// # Examples + /// + /// ```rust + /// # #[cfg(feature = "serde_json")] + /// # { + /// use masterror::{AppError, AppErrorKind}; + /// use serde_json::json; + /// + /// let err = AppError::new(AppErrorKind::Validation, "invalid input") + /// .with_details_json(json!({"field": "email"})); + /// assert!(err.details.is_some()); + /// # } + /// ``` + #[must_use] + #[cfg(feature = "serde_json")] + pub fn with_details_json(mut self, details: JsonValue) -> Self { + self.details = Some(details); + self.mark_dirty(); + self + } + + /// Serialize and attach structured details. + /// + /// Returns [`crate::AppError`] with [`crate::AppErrorKind::BadRequest`] if + /// serialization fails. + /// + /// # Examples + /// + /// ```rust + /// # #[cfg(feature = "serde_json")] + /// # { + /// use masterror::{AppError, AppErrorKind}; + /// use serde::Serialize; + /// + /// #[derive(Serialize)] + /// struct Extra { + /// reason: &'static str + /// } + /// + /// let err = AppError::new(AppErrorKind::BadRequest, "invalid") + /// .with_details(Extra { + /// reason: "missing" + /// }) + /// .expect("details should serialize"); + /// assert!(err.details.is_some()); + /// # } + /// ``` + #[cfg(feature = "serde_json")] + #[allow(clippy::result_large_err)] + pub fn with_details(self, payload: T) -> crate::AppResult + where + T: Serialize + { + let details = to_value(payload).map_err(|err| Self::bad_request(err.to_string()))?; + Ok(self.with_details_json(details)) + } + + /// Attach plain-text details for client payloads. + /// + /// The text is omitted from responses when the error is + /// [`redactable`](Self::redactable). + /// + /// # Examples + /// + /// ```rust + /// # #[cfg(not(feature = "serde_json"))] + /// # { + /// use masterror::{AppError, AppErrorKind}; + /// + /// let err = AppError::new(AppErrorKind::Internal, "boom").with_details_text("retry later"); + /// assert!(err.details.is_some()); + /// # } + /// ``` + #[must_use] + #[cfg(not(feature = "serde_json"))] + pub fn with_details_text(mut self, details: impl Into) -> Self { + self.details = Some(details.into()); + self.mark_dirty(); + self + } +} diff --git a/src/app_error/core/builder/diagnostics.rs b/src/app_error/core/builder/diagnostics.rs new file mode 100644 index 0000000..136388a --- /dev/null +++ b/src/app_error/core/builder/diagnostics.rs @@ -0,0 +1,197 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Diagnostics builder methods for `AppError`. +//! +//! Provides methods to attach hints, suggestions, documentation links, +//! and related error codes to errors. + +use alloc::{borrow::Cow, boxed::Box}; + +use crate::app_error::{ + core::error::Error, + diagnostics::{DiagnosticVisibility, Diagnostics, DocLink, Hint, Suggestion} +}; + +impl Error { + /// Adds a development-only hint to explain the error. + /// + /// Hints provide context about why an error occurred without necessarily + /// offering a solution. They are only shown in Local (development) mode. + /// + /// # Examples + /// + /// ```rust + /// use masterror::AppError; + /// + /// let err = AppError::not_found("User not found") + /// .with_hint("Check if the user ID is correct") + /// .with_hint("User might have been deleted"); + /// ``` + #[must_use] + pub fn with_hint(mut self, message: impl Into>) -> Self { + self.ensure_diagnostics().hints.push(Hint::new(message)); + self.mark_dirty(); + self + } + + /// Adds a hint with custom visibility. + /// + /// Use this when a hint should be visible in Staging or Production. + /// + /// # Examples + /// + /// ```rust + /// use masterror::{AppError, DiagnosticVisibility}; + /// + /// let err = AppError::unauthorized("Invalid token").with_hint_visible( + /// "Token may have expired, please login again", + /// DiagnosticVisibility::Public + /// ); + /// ``` + #[must_use] + pub fn with_hint_visible( + mut self, + message: impl Into>, + visibility: DiagnosticVisibility + ) -> Self { + self.ensure_diagnostics() + .hints + .push(Hint::with_visibility(message, visibility)); + self.mark_dirty(); + self + } + + /// Adds an actionable suggestion to fix the error. + /// + /// Suggestions provide concrete steps users can take to resolve an error. + /// They are only shown in Local (development) mode by default. + /// + /// # Examples + /// + /// ```rust + /// use masterror::AppError; + /// + /// let err = AppError::database_with_message("Connection failed") + /// .with_suggestion("Check if the database server is running"); + /// ``` + #[must_use] + pub fn with_suggestion(mut self, message: impl Into>) -> Self { + self.ensure_diagnostics() + .suggestions + .push(Suggestion::new(message)); + self.mark_dirty(); + self + } + + /// Adds a suggestion with an executable command or code snippet. + /// + /// The command is displayed in a distinct style to indicate it can be + /// copied and executed. + /// + /// # Examples + /// + /// ```rust + /// use masterror::AppError; + /// + /// let err = AppError::database_with_message("PostgreSQL connection refused") + /// .with_suggestion_cmd( + /// "Check if PostgreSQL is running", + /// "systemctl status postgresql" + /// ); + /// ``` + #[must_use] + pub fn with_suggestion_cmd( + mut self, + message: impl Into>, + command: impl Into> + ) -> Self { + self.ensure_diagnostics() + .suggestions + .push(Suggestion::with_command(message, command)); + self.mark_dirty(); + self + } + + /// Links to documentation explaining this error. + /// + /// Documentation links are publicly visible by default, helping end users + /// understand and resolve errors. + /// + /// # Examples + /// + /// ```rust + /// use masterror::AppError; + /// + /// let err = AppError::not_found("User not found") + /// .with_docs("https://docs.example.com/errors/USER_NOT_FOUND"); + /// ``` + #[must_use] + pub fn with_docs(mut self, url: impl Into>) -> Self { + self.ensure_diagnostics().doc_link = Some(DocLink::new(url)); + self.mark_dirty(); + self + } + + /// Links to documentation with a human-readable title. + /// + /// # Examples + /// + /// ```rust + /// use masterror::AppError; + /// + /// let err = AppError::unauthorized("Token expired").with_docs_titled( + /// "https://docs.example.com/auth/tokens", + /// "Authentication Guide" + /// ); + /// ``` + #[must_use] + pub fn with_docs_titled( + mut self, + url: impl Into>, + title: impl Into> + ) -> Self { + self.ensure_diagnostics().doc_link = Some(DocLink::with_title(url, title)); + self.mark_dirty(); + self + } + + /// Adds a related error code for cross-reference. + /// + /// Related codes help users discover errors that might provide additional + /// context or alternative explanations. + /// + /// # Examples + /// + /// ```rust + /// use masterror::AppError; + /// + /// let err = AppError::database_with_message("Connection timeout") + /// .with_related_code("DB_POOL_EXHAUSTED") + /// .with_related_code("DB_AUTH_FAILED"); + /// ``` + #[must_use] + pub fn with_related_code(mut self, code: impl Into>) -> Self { + self.ensure_diagnostics().related_codes.push(code.into()); + self.mark_dirty(); + self + } + + /// Returns a mutable reference to diagnostics, initializing if needed. + fn ensure_diagnostics(&mut self) -> &mut Diagnostics { + if self.inner.diagnostics.is_none() { + self.inner.diagnostics = Some(Box::new(Diagnostics::new())); + } + self.inner + .diagnostics + .as_mut() + .expect("diagnostics initialized above") + } + + /// Returns diagnostics if present. + #[must_use] + pub fn diagnostics(&self) -> Option<&Diagnostics> { + self.inner.diagnostics.as_deref() + } +} diff --git a/src/app_error/core/builder/metadata.rs b/src/app_error/core/builder/metadata.rs new file mode 100644 index 0000000..0995500 --- /dev/null +++ b/src/app_error/core/builder/metadata.rs @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Metadata attachment methods for `AppError`. + +use crate::{ + FieldRedaction, + app_error::{ + core::error::Error, + metadata::{Field, Metadata} + } +}; + +impl Error { + /// Attach additional metadata to the error. + /// + /// # Examples + /// + /// ```rust + /// use masterror::{AppError, AppErrorKind, field}; + /// let err = AppError::new(AppErrorKind::Validation, "bad field") + /// .with_field(field::str("field_name", "email")); + /// assert!(err.metadata().get("field_name").is_some()); + /// ``` + #[must_use] + pub fn with_field(mut self, field: Field) -> Self { + self.metadata.insert(field); + self.mark_dirty(); + self + } + + /// Extend metadata from an iterator of fields. + /// + /// # Examples + /// + /// ```rust + /// use masterror::{AppError, AppErrorKind, field}; + /// let fields = vec![field::str("key1", "value1"), field::str("key2", "value2")]; + /// let err = AppError::new(AppErrorKind::BadRequest, "test").with_fields(fields); + /// assert!(err.metadata().get("key1").is_some()); + /// ``` + #[must_use] + pub fn with_fields(mut self, fields: impl IntoIterator) -> Self { + self.metadata.extend(fields); + self.mark_dirty(); + self + } + + /// Override the redaction policy for a stored metadata field. + /// + /// # Examples + /// + /// ```rust + /// use masterror::{AppError, AppErrorKind, FieldRedaction, field}; + /// + /// let err = AppError::new(AppErrorKind::Internal, "test") + /// .with_field(field::str("password", "secret")) + /// .redact_field("password", FieldRedaction::Redact); + /// ``` + #[must_use] + pub fn redact_field(mut self, name: &'static str, redaction: FieldRedaction) -> Self { + self.metadata.set_redaction(name, redaction); + self.mark_dirty(); + self + } + + /// Replace metadata entirely. + /// + /// # Examples + /// + /// ```rust + /// use masterror::{AppError, AppErrorKind, Metadata}; + /// + /// let metadata = Metadata::new(); + /// let err = AppError::new(AppErrorKind::Internal, "test").with_metadata(metadata); + /// ``` + #[must_use] + pub fn with_metadata(mut self, metadata: Metadata) -> Self { + self.metadata = metadata; + self.mark_dirty(); + self + } +} diff --git a/src/app_error/core/builder/mod.rs b/src/app_error/core/builder/mod.rs new file mode 100644 index 0000000..229ee97 --- /dev/null +++ b/src/app_error/core/builder/mod.rs @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Builder pattern implementation for `AppError`. +//! +//! This module provides fluent builder methods for constructing errors with +//! various attributes like metadata, source chains, and diagnostics. + +mod constructors; +mod context; +mod details; +mod diagnostics; +mod metadata; +mod modifiers; + +#[cfg(test)] +mod tests; diff --git a/src/app_error/core/builder/modifiers.rs b/src/app_error/core/builder/modifiers.rs new file mode 100644 index 0000000..aad1eaa --- /dev/null +++ b/src/app_error/core/builder/modifiers.rs @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Modifier methods for `AppError` that change error properties. + +use alloc::string::String; + +use crate::{ + AppCode, RetryAdvice, + app_error::core::{error::Error, types::MessageEditPolicy} +}; + +impl Error { + /// Override the machine-readable [`AppCode`]. + /// + /// # Examples + /// + /// ```rust + /// use masterror::{AppCode, AppError, AppErrorKind}; + /// let err = AppError::new(AppErrorKind::BadRequest, "test").with_code(AppCode::NotFound); + /// assert_eq!(err.code, AppCode::NotFound); + /// ``` + #[must_use] + pub fn with_code(mut self, code: AppCode) -> Self { + self.code = code; + self.mark_dirty(); + self + } + + /// Attach retry advice to the error. + /// + /// When mapped to HTTP, this becomes the `Retry-After` header. + /// + /// # Examples + /// + /// ```rust + /// use masterror::{AppError, AppErrorKind}; + /// let err = AppError::new(AppErrorKind::RateLimited, "slow down").with_retry_after_secs(60); + /// assert_eq!(err.retry.map(|r| r.after_seconds), Some(60)); + /// ``` + #[must_use] + pub fn with_retry_after_secs(mut self, secs: u64) -> Self { + self.retry = Some(RetryAdvice { + after_seconds: secs + }); + self.mark_dirty(); + self + } + + /// Attach a `WWW-Authenticate` challenge string. + /// + /// # Examples + /// + /// ```rust + /// use masterror::{AppError, AppErrorKind}; + /// let err = AppError::new(AppErrorKind::Unauthorized, "auth required") + /// .with_www_authenticate("Bearer realm=\"api\""); + /// assert!(err.www_authenticate.is_some()); + /// ``` + #[must_use] + pub fn with_www_authenticate(mut self, value: impl Into) -> Self { + self.www_authenticate = Some(value.into()); + self.mark_dirty(); + self + } + + /// Mark the message as redactable. + /// + /// # Examples + /// + /// ```rust + /// use masterror::{AppError, AppErrorKind, MessageEditPolicy}; + /// + /// let err = AppError::new(AppErrorKind::Internal, "secret").redactable(); + /// assert_eq!(err.edit_policy, MessageEditPolicy::Redact); + /// ``` + #[must_use] + pub fn redactable(mut self) -> Self { + self.edit_policy = MessageEditPolicy::Redact; + self.mark_dirty(); + self + } +} diff --git a/src/app_error/core/builder/tests.rs b/src/app_error/core/builder/tests.rs new file mode 100644 index 0000000..ee976ed --- /dev/null +++ b/src/app_error/core/builder/tests.rs @@ -0,0 +1,428 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Tests for builder module. + +use crate::{AppCode, AppError, AppErrorKind, FieldRedaction, MessageEditPolicy, Metadata, field}; + +// ───────────────────────────────────────────────────────────────────────────── +// Constructors tests +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn new_creates_error_with_message() { + let err = AppError::new(AppErrorKind::BadRequest, "test message"); + assert_eq!(err.kind, AppErrorKind::BadRequest); + assert_eq!(err.message.as_deref(), Some("test message")); +} + +#[test] +fn new_with_owned_string() { + let msg = String::from("owned message"); + let err = AppError::new(AppErrorKind::Internal, msg); + assert_eq!(err.message.as_deref(), Some("owned message")); +} + +#[test] +fn with_creates_error_with_message() { + let err = AppError::with(AppErrorKind::Validation, "validation failed"); + assert_eq!(err.kind, AppErrorKind::Validation); + assert_eq!(err.message.as_deref(), Some("validation failed")); +} + +#[test] +fn bare_creates_error_without_message() { + let err = AppError::bare(AppErrorKind::NotFound); + assert_eq!(err.kind, AppErrorKind::NotFound); + assert!(err.message.is_none()); +} + +#[test] +fn bare_sets_correct_code() { + let err = AppError::bare(AppErrorKind::Unauthorized); + assert_eq!(err.code, AppCode::Unauthorized); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Modifiers tests +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn with_code_overrides_default_code() { + let err = AppError::new(AppErrorKind::BadRequest, "test").with_code(AppCode::NotFound); + assert_eq!(err.code, AppCode::NotFound); +} + +#[test] +fn with_retry_after_secs_sets_retry_advice() { + let err = AppError::new(AppErrorKind::RateLimited, "slow down").with_retry_after_secs(60); + assert!(err.retry.is_some()); + assert_eq!(err.retry.unwrap().after_seconds, 60); +} + +#[test] +fn with_retry_after_secs_zero() { + let err = AppError::new(AppErrorKind::RateLimited, "test").with_retry_after_secs(0); + assert_eq!(err.retry.unwrap().after_seconds, 0); +} + +#[test] +fn with_www_authenticate_sets_header() { + let err = AppError::new(AppErrorKind::Unauthorized, "auth required") + .with_www_authenticate("Bearer realm=\"api\""); + assert_eq!( + err.www_authenticate.as_deref(), + Some("Bearer realm=\"api\"") + ); +} + +#[test] +fn with_www_authenticate_owned_string() { + let challenge = String::from("Basic realm=\"test\""); + let err = AppError::unauthorized("test").with_www_authenticate(challenge); + assert_eq!( + err.www_authenticate.as_deref(), + Some("Basic realm=\"test\"") + ); +} + +#[test] +fn redactable_sets_edit_policy() { + let err = AppError::new(AppErrorKind::Internal, "secret").redactable(); + assert_eq!(err.edit_policy, MessageEditPolicy::Redact); +} + +#[test] +fn redactable_can_be_chained() { + let err = AppError::internal("secret") + .redactable() + .with_code(AppCode::Internal); + assert_eq!(err.edit_policy, MessageEditPolicy::Redact); + assert_eq!(err.code, AppCode::Internal); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Metadata tests +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn with_field_adds_metadata() { + let err = AppError::new(AppErrorKind::Validation, "bad field") + .with_field(field::str("name", "email")); + assert!(err.metadata().get("name").is_some()); +} + +#[test] +fn with_field_multiple_fields() { + let err = AppError::internal("test") + .with_field(field::str("key1", "value1")) + .with_field(field::u64("key2", 42)); + assert!(err.metadata().get("key1").is_some()); + assert!(err.metadata().get("key2").is_some()); +} + +#[test] +fn with_fields_adds_multiple() { + let fields = vec![ + field::str("a", "1"), + field::str("b", "2"), + field::str("c", "3"), + ]; + let err = AppError::new(AppErrorKind::BadRequest, "test").with_fields(fields); + assert!(err.metadata().get("a").is_some()); + assert!(err.metadata().get("b").is_some()); + assert!(err.metadata().get("c").is_some()); +} + +#[test] +fn with_fields_empty_iterator() { + let err = AppError::internal("test").with_fields(Vec::new()); + assert!(err.metadata().is_empty()); +} + +#[test] +fn redact_field_sets_redaction() { + let err = AppError::new(AppErrorKind::Internal, "test") + .with_field(field::str("password", "secret")) + .redact_field("password", FieldRedaction::Redact); + // Field exists but is marked for redaction + assert!(err.metadata().get("password").is_some()); +} + +#[test] +fn redact_field_nonexistent_field() { + let err = AppError::internal("test").redact_field("nonexistent", FieldRedaction::Redact); + // Should not panic, just no-op + assert!(err.metadata().get("nonexistent").is_none()); +} + +#[test] +fn with_metadata_replaces_all() { + let mut metadata = Metadata::new(); + metadata.insert(field::str("new_key", "new_value")); + let err = AppError::internal("test") + .with_field(field::str("old_key", "old_value")) + .with_metadata(metadata); + assert!(err.metadata().get("old_key").is_none()); + assert!(err.metadata().get("new_key").is_some()); +} + +#[test] +fn with_metadata_empty() { + let err = AppError::internal("test") + .with_field(field::str("key", "value")) + .with_metadata(Metadata::new()); + assert!(err.metadata().is_empty()); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Context tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(feature = "std")] +#[test] +fn with_source_attaches_error() { + use std::io::{Error as IoError, ErrorKind}; + let io_err = IoError::new(ErrorKind::NotFound, "file not found"); + let err = AppError::internal("failed").with_source(io_err); + assert!(err.source_ref().is_some()); +} + +#[cfg(feature = "std")] +#[test] +fn with_source_arc_shares_error() { + use std::{io::Error as IoError, sync::Arc}; + let source = Arc::new(IoError::other("shared error")); + let err = AppError::internal("test").with_source_arc(source.clone()); + assert!(err.source_ref().is_some()); + assert_eq!(Arc::strong_count(&source), 2); +} + +#[cfg(feature = "std")] +#[test] +fn with_context_accepts_owned_error() { + use std::io::Error as IoError; + let io_err = IoError::other("context error"); + let err = AppError::service("degraded").with_context(io_err); + assert!(err.source_ref().is_some()); +} + +#[cfg(feature = "std")] +#[test] +fn with_context_accepts_arc() { + use std::{io::Error as IoError, sync::Arc}; + let source: Arc = Arc::new(IoError::other("arc error")); + let err = AppError::internal("test").with_context(source); + assert!(err.source_ref().is_some()); +} + +#[cfg(feature = "backtrace")] +#[test] +fn with_backtrace_attaches_backtrace() { + use std::backtrace::Backtrace; + let bt = Backtrace::capture(); + let err = AppError::internal("test").with_backtrace(bt); + // Just ensure it doesn't panic + let _ = err; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Details tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(feature = "serde_json")] +#[test] +fn with_details_json_attaches_value() { + use serde_json::json; + let err = AppError::new(AppErrorKind::Validation, "invalid") + .with_details_json(json!({"field": "email", "reason": "invalid format"})); + assert!(err.details.is_some()); +} + +#[cfg(feature = "serde_json")] +#[test] +fn with_details_json_null() { + use serde_json::Value; + let err = AppError::internal("test").with_details_json(Value::Null); + assert!(err.details.is_some()); +} + +#[cfg(feature = "serde_json")] +#[test] +fn with_details_serializes_struct() { + use serde::Serialize; + + #[derive(Serialize)] + struct Details { + code: u32, + reason: &'static str + } + + let err = AppError::bad_request("invalid") + .with_details(Details { + code: 100, + reason: "missing field" + }) + .expect("serialization should succeed"); + assert!(err.details.is_some()); +} + +#[cfg(feature = "serde_json")] +#[test] +fn with_details_handles_serialization_error() { + use serde::Serialize; + + #[derive(Serialize)] + struct BadStruct { + #[serde(serialize_with = "fail_serialize")] + value: u32 + } + + fn fail_serialize(_: &u32, _: S) -> Result + where + S: serde::Serializer + { + Err(serde::ser::Error::custom("intentional failure")) + } + + let result = AppError::internal("test").with_details(BadStruct { + value: 0 + }); + assert!(result.is_err()); +} + +#[cfg(not(feature = "serde_json"))] +#[test] +fn with_details_text_attaches_string() { + let err = AppError::internal("test").with_details_text("additional info"); + assert!(err.details.is_some()); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Diagnostics tests +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn with_hint_adds_hint() { + let err = AppError::not_found("User not found").with_hint("Check user ID"); + let diag = err.diagnostics().expect("diagnostics should exist"); + assert_eq!(diag.hints.len(), 1); +} + +#[test] +fn with_hint_multiple() { + let err = AppError::not_found("test") + .with_hint("hint 1") + .with_hint("hint 2") + .with_hint("hint 3"); + let diag = err.diagnostics().unwrap(); + assert_eq!(diag.hints.len(), 3); +} + +#[test] +fn with_hint_visible_sets_visibility() { + use crate::DiagnosticVisibility; + let err = AppError::unauthorized("test") + .with_hint_visible("public hint", DiagnosticVisibility::Public); + let diag = err.diagnostics().unwrap(); + assert_eq!(diag.hints[0].visibility, DiagnosticVisibility::Public); +} + +#[test] +fn with_suggestion_adds_suggestion() { + let err = + AppError::database_with_message("Connection failed").with_suggestion("Check DB server"); + let diag = err.diagnostics().unwrap(); + assert_eq!(diag.suggestions.len(), 1); +} + +#[test] +fn with_suggestion_cmd_includes_command() { + let err = AppError::database_with_message("test") + .with_suggestion_cmd("Check status", "systemctl status postgresql"); + let diag = err.diagnostics().unwrap(); + assert!(diag.suggestions[0].command.is_some()); + assert_eq!( + diag.suggestions[0].command.as_deref(), + Some("systemctl status postgresql") + ); +} + +#[test] +fn with_docs_sets_doc_link() { + let err = AppError::not_found("test").with_docs("https://docs.example.com/errors/NOT_FOUND"); + let diag = err.diagnostics().unwrap(); + assert!(diag.doc_link.is_some()); + assert_eq!( + diag.doc_link.as_ref().unwrap().url.as_ref(), + "https://docs.example.com/errors/NOT_FOUND" + ); +} + +#[test] +fn with_docs_titled_includes_title() { + let err = AppError::unauthorized("test") + .with_docs_titled("https://docs.example.com/auth", "Authentication Guide"); + let diag = err.diagnostics().unwrap(); + let doc = diag.doc_link.as_ref().unwrap(); + assert_eq!(doc.title.as_deref(), Some("Authentication Guide")); +} + +#[test] +fn with_related_code_adds_code() { + let err = AppError::database_with_message("test") + .with_related_code("DB_POOL_EXHAUSTED") + .with_related_code("DB_AUTH_FAILED"); + let diag = err.diagnostics().unwrap(); + assert_eq!(diag.related_codes.len(), 2); +} + +#[test] +fn diagnostics_returns_none_when_empty() { + let err = AppError::internal("no diagnostics"); + assert!(err.diagnostics().is_none()); +} + +#[test] +fn diagnostics_returns_some_when_present() { + let err = AppError::internal("test").with_hint("a hint"); + assert!(err.diagnostics().is_some()); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Chaining tests +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn all_builders_can_be_chained() { + let err = AppError::new(AppErrorKind::Service, "service error") + .with_code(AppCode::Service) + .with_retry_after_secs(30) + .with_field(field::str("service", "payment")) + .with_hint("Check service health") + .with_suggestion("Retry in 30 seconds") + .with_docs("https://docs.example.com/errors"); + + assert_eq!(err.code, AppCode::Service); + assert!(err.retry.is_some()); + assert!(err.metadata().get("service").is_some()); + assert!(err.diagnostics().is_some()); +} + +#[cfg(feature = "std")] +#[test] +fn chaining_with_source_preserves_all() { + use std::io::Error as IoError; + + let err = AppError::internal("test") + .with_field(field::u64("request_id", 12345)) + .with_source(IoError::other("underlying error")) + .with_hint("debug hint") + .redactable(); + + assert!(err.metadata().get("request_id").is_some()); + assert!(err.source_ref().is_some()); + assert!(err.diagnostics().is_some()); + assert_eq!(err.edit_policy, MessageEditPolicy::Redact); +} diff --git a/src/app_error/core/display.rs b/src/app_error/core/display.rs deleted file mode 100644 index cc92573..0000000 --- a/src/app_error/core/display.rs +++ /dev/null @@ -1,799 +0,0 @@ -// SPDX-FileCopyrightText: 2025 RAprogramm -// -// SPDX-License-Identifier: MIT - -use alloc::string::ToString; -use core::{ - error::Error as CoreError, - fmt::{Formatter, Result as FmtResult}, - sync::atomic::{AtomicU8, Ordering} -}; - -use super::error::Error; -use crate::{FieldRedaction, FieldValue, MessageEditPolicy}; - -/// Display mode for error output. -/// -/// Controls the structure and verbosity of error messages based on -/// the deployment environment. The mode is determined by the -/// `MASTERROR_ENV` environment variable or auto-detected based on -/// build configuration and runtime environment. -/// -/// # Examples -/// -/// ``` -/// use masterror::DisplayMode; -/// -/// let mode = DisplayMode::current(); -/// match mode { -/// DisplayMode::Prod => println!("Production mode: JSON output"), -/// DisplayMode::Local => println!("Local mode: Human-readable output"), -/// DisplayMode::Staging => println!("Staging mode: JSON with context") -/// } -/// ``` -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DisplayMode { - /// Production mode: lightweight JSON, minimal fields, no sensitive data. - /// - /// Output includes only: `kind`, `code`, `message` (if not redacted). - /// Metadata is filtered to exclude sensitive fields. - /// Source chain and backtrace are excluded. - /// - /// # Example Output - /// - /// ```json - /// {"kind":"NotFound","code":"NOT_FOUND","message":"User not found"} - /// ``` - Prod = 0, - - /// Development mode: human-readable, full context. - /// - /// Output includes: error details, full source chain, complete metadata, - /// and backtrace (if enabled). Supports colored output when the `colored` - /// feature is enabled and output is a TTY. - /// - /// # Example Output - /// - /// ```text - /// Error: NotFound - /// Code: NOT_FOUND - /// Message: User not found - /// - /// Caused by: database query failed - /// Caused by: connection timeout - /// - /// Context: - /// user_id: 12345 - /// ``` - Local = 1, - - /// Staging mode: JSON with additional context. - /// - /// Output includes: `kind`, `code`, `message`, limited `source_chain`, - /// and filtered metadata. No backtrace. - /// - /// # Example Output - /// - /// ```json - /// {"kind":"NotFound","code":"NOT_FOUND","message":"User not found","source_chain":["database error"],"metadata":{"user_id":12345}} - /// ``` - Staging = 2 -} - -impl DisplayMode { - /// Returns the current display mode based on environment configuration. - /// - /// The mode is determined by checking (in order): - /// 1. `MASTERROR_ENV` environment variable (`prod`, `local`, or `staging`) - /// 2. Kubernetes environment detection (`KUBERNETES_SERVICE_HOST`) - /// 3. Build configuration (`cfg!(debug_assertions)`) - /// - /// The result is cached for performance. - /// - /// # Examples - /// - /// ``` - /// use masterror::DisplayMode; - /// - /// let mode = DisplayMode::current(); - /// assert!(matches!( - /// mode, - /// DisplayMode::Prod | DisplayMode::Local | DisplayMode::Staging - /// )); - /// ``` - #[must_use] - pub fn current() -> Self { - static CACHED_MODE: AtomicU8 = AtomicU8::new(255); - let cached = CACHED_MODE.load(Ordering::Relaxed); - if cached != 255 { - return match cached { - 0 => Self::Prod, - 1 => Self::Local, - 2 => Self::Staging, - _ => unreachable!() - }; - } - let mode = Self::detect(); - CACHED_MODE.store(mode as u8, Ordering::Relaxed); - mode - } - - /// Detects the appropriate display mode from environment. - /// - /// This is an internal helper called by [`current()`](Self::current). - fn detect() -> Self { - #[cfg(feature = "std")] - { - use std::env::var; - if let Ok(env) = var("MASTERROR_ENV") { - return match env.as_str() { - "prod" | "production" => Self::Prod, - "local" | "dev" | "development" => Self::Local, - "staging" | "stage" => Self::Staging, - _ => Self::detect_auto() - }; - } - if var("KUBERNETES_SERVICE_HOST").is_ok() { - return Self::Prod; - } - } - Self::detect_auto() - } - - /// Auto-detects mode based on build configuration. - fn detect_auto() -> Self { - if cfg!(debug_assertions) { - Self::Local - } else { - Self::Prod - } - } -} - -#[allow(dead_code)] -impl Error { - /// Formats error in production mode (compact JSON). - /// - /// # Arguments - /// - /// * `f` - Formatter to write output to - /// - /// # Examples - /// - /// ``` - /// use masterror::AppError; - /// - /// let error = AppError::not_found("User not found"); - /// let output = format!("{}", error); - /// // In prod mode: {"kind":"NotFound","code":"NOT_FOUND","message":"User not found"} - /// ``` - #[cfg(not(test))] - pub(crate) fn fmt_prod(&self, f: &mut Formatter<'_>) -> FmtResult { - self.fmt_prod_impl(f) - } - - #[cfg(test)] - #[allow(missing_docs)] - pub fn fmt_prod(&self, f: &mut Formatter<'_>) -> FmtResult { - self.fmt_prod_impl(f) - } - - fn fmt_prod_impl(&self, f: &mut Formatter<'_>) -> FmtResult { - write!(f, r#"{{"kind":"{:?}","code":"{}""#, self.kind, self.code)?; - if !matches!(self.edit_policy, MessageEditPolicy::Redact) - && let Some(msg) = &self.message - { - write!(f, ",\"message\":\"")?; - write_json_escaped(f, msg.as_ref())?; - write!(f, "\"")?; - } - if !self.metadata.is_empty() { - let has_public_fields = self - .metadata - .iter_with_redaction() - .any(|(_, _, redaction)| !matches!(redaction, FieldRedaction::Redact)); - if has_public_fields { - write!(f, r#","metadata":{{"#)?; - let mut first = true; - for (name, value, redaction) in self.metadata.iter_with_redaction() { - if matches!(redaction, FieldRedaction::Redact) { - continue; - } - if !first { - write!(f, ",")?; - } - first = false; - write!(f, r#""{}":"#, name)?; - write_metadata_value(f, value)?; - } - write!(f, "}}")?; - } - } - write!(f, "}}") - } - - /// Formats error in local/development mode (human-readable). - /// - /// # Arguments - /// - /// * `f` - Formatter to write output to - /// - /// # Examples - /// - /// ``` - /// use masterror::AppError; - /// - /// let error = AppError::internal("Database error"); - /// let output = format!("{}", error); - /// // In local mode: multi-line human-readable format with full context - /// ``` - #[cfg(not(test))] - pub(crate) fn fmt_local(&self, f: &mut Formatter<'_>) -> FmtResult { - self.fmt_local_impl(f) - } - - #[cfg(test)] - #[allow(missing_docs)] - pub fn fmt_local(&self, f: &mut Formatter<'_>) -> FmtResult { - self.fmt_local_impl(f) - } - - fn fmt_local_impl(&self, f: &mut Formatter<'_>) -> FmtResult { - #[cfg(feature = "colored")] - { - use crate::colored::style; - writeln!(f, "Error: {}", self.kind)?; - writeln!(f, "Code: {}", style::error_code(self.code.to_string()))?; - if let Some(msg) = &self.message { - writeln!(f, "Message: {}", style::error_message(msg))?; - } - if let Some(source) = &self.source { - writeln!(f)?; - let mut current: &dyn CoreError = source.as_ref(); - let mut depth = 0; - while depth < 10 { - writeln!( - f, - " {}: {}", - style::source_context("Caused by"), - style::source_context(current.to_string()) - )?; - if let Some(next) = current.source() { - current = next; - depth += 1; - } else { - break; - } - } - } - if !self.metadata.is_empty() { - writeln!(f)?; - writeln!(f, "Context:")?; - for (key, value) in self.metadata.iter() { - writeln!(f, " {}: {}", style::metadata_key(key), value)?; - } - } - Ok(()) - } - #[cfg(not(feature = "colored"))] - { - writeln!(f, "Error: {}", self.kind)?; - writeln!(f, "Code: {}", self.code)?; - if let Some(msg) = &self.message { - writeln!(f, "Message: {}", msg)?; - } - if let Some(source) = &self.source { - writeln!(f)?; - let mut current: &dyn CoreError = source.as_ref(); - let mut depth = 0; - while depth < 10 { - writeln!(f, " Caused by: {}", current)?; - if let Some(next) = current.source() { - current = next; - depth += 1; - } else { - break; - } - } - } - if !self.metadata.is_empty() { - writeln!(f)?; - writeln!(f, "Context:")?; - for (key, value) in self.metadata.iter() { - writeln!(f, " {}: {}", key, value)?; - } - } - Ok(()) - } - } - - /// Formats error in staging mode (JSON with context). - /// - /// # Arguments - /// - /// * `f` - Formatter to write output to - /// - /// # Examples - /// - /// ``` - /// use masterror::AppError; - /// - /// let error = AppError::service("Service unavailable"); - /// let output = format!("{}", error); - /// // In staging mode: JSON with source_chain and metadata - /// ``` - #[cfg(not(test))] - pub(crate) fn fmt_staging(&self, f: &mut Formatter<'_>) -> FmtResult { - self.fmt_staging_impl(f) - } - - #[cfg(test)] - #[allow(missing_docs)] - pub fn fmt_staging(&self, f: &mut Formatter<'_>) -> FmtResult { - self.fmt_staging_impl(f) - } - - fn fmt_staging_impl(&self, f: &mut Formatter<'_>) -> FmtResult { - write!(f, r#"{{"kind":"{:?}","code":"{}""#, self.kind, self.code)?; - if !matches!(self.edit_policy, MessageEditPolicy::Redact) - && let Some(msg) = &self.message - { - write!(f, ",\"message\":\"")?; - write_json_escaped(f, msg.as_ref())?; - write!(f, "\"")?; - } - if let Some(source) = &self.source { - write!(f, r#","source_chain":["#)?; - let mut current: &dyn CoreError = source.as_ref(); - let mut depth = 0; - let mut first = true; - while depth < 5 { - if !first { - write!(f, ",")?; - } - first = false; - write!(f, "\"")?; - write_json_escaped(f, ¤t.to_string())?; - write!(f, "\"")?; - if let Some(next) = current.source() { - current = next; - depth += 1; - } else { - break; - } - } - write!(f, "]")?; - } - if !self.metadata.is_empty() { - let has_public_fields = self - .metadata - .iter_with_redaction() - .any(|(_, _, redaction)| !matches!(redaction, FieldRedaction::Redact)); - if has_public_fields { - write!(f, r#","metadata":{{"#)?; - let mut first = true; - for (name, value, redaction) in self.metadata.iter_with_redaction() { - if matches!(redaction, FieldRedaction::Redact) { - continue; - } - if !first { - write!(f, ",")?; - } - first = false; - write!(f, r#""{}":"#, name)?; - write_metadata_value(f, value)?; - } - write!(f, "}}")?; - } - } - write!(f, "}}") - } -} - -/// Writes a string with JSON escaping. -#[allow(dead_code)] -fn write_json_escaped(f: &mut Formatter<'_>, s: &str) -> FmtResult { - for ch in s.chars() { - match ch { - '"' => write!(f, "\\\"")?, - '\\' => write!(f, "\\\\")?, - '\n' => write!(f, "\\n")?, - '\r' => write!(f, "\\r")?, - '\t' => write!(f, "\\t")?, - ch if ch.is_control() => write!(f, "\\u{:04x}", ch as u32)?, - ch => write!(f, "{}", ch)? - } - } - Ok(()) -} - -/// Writes a metadata field value in JSON format. -#[allow(dead_code)] -fn write_metadata_value(f: &mut Formatter<'_>, value: &FieldValue) -> FmtResult { - use crate::app_error::metadata::FieldValue; - match value { - FieldValue::Str(s) => { - write!(f, "\"")?; - write_json_escaped(f, s.as_ref())?; - write!(f, "\"") - } - FieldValue::I64(v) => write!(f, "{}", v), - FieldValue::U64(v) => write!(f, "{}", v), - FieldValue::F64(v) => { - if v.is_finite() { - write!(f, "{}", v) - } else { - write!(f, "null") - } - } - FieldValue::Bool(v) => write!(f, "{}", v), - FieldValue::Uuid(v) => write!(f, "\"{}\"", v), - FieldValue::Duration(v) => { - write!( - f, - r#"{{"secs":{},"nanos":{}}}"#, - v.as_secs(), - v.subsec_nanos() - ) - } - FieldValue::Ip(v) => write!(f, "\"{}\"", v), - #[cfg(feature = "serde_json")] - FieldValue::Json(v) => write!(f, "{}", v) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{AppError, field}; - - #[test] - fn display_mode_current_returns_valid_mode() { - let mode = DisplayMode::current(); - assert!(matches!( - mode, - DisplayMode::Prod | DisplayMode::Local | DisplayMode::Staging - )); - } - - #[test] - fn display_mode_detect_auto_returns_local_in_debug() { - if cfg!(debug_assertions) { - assert_eq!(DisplayMode::detect_auto(), DisplayMode::Local); - } else { - assert_eq!(DisplayMode::detect_auto(), DisplayMode::Prod); - } - } - - #[test] - fn fmt_prod_outputs_json() { - let error = AppError::not_found("User not found"); - let output = format!("{}", error.fmt_prod_wrapper()); - assert!(output.contains(r#""kind":"NotFound""#)); - assert!(output.contains(r#""code":"NOT_FOUND""#)); - assert!(output.contains(r#""message":"User not found""#)); - } - - #[test] - fn fmt_prod_excludes_redacted_message() { - let error = AppError::internal("secret").redactable(); - let output = format!("{}", error.fmt_prod_wrapper()); - assert!(!output.contains("secret")); - } - - #[test] - fn fmt_prod_includes_metadata() { - let error = AppError::not_found("User not found").with_field(field::u64("user_id", 12345)); - let output = format!("{}", error.fmt_prod_wrapper()); - assert!(output.contains(r#""metadata""#)); - assert!(output.contains(r#""user_id":12345"#)); - } - - #[test] - fn fmt_prod_excludes_sensitive_metadata() { - let error = AppError::internal("Error").with_field(field::str("password", "secret")); - let output = format!("{}", error.fmt_prod_wrapper()); - assert!(!output.contains("secret")); - } - - #[test] - fn fmt_local_outputs_human_readable() { - let error = AppError::not_found("User not found"); - let output = format!("{}", error.fmt_local_wrapper()); - assert!(output.contains("Error:")); - assert!(output.contains("Code: NOT_FOUND")); - assert!(output.contains("Message: User not found")); - } - - #[cfg(feature = "std")] - #[test] - fn fmt_local_includes_source_chain() { - use std::io::Error as IoError; - let io_err = IoError::other("connection failed"); - let error = AppError::internal("Database error").with_source(io_err); - let output = format!("{}", error.fmt_local_wrapper()); - assert!(output.contains("Caused by")); - assert!(output.contains("connection failed")); - } - - #[test] - fn fmt_staging_outputs_json_with_context() { - let error = AppError::service("Service unavailable"); - let output = format!("{}", error.fmt_staging_wrapper()); - assert!(output.contains(r#""kind":"Service""#)); - assert!(output.contains(r#""code":"SERVICE""#)); - } - - #[cfg(feature = "std")] - #[test] - fn fmt_staging_includes_source_chain() { - use std::io::Error as IoError; - let io_err = IoError::other("timeout"); - let error = AppError::network("Network error").with_source(io_err); - let output = format!("{}", error.fmt_staging_wrapper()); - assert!(output.contains(r#""source_chain""#)); - assert!(output.contains("timeout")); - } - - #[test] - fn fmt_prod_escapes_special_chars() { - let error = AppError::internal("Line\nwith\"quotes\""); - let output = format!("{}", error.fmt_prod_wrapper()); - assert!(output.contains(r#"\n"#)); - assert!(output.contains(r#"\""#)); - } - - #[test] - fn fmt_prod_handles_infinity_in_metadata() { - let error = AppError::internal("Error").with_field(field::f64("ratio", f64::INFINITY)); - let output = format!("{}", error.fmt_prod_wrapper()); - assert!(output.contains("null")); - } - - #[test] - fn fmt_prod_formats_duration_metadata() { - use core::time::Duration; - let error = AppError::internal("Error") - .with_field(field::duration("elapsed", Duration::from_millis(1500))); - let output = format!("{}", error.fmt_prod_wrapper()); - assert!(output.contains(r#""secs":1"#)); - assert!(output.contains(r#""nanos":500000000"#)); - } - - #[test] - fn fmt_prod_formats_bool_metadata() { - let error = AppError::internal("Error").with_field(field::bool("active", true)); - let output = format!("{}", error.fmt_prod_wrapper()); - assert!(output.contains(r#""active":true"#)); - } - - #[cfg(feature = "std")] - #[test] - fn fmt_prod_formats_ip_metadata() { - use std::net::IpAddr; - let ip: IpAddr = "192.168.1.1".parse().unwrap(); - let error = AppError::internal("Error").with_field(field::ip("client_ip", ip)); - let output = format!("{}", error.fmt_prod_wrapper()); - assert!(output.contains(r#""client_ip":"192.168.1.1""#)); - } - - #[test] - fn fmt_prod_formats_uuid_metadata() { - use uuid::Uuid; - let uuid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); - let error = AppError::internal("Error").with_field(field::uuid("request_id", uuid)); - let output = format!("{}", error.fmt_prod_wrapper()); - assert!(output.contains(r#""request_id":"550e8400-e29b-41d4-a716-446655440000""#)); - } - - #[cfg(feature = "serde_json")] - #[test] - fn fmt_prod_formats_json_metadata() { - let json = serde_json::json!({"nested": "value"}); - let error = AppError::internal("Error").with_field(field::json("data", json)); - let output = format!("{}", error.fmt_prod_wrapper()); - assert!(output.contains(r#""data":"#)); - } - - #[test] - fn fmt_prod_without_message() { - let error = AppError::bare(crate::AppErrorKind::Internal); - let output = format!("{}", error.fmt_prod_wrapper()); - assert!(output.contains(r#""kind":"Internal""#)); - assert!(!output.contains(r#""message""#)); - } - - #[test] - fn fmt_local_without_message() { - let error = AppError::bare(crate::AppErrorKind::BadRequest); - let output = format!("{}", error.fmt_local_wrapper()); - assert!(output.contains("Error:")); - assert!(!output.contains("Message:")); - } - - #[test] - fn fmt_local_with_metadata() { - let error = AppError::internal("Error") - .with_field(field::str("key", "value")) - .with_field(field::i64("count", -42)); - let output = format!("{}", error.fmt_local_wrapper()); - assert!(output.contains("Context:")); - assert!(output.contains("key: value")); - assert!(output.contains("count: -42")); - } - - #[test] - fn fmt_staging_without_message() { - let error = AppError::bare(crate::AppErrorKind::Timeout); - let output = format!("{}", error.fmt_staging_wrapper()); - assert!(output.contains(r#""kind":"Timeout""#)); - assert!(!output.contains(r#""message""#)); - } - - #[test] - fn fmt_staging_with_metadata() { - let error = AppError::service("Service error").with_field(field::u64("retry_count", 3)); - let output = format!("{}", error.fmt_staging_wrapper()); - assert!(output.contains(r#""metadata""#)); - assert!(output.contains(r#""retry_count":3"#)); - } - - #[test] - fn fmt_staging_with_redacted_message() { - let error = AppError::internal("sensitive data").redactable(); - let output = format!("{}", error.fmt_staging_wrapper()); - assert!(!output.contains("sensitive data")); - } - - #[test] - fn fmt_prod_escapes_control_chars() { - let error = AppError::internal("test\x00\x1F"); - let output = format!("{}", error.fmt_prod_wrapper()); - assert!(output.contains(r#"\u0000"#)); - assert!(output.contains(r#"\u001f"#)); - } - - #[test] - fn fmt_prod_escapes_tab_and_carriage_return() { - let error = AppError::internal("line\ttab\rreturn"); - let output = format!("{}", error.fmt_prod_wrapper()); - assert!(output.contains(r#"\t"#)); - assert!(output.contains(r#"\r"#)); - } - - #[test] - fn display_mode_current_caches_result() { - let first = DisplayMode::current(); - let second = DisplayMode::current(); - assert_eq!(first, second); - } - - #[test] - fn display_mode_detect_auto_returns_prod_in_release() { - if !cfg!(debug_assertions) { - assert_eq!(DisplayMode::detect_auto(), DisplayMode::Prod); - } - } - - #[test] - fn fmt_prod_with_multiple_metadata_fields() { - let error = AppError::not_found("test") - .with_field(field::str("first", "value1")) - .with_field(field::u64("second", 42)) - .with_field(field::bool("third", true)); - let output = format!("{}", error.fmt_prod_wrapper()); - assert!(output.contains(r#""first":"value1""#)); - assert!(output.contains(r#""second":42"#)); - assert!(output.contains(r#""third":true"#)); - } - - #[test] - fn fmt_prod_escapes_backslash() { - let error = AppError::internal("path\\to\\file"); - let output = format!("{}", error.fmt_prod_wrapper()); - assert!(output.contains(r#"path\\to\\file"#)); - } - - #[test] - fn fmt_prod_with_i64_metadata() { - let error = AppError::internal("test").with_field(field::i64("count", -100)); - let output = format!("{}", error.fmt_prod_wrapper()); - assert!(output.contains(r#""count":-100"#)); - } - - #[test] - fn fmt_prod_with_string_metadata() { - let error = AppError::internal("test").with_field(field::str("name", "value")); - let output = format!("{}", error.fmt_prod_wrapper()); - assert!(output.contains(r#""name":"value""#)); - } - - #[cfg(feature = "colored")] - #[test] - fn fmt_local_with_deep_source_chain() { - use std::io::{Error as IoError, ErrorKind}; - let io1 = IoError::new(ErrorKind::NotFound, "level 1"); - let io2 = IoError::other(io1); - let error = AppError::internal("top").with_source(io2); - let output = format!("{}", error.fmt_local_wrapper()); - assert!(output.contains("Caused by")); - assert!(output.contains("level 1")); - } - - #[test] - fn fmt_staging_with_multiple_metadata_fields() { - let error = AppError::service("error") - .with_field(field::str("key1", "value1")) - .with_field(field::u64("key2", 123)) - .with_field(field::bool("key3", false)); - let output = format!("{}", error.fmt_staging_wrapper()); - assert!(output.contains(r#""key1":"value1""#)); - assert!(output.contains(r#""key2":123"#)); - assert!(output.contains(r#""key3":false"#)); - } - - #[test] - fn fmt_staging_with_deep_source_chain() { - use std::io::{Error as IoError, ErrorKind}; - let io1 = IoError::new(ErrorKind::NotFound, "inner error"); - let io2 = IoError::other(io1); - let error = AppError::service("outer").with_source(io2); - let output = format!("{}", error.fmt_staging_wrapper()); - assert!(output.contains(r#""source_chain""#)); - assert!(output.contains("inner error")); - } - - #[test] - fn fmt_staging_with_redacted_and_public_metadata() { - let error = AppError::internal("test") - .with_field(field::str("public", "visible")) - .with_field(field::str("password", "secret")); - let output = format!("{}", error.fmt_staging_wrapper()); - assert!(output.contains(r#""public":"visible""#)); - assert!(!output.contains("secret")); - } - - impl Error { - fn fmt_prod_wrapper(&self) -> FormatterWrapper<'_> { - FormatterWrapper { - error: self, - mode: FormatterMode::Prod - } - } - - fn fmt_local_wrapper(&self) -> FormatterWrapper<'_> { - FormatterWrapper { - error: self, - mode: FormatterMode::Local - } - } - - fn fmt_staging_wrapper(&self) -> FormatterWrapper<'_> { - FormatterWrapper { - error: self, - mode: FormatterMode::Staging - } - } - } - - enum FormatterMode { - Prod, - Local, - Staging - } - - struct FormatterWrapper<'a> { - error: &'a Error, - mode: FormatterMode - } - - impl core::fmt::Display for FormatterWrapper<'_> { - fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - match self.mode { - FormatterMode::Prod => self.error.fmt_prod(f), - FormatterMode::Local => self.error.fmt_local(f), - FormatterMode::Staging => self.error.fmt_staging(f) - } - } - } -} diff --git a/src/app_error/core/display/helpers.rs b/src/app_error/core/display/helpers.rs new file mode 100644 index 0000000..e1d8827 --- /dev/null +++ b/src/app_error/core/display/helpers.rs @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Helper functions for display formatting. + +use core::fmt::{Formatter, Result as FmtResult}; + +use crate::FieldValue; + +#[allow(dead_code)] +/// Writes a string with JSON escaping. +pub(super) fn write_json_escaped(f: &mut Formatter<'_>, s: &str) -> FmtResult { + for ch in s.chars() { + match ch { + '"' => write!(f, "\\\"")?, + '\\' => write!(f, "\\\\")?, + '\n' => write!(f, "\\n")?, + '\r' => write!(f, "\\r")?, + '\t' => write!(f, "\\t")?, + ch if ch.is_control() => write!(f, "\\u{:04x}", ch as u32)?, + ch => write!(f, "{}", ch)? + } + } + Ok(()) +} + +#[allow(dead_code)] +/// Writes a metadata field value in JSON format. +pub(super) fn write_metadata_value(f: &mut Formatter<'_>, value: &FieldValue) -> FmtResult { + match value { + FieldValue::Str(s) => { + write!(f, "\"")?; + write_json_escaped(f, s.as_ref())?; + write!(f, "\"") + } + FieldValue::I64(v) => write!(f, "{}", v), + FieldValue::U64(v) => write!(f, "{}", v), + FieldValue::F64(v) => { + if v.is_finite() { + write!(f, "{}", v) + } else { + write!(f, "null") + } + } + FieldValue::Bool(v) => write!(f, "{}", v), + FieldValue::Uuid(v) => write!(f, "\"{}\"", v), + FieldValue::Duration(v) => { + write!( + f, + r#"{{"secs":{},"nanos":{}}}"#, + v.as_secs(), + v.subsec_nanos() + ) + } + FieldValue::Ip(v) => write!(f, "\"{}\"", v), + #[cfg(feature = "serde_json")] + FieldValue::Json(v) => write!(f, "{}", v) + } +} diff --git a/src/app_error/core/display/local.rs b/src/app_error/core/display/local.rs new file mode 100644 index 0000000..bdc03e6 --- /dev/null +++ b/src/app_error/core/display/local.rs @@ -0,0 +1,282 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Local/development mode formatting (human-readable). + +#[cfg(feature = "colored")] +use alloc::string::ToString; +use core::{ + error::Error as CoreError, + fmt::{Formatter, Result as FmtResult} +}; + +use crate::app_error::core::error::Error; + +#[allow(dead_code)] +impl Error { + /// Formats error in local/development mode (human-readable). + /// + /// # Arguments + /// + /// * `f` - Formatter to write output to + /// + /// # Examples + /// + /// ``` + /// use masterror::AppError; + /// + /// let error = AppError::internal("Database error"); + /// let output = format!("{}", error); + /// // In local mode: multi-line human-readable format with full context + /// ``` + #[cfg(not(test))] + pub(crate) fn fmt_local(&self, f: &mut Formatter<'_>) -> FmtResult { + self.fmt_local_impl(f) + } + + #[cfg(test)] + #[allow(missing_docs)] + pub fn fmt_local(&self, f: &mut Formatter<'_>) -> FmtResult { + self.fmt_local_impl(f) + } + + pub(super) fn fmt_local_impl(&self, f: &mut Formatter<'_>) -> FmtResult { + #[cfg(feature = "colored")] + { + self.fmt_local_colored(f) + } + #[cfg(not(feature = "colored"))] + { + self.fmt_local_plain(f) + } + } + + #[cfg(feature = "colored")] + fn fmt_local_colored(&self, f: &mut Formatter<'_>) -> FmtResult { + use crate::colored::style; + + writeln!(f, "Error: {}", self.kind)?; + writeln!(f, "Code: {}", style::error_code(self.code.to_string()))?; + if let Some(msg) = &self.message { + writeln!(f, "Message: {}", style::error_message(msg))?; + } + self.fmt_source_chain_colored(f)?; + self.fmt_metadata_colored(f)?; + self.fmt_diagnostics_colored(f)?; + Ok(()) + } + + #[cfg(feature = "colored")] + fn fmt_source_chain_colored(&self, f: &mut Formatter<'_>) -> FmtResult { + use crate::colored::style; + + if let Some(source) = &self.source { + writeln!(f)?; + let mut current: &dyn CoreError = source.as_ref(); + let mut depth = 0; + while depth < 10 { + writeln!( + f, + " {}: {}", + style::source_context("Caused by"), + style::source_context(current.to_string()) + )?; + if let Some(next) = current.source() { + current = next; + depth += 1; + } else { + break; + } + } + } + Ok(()) + } + + #[cfg(feature = "colored")] + fn fmt_metadata_colored(&self, f: &mut Formatter<'_>) -> FmtResult { + use crate::colored::style; + + if !self.metadata.is_empty() { + writeln!(f)?; + writeln!(f, "Context:")?; + for (key, value) in self.metadata.iter() { + writeln!(f, " {}: {}", style::metadata_key(key), value)?; + } + } + Ok(()) + } + + #[cfg(feature = "colored")] + fn fmt_diagnostics_colored(&self, f: &mut Formatter<'_>) -> FmtResult { + use crate::{app_error::diagnostics::DiagnosticVisibility, colored::style}; + + if let Some(diag) = &self.diagnostics { + let min_visibility = DiagnosticVisibility::DevOnly; + + // Hints + let hints: alloc::vec::Vec<_> = diag.visible_hints(min_visibility).collect(); + if !hints.is_empty() { + writeln!(f)?; + for hint in hints { + writeln!( + f, + " {}: {}", + style::hint_label("hint"), + style::hint_text(&hint.message) + )?; + } + } + + // Suggestions + for suggestion in diag.visible_suggestions(min_visibility) { + writeln!(f)?; + write!( + f, + " {}: {}", + style::suggestion_label("suggestion"), + style::suggestion_text(&suggestion.message) + )?; + if let Some(cmd) = &suggestion.command { + writeln!(f)?; + writeln!(f, " {}", style::command(cmd))?; + } else { + writeln!(f)?; + } + } + + // Documentation link + if let Some(doc) = diag.visible_doc_link(min_visibility) { + writeln!(f)?; + if let Some(title) = &doc.title { + writeln!( + f, + " {}: {} ({})", + style::docs_label("docs"), + title, + style::url(&doc.url) + )?; + } else { + writeln!( + f, + " {}: {}", + style::docs_label("docs"), + style::url(&doc.url) + )?; + } + } + + // Related codes + if !diag.related_codes.is_empty() { + writeln!(f)?; + write!(f, " {}: ", style::related_label("see also"))?; + for (i, code) in diag.related_codes.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{}", style::error_code(code))?; + } + writeln!(f)?; + } + } + Ok(()) + } + + #[cfg(not(feature = "colored"))] + fn fmt_local_plain(&self, f: &mut Formatter<'_>) -> FmtResult { + writeln!(f, "Error: {}", self.kind)?; + writeln!(f, "Code: {}", self.code)?; + if let Some(msg) = &self.message { + writeln!(f, "Message: {}", msg)?; + } + self.fmt_source_chain_plain(f)?; + self.fmt_metadata_plain(f)?; + self.fmt_diagnostics_plain(f)?; + Ok(()) + } + + #[cfg(not(feature = "colored"))] + fn fmt_source_chain_plain(&self, f: &mut Formatter<'_>) -> FmtResult { + if let Some(source) = &self.source { + writeln!(f)?; + let mut current: &dyn CoreError = source.as_ref(); + let mut depth = 0; + while depth < 10 { + writeln!(f, " Caused by: {}", current)?; + if let Some(next) = current.source() { + current = next; + depth += 1; + } else { + break; + } + } + } + Ok(()) + } + + #[cfg(not(feature = "colored"))] + fn fmt_metadata_plain(&self, f: &mut Formatter<'_>) -> FmtResult { + if !self.metadata.is_empty() { + writeln!(f)?; + writeln!(f, "Context:")?; + for (key, value) in self.metadata.iter() { + writeln!(f, " {}: {}", key, value)?; + } + } + Ok(()) + } + + #[cfg(not(feature = "colored"))] + fn fmt_diagnostics_plain(&self, f: &mut Formatter<'_>) -> FmtResult { + use crate::app_error::diagnostics::DiagnosticVisibility; + + if let Some(diag) = &self.diagnostics { + let min_visibility = DiagnosticVisibility::DevOnly; + + // Hints + let hints: alloc::vec::Vec<_> = diag.visible_hints(min_visibility).collect(); + if !hints.is_empty() { + writeln!(f)?; + for hint in hints { + writeln!(f, " hint: {}", hint.message)?; + } + } + + // Suggestions + for suggestion in diag.visible_suggestions(min_visibility) { + writeln!(f)?; + write!(f, " suggestion: {}", suggestion.message)?; + if let Some(cmd) = &suggestion.command { + writeln!(f)?; + writeln!(f, " {}", cmd)?; + } else { + writeln!(f)?; + } + } + + // Documentation link + if let Some(doc) = diag.visible_doc_link(min_visibility) { + writeln!(f)?; + if let Some(title) = &doc.title { + writeln!(f, " docs: {} ({})", title, doc.url)?; + } else { + writeln!(f, " docs: {}", doc.url)?; + } + } + + // Related codes + if !diag.related_codes.is_empty() { + writeln!(f)?; + write!(f, " see also: ")?; + for (i, code) in diag.related_codes.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{}", code)?; + } + writeln!(f)?; + } + } + Ok(()) + } +} diff --git a/src/app_error/core/display/mod.rs b/src/app_error/core/display/mod.rs new file mode 100644 index 0000000..77c2dc1 --- /dev/null +++ b/src/app_error/core/display/mod.rs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Display formatting for `AppError`. +//! +//! Provides environment-aware display modes: production (compact JSON), +//! local (human-readable), and staging (JSON with context). + +mod helpers; +mod local; +mod mode; +mod prod; +mod staging; + +#[cfg(test)] +mod tests; + +pub use mode::DisplayMode; diff --git a/src/app_error/core/display/mode.rs b/src/app_error/core/display/mode.rs new file mode 100644 index 0000000..dd88b49 --- /dev/null +++ b/src/app_error/core/display/mode.rs @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Display mode detection and configuration. + +use core::sync::atomic::{AtomicU8, Ordering}; + +/// Display mode for error output. +/// +/// Controls the structure and verbosity of error messages based on +/// the deployment environment. The mode is determined by the +/// `MASTERROR_ENV` environment variable or auto-detected based on +/// build configuration and runtime environment. +/// +/// # Examples +/// +/// ``` +/// use masterror::DisplayMode; +/// +/// let mode = DisplayMode::current(); +/// match mode { +/// DisplayMode::Prod => println!("Production mode: JSON output"), +/// DisplayMode::Local => println!("Local mode: Human-readable output"), +/// DisplayMode::Staging => println!("Staging mode: JSON with context") +/// } +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisplayMode { + /// Production mode: lightweight JSON, minimal fields, no sensitive data. + /// + /// Output includes only: `kind`, `code`, `message` (if not redacted). + /// Metadata is filtered to exclude sensitive fields. + /// Source chain and backtrace are excluded. + /// + /// # Example Output + /// + /// ```json + /// {"kind":"NotFound","code":"NOT_FOUND","message":"User not found"} + /// ``` + Prod = 0, + + /// Development mode: human-readable, full context. + /// + /// Output includes: error details, full source chain, complete metadata, + /// and backtrace (if enabled). Supports colored output when the `colored` + /// feature is enabled and output is a TTY. + /// + /// # Example Output + /// + /// ```text + /// Error: NotFound + /// Code: NOT_FOUND + /// Message: User not found + /// + /// Caused by: database query failed + /// Caused by: connection timeout + /// + /// Context: + /// user_id: 12345 + /// ``` + Local = 1, + + /// Staging mode: JSON with additional context. + /// + /// Output includes: `kind`, `code`, `message`, limited `source_chain`, + /// and filtered metadata. No backtrace. + /// + /// # Example Output + /// + /// ```json + /// {"kind":"NotFound","code":"NOT_FOUND","message":"User not found","source_chain":["database error"],"metadata":{"user_id":12345}} + /// ``` + Staging = 2 +} + +impl DisplayMode { + /// Returns the current display mode based on environment configuration. + /// + /// The mode is determined by checking (in order): + /// 1. `MASTERROR_ENV` environment variable (`prod`, `local`, or `staging`) + /// 2. Kubernetes environment detection (`KUBERNETES_SERVICE_HOST`) + /// 3. Build configuration (`cfg!(debug_assertions)`) + /// + /// The result is cached for performance. + /// + /// # Examples + /// + /// ``` + /// use masterror::DisplayMode; + /// + /// let mode = DisplayMode::current(); + /// assert!(matches!( + /// mode, + /// DisplayMode::Prod | DisplayMode::Local | DisplayMode::Staging + /// )); + /// ``` + #[must_use] + pub fn current() -> Self { + static CACHED_MODE: AtomicU8 = AtomicU8::new(255); + let cached = CACHED_MODE.load(Ordering::Relaxed); + if cached != 255 { + return match cached { + 0 => Self::Prod, + 1 => Self::Local, + 2 => Self::Staging, + _ => unreachable!() + }; + } + let mode = Self::detect(); + CACHED_MODE.store(mode as u8, Ordering::Relaxed); + mode + } + + /// Detects the appropriate display mode from environment. + /// + /// This is an internal helper called by [`current()`](Self::current). + pub(crate) fn detect() -> Self { + #[cfg(feature = "std")] + { + use std::env::var; + if let Ok(env) = var("MASTERROR_ENV") { + return match env.as_str() { + "prod" | "production" => Self::Prod, + "local" | "dev" | "development" => Self::Local, + "staging" | "stage" => Self::Staging, + _ => Self::detect_auto() + }; + } + if var("KUBERNETES_SERVICE_HOST").is_ok() { + return Self::Prod; + } + } + Self::detect_auto() + } + + /// Auto-detects mode based on build configuration. + pub(crate) fn detect_auto() -> Self { + if cfg!(debug_assertions) { + Self::Local + } else { + Self::Prod + } + } +} diff --git a/src/app_error/core/display/prod.rs b/src/app_error/core/display/prod.rs new file mode 100644 index 0000000..768505a --- /dev/null +++ b/src/app_error/core/display/prod.rs @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Production mode formatting (compact JSON). + +use core::fmt::{Formatter, Result as FmtResult}; + +use super::helpers::{write_json_escaped, write_metadata_value}; +use crate::{FieldRedaction, MessageEditPolicy, app_error::core::error::Error}; + +#[allow(dead_code)] +impl Error { + /// Formats error in production mode (compact JSON). + /// + /// # Arguments + /// + /// * `f` - Formatter to write output to + /// + /// # Examples + /// + /// ``` + /// use masterror::AppError; + /// + /// let error = AppError::not_found("User not found"); + /// let output = format!("{}", error); + /// // In prod mode: {"kind":"NotFound","code":"NOT_FOUND","message":"User not found"} + /// ``` + #[cfg(not(test))] + pub(crate) fn fmt_prod(&self, f: &mut Formatter<'_>) -> FmtResult { + self.fmt_prod_impl(f) + } + + #[cfg(test)] + #[allow(missing_docs)] + pub fn fmt_prod(&self, f: &mut Formatter<'_>) -> FmtResult { + self.fmt_prod_impl(f) + } + + pub(super) fn fmt_prod_impl(&self, f: &mut Formatter<'_>) -> FmtResult { + write!(f, r#"{{"kind":"{:?}","code":"{}""#, self.kind, self.code)?; + if !matches!(self.edit_policy, MessageEditPolicy::Redact) + && let Some(msg) = &self.message + { + write!(f, ",\"message\":\"")?; + write_json_escaped(f, msg.as_ref())?; + write!(f, "\"")?; + } + if !self.metadata.is_empty() { + let has_public_fields = self + .metadata + .iter_with_redaction() + .any(|(_, _, redaction)| !matches!(redaction, FieldRedaction::Redact)); + if has_public_fields { + write!(f, r#","metadata":{{"#)?; + let mut first = true; + for (name, value, redaction) in self.metadata.iter_with_redaction() { + if matches!(redaction, FieldRedaction::Redact) { + continue; + } + if !first { + write!(f, ",")?; + } + first = false; + write!(f, r#""{}":"#, name)?; + write_metadata_value(f, value)?; + } + write!(f, "}}")?; + } + } + write!(f, "}}") + } +} diff --git a/src/app_error/core/display/staging.rs b/src/app_error/core/display/staging.rs new file mode 100644 index 0000000..0975f2c --- /dev/null +++ b/src/app_error/core/display/staging.rs @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Staging mode formatting (JSON with context). + +use alloc::string::ToString; +use core::{ + error::Error as CoreError, + fmt::{Formatter, Result as FmtResult} +}; + +use super::helpers::{write_json_escaped, write_metadata_value}; +use crate::{FieldRedaction, MessageEditPolicy, app_error::core::error::Error}; + +#[allow(dead_code)] +impl Error { + /// Formats error in staging mode (JSON with context). + /// + /// # Arguments + /// + /// * `f` - Formatter to write output to + /// + /// # Examples + /// + /// ``` + /// use masterror::AppError; + /// + /// let error = AppError::service("Service unavailable"); + /// let output = format!("{}", error); + /// // In staging mode: JSON with source_chain and metadata + /// ``` + #[cfg(not(test))] + pub(crate) fn fmt_staging(&self, f: &mut Formatter<'_>) -> FmtResult { + self.fmt_staging_impl(f) + } + + #[cfg(test)] + #[allow(missing_docs)] + pub fn fmt_staging(&self, f: &mut Formatter<'_>) -> FmtResult { + self.fmt_staging_impl(f) + } + + pub(super) fn fmt_staging_impl(&self, f: &mut Formatter<'_>) -> FmtResult { + write!(f, r#"{{"kind":"{:?}","code":"{}""#, self.kind, self.code)?; + if !matches!(self.edit_policy, MessageEditPolicy::Redact) + && let Some(msg) = &self.message + { + write!(f, ",\"message\":\"")?; + write_json_escaped(f, msg.as_ref())?; + write!(f, "\"")?; + } + self.fmt_staging_source_chain(f)?; + self.fmt_staging_metadata(f)?; + write!(f, "}}") + } + + fn fmt_staging_source_chain(&self, f: &mut Formatter<'_>) -> FmtResult { + if let Some(source) = &self.source { + write!(f, r#","source_chain":["#)?; + let mut current: &dyn CoreError = source.as_ref(); + let mut depth = 0; + let mut first = true; + while depth < 5 { + if !first { + write!(f, ",")?; + } + first = false; + write!(f, "\"")?; + write_json_escaped(f, ¤t.to_string())?; + write!(f, "\"")?; + if let Some(next) = current.source() { + current = next; + depth += 1; + } else { + break; + } + } + write!(f, "]")?; + } + Ok(()) + } + + fn fmt_staging_metadata(&self, f: &mut Formatter<'_>) -> FmtResult { + if !self.metadata.is_empty() { + let has_public_fields = self + .metadata + .iter_with_redaction() + .any(|(_, _, redaction)| !matches!(redaction, FieldRedaction::Redact)); + if has_public_fields { + write!(f, r#","metadata":{{"#)?; + let mut first = true; + for (name, value, redaction) in self.metadata.iter_with_redaction() { + if matches!(redaction, FieldRedaction::Redact) { + continue; + } + if !first { + write!(f, ",")?; + } + first = false; + write!(f, r#""{}":"#, name)?; + write_metadata_value(f, value)?; + } + write!(f, "}}")?; + } + } + Ok(()) + } +} diff --git a/src/app_error/core/display/tests.rs b/src/app_error/core/display/tests.rs new file mode 100644 index 0000000..128bd5b --- /dev/null +++ b/src/app_error/core/display/tests.rs @@ -0,0 +1,451 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Tests for display module. + +use core::fmt::{Formatter, Result as FmtResult}; + +use super::DisplayMode; +use crate::{AppError, field}; + +// ───────────────────────────────────────────────────────────────────────────── +// DisplayMode tests +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn display_mode_current_returns_valid_mode() { + let mode = DisplayMode::current(); + assert!(matches!( + mode, + DisplayMode::Prod | DisplayMode::Local | DisplayMode::Staging + )); +} + +#[test] +fn display_mode_detect_auto_returns_local_in_debug() { + if cfg!(debug_assertions) { + assert_eq!(DisplayMode::detect_auto(), DisplayMode::Local); + } else { + assert_eq!(DisplayMode::detect_auto(), DisplayMode::Prod); + } +} + +#[test] +fn display_mode_current_caches_result() { + let first = DisplayMode::current(); + let second = DisplayMode::current(); + assert_eq!(first, second); +} + +#[test] +fn display_mode_detect_auto_returns_prod_in_release() { + if !cfg!(debug_assertions) { + assert_eq!(DisplayMode::detect_auto(), DisplayMode::Prod); + } +} + +// Note: Environment-based mode detection tests (MASTERROR_ENV, +// KUBERNETES_SERVICE_HOST) cannot be tested without unsafe env var manipulation +// which is forbidden by #![deny(unsafe_code)]. These code paths are tested via +// integration tests and manual verification. + +// ───────────────────────────────────────────────────────────────────────────── +// Production format tests +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn fmt_prod_outputs_json() { + let error = AppError::not_found("User not found"); + let output = format!("{}", error.fmt_prod_wrapper()); + assert!(output.contains(r#""kind":"NotFound""#)); + assert!(output.contains(r#""code":"NOT_FOUND""#)); + assert!(output.contains(r#""message":"User not found""#)); +} + +#[test] +fn fmt_prod_excludes_redacted_message() { + let error = AppError::internal("secret").redactable(); + let output = format!("{}", error.fmt_prod_wrapper()); + assert!(!output.contains("secret")); +} + +#[test] +fn fmt_prod_includes_metadata() { + let error = AppError::not_found("User not found").with_field(field::u64("user_id", 12345)); + let output = format!("{}", error.fmt_prod_wrapper()); + assert!(output.contains(r#""metadata""#)); + assert!(output.contains(r#""user_id":12345"#)); +} + +#[test] +fn fmt_prod_excludes_sensitive_metadata() { + let error = AppError::internal("Error").with_field(field::str("password", "secret")); + let output = format!("{}", error.fmt_prod_wrapper()); + assert!(!output.contains("secret")); +} + +#[test] +fn fmt_prod_escapes_special_chars() { + let error = AppError::internal("Line\nwith\"quotes\""); + let output = format!("{}", error.fmt_prod_wrapper()); + assert!(output.contains(r#"\n"#)); + assert!(output.contains(r#"\""#)); +} + +#[test] +fn fmt_prod_handles_infinity_in_metadata() { + let error = AppError::internal("Error").with_field(field::f64("ratio", f64::INFINITY)); + let output = format!("{}", error.fmt_prod_wrapper()); + assert!(output.contains("null")); +} + +#[test] +fn fmt_prod_formats_duration_metadata() { + use core::time::Duration; + let error = AppError::internal("Error") + .with_field(field::duration("elapsed", Duration::from_millis(1500))); + let output = format!("{}", error.fmt_prod_wrapper()); + assert!(output.contains(r#""secs":1"#)); + assert!(output.contains(r#""nanos":500000000"#)); +} + +#[test] +fn fmt_prod_formats_bool_metadata() { + let error = AppError::internal("Error").with_field(field::bool("active", true)); + let output = format!("{}", error.fmt_prod_wrapper()); + assert!(output.contains(r#""active":true"#)); +} + +#[cfg(feature = "std")] +#[test] +fn fmt_prod_formats_ip_metadata() { + use std::net::IpAddr; + let ip: IpAddr = "192.168.1.1".parse().unwrap(); + let error = AppError::internal("Error").with_field(field::ip("client_ip", ip)); + let output = format!("{}", error.fmt_prod_wrapper()); + assert!(output.contains(r#""client_ip":"192.168.1.1""#)); +} + +#[test] +fn fmt_prod_formats_uuid_metadata() { + use uuid::Uuid; + let uuid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let error = AppError::internal("Error").with_field(field::uuid("request_id", uuid)); + let output = format!("{}", error.fmt_prod_wrapper()); + assert!(output.contains(r#""request_id":"550e8400-e29b-41d4-a716-446655440000""#)); +} + +#[cfg(feature = "serde_json")] +#[test] +fn fmt_prod_formats_json_metadata() { + let json = serde_json::json!({"nested": "value"}); + let error = AppError::internal("Error").with_field(field::json("data", json)); + let output = format!("{}", error.fmt_prod_wrapper()); + assert!(output.contains(r#""data":"#)); +} + +#[test] +fn fmt_prod_without_message() { + let error = AppError::bare(crate::AppErrorKind::Internal); + let output = format!("{}", error.fmt_prod_wrapper()); + assert!(output.contains(r#""kind":"Internal""#)); + assert!(!output.contains(r#""message""#)); +} + +#[test] +fn fmt_prod_with_multiple_metadata_fields() { + let error = AppError::not_found("test") + .with_field(field::str("first", "value1")) + .with_field(field::u64("second", 42)) + .with_field(field::bool("third", true)); + let output = format!("{}", error.fmt_prod_wrapper()); + assert!(output.contains(r#""first":"value1""#)); + assert!(output.contains(r#""second":42"#)); + assert!(output.contains(r#""third":true"#)); +} + +#[test] +fn fmt_prod_escapes_backslash() { + let error = AppError::internal("path\\to\\file"); + let output = format!("{}", error.fmt_prod_wrapper()); + assert!(output.contains(r#"path\\to\\file"#)); +} + +#[test] +fn fmt_prod_with_i64_metadata() { + let error = AppError::internal("test").with_field(field::i64("count", -100)); + let output = format!("{}", error.fmt_prod_wrapper()); + assert!(output.contains(r#""count":-100"#)); +} + +#[test] +fn fmt_prod_with_string_metadata() { + let error = AppError::internal("test").with_field(field::str("name", "value")); + let output = format!("{}", error.fmt_prod_wrapper()); + assert!(output.contains(r#""name":"value""#)); +} + +#[test] +fn fmt_prod_escapes_control_chars() { + let error = AppError::internal("test\x00\x1F"); + let output = format!("{}", error.fmt_prod_wrapper()); + assert!(output.contains(r#"\u0000"#)); + assert!(output.contains(r#"\u001f"#)); +} + +#[test] +fn fmt_prod_escapes_tab_and_carriage_return() { + let error = AppError::internal("line\ttab\rreturn"); + let output = format!("{}", error.fmt_prod_wrapper()); + assert!(output.contains(r#"\t"#)); + assert!(output.contains(r#"\r"#)); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Local format tests +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn fmt_local_outputs_human_readable() { + let error = AppError::not_found("User not found"); + let output = format!("{}", error.fmt_local_wrapper()); + assert!(output.contains("Error:")); + assert!(output.contains("Code: NOT_FOUND") || output.contains("Code:")); + assert!(output.contains("Message: User not found") || output.contains("Message:")); +} + +#[cfg(feature = "std")] +#[test] +fn fmt_local_includes_source_chain() { + use std::io::Error as IoError; + let io_err = IoError::other("connection failed"); + let error = AppError::internal("Database error").with_source(io_err); + let output = format!("{}", error.fmt_local_wrapper()); + assert!(output.contains("Caused by")); + assert!(output.contains("connection failed")); +} + +#[test] +fn fmt_local_without_message() { + let error = AppError::bare(crate::AppErrorKind::BadRequest); + let output = format!("{}", error.fmt_local_wrapper()); + assert!(output.contains("Error:")); + assert!(!output.contains("Message:")); +} + +#[test] +fn fmt_local_with_metadata() { + let error = AppError::internal("Error") + .with_field(field::str("key", "value")) + .with_field(field::i64("count", -42)); + let output = format!("{}", error.fmt_local_wrapper()); + assert!(output.contains("Context:")); + assert!(output.contains("key")); + assert!(output.contains("value") || output.contains("-42")); +} + +#[cfg(feature = "colored")] +#[test] +fn fmt_local_with_deep_source_chain() { + use std::io::{Error as IoError, ErrorKind}; + let io1 = IoError::new(ErrorKind::NotFound, "level 1"); + let io2 = IoError::other(io1); + let error = AppError::internal("top").with_source(io2); + let output = format!("{}", error.fmt_local_wrapper()); + assert!(output.contains("Caused by")); + assert!(output.contains("level 1")); +} + +#[test] +fn fmt_local_with_hints() { + let error = AppError::not_found("Resource missing") + .with_hint("Check the resource ID") + .with_hint("Verify permissions"); + let output = format!("{}", error.fmt_local_wrapper()); + assert!(output.contains("hint")); + assert!(output.contains("Check the resource ID")); +} + +#[test] +fn fmt_local_with_suggestions() { + let error = AppError::service("Service unavailable") + .with_suggestion("Retry the request") + .with_suggestion_cmd("Check service status", "systemctl status myservice"); + let output = format!("{}", error.fmt_local_wrapper()); + assert!(output.contains("suggestion")); + assert!(output.contains("Retry the request")); + assert!(output.contains("systemctl status myservice")); +} + +#[test] +fn fmt_local_with_doc_link() { + let error = AppError::unauthorized("Authentication required") + .with_docs("https://docs.example.com/auth"); + let output = format!("{}", error.fmt_local_wrapper()); + assert!(output.contains("docs")); + assert!(output.contains("https://docs.example.com/auth")); +} + +#[test] +fn fmt_local_with_doc_link_titled() { + let error = AppError::forbidden("Access denied") + .with_docs_titled("https://docs.example.com/rbac", "RBAC Guide"); + let output = format!("{}", error.fmt_local_wrapper()); + assert!(output.contains("docs")); + assert!(output.contains("RBAC Guide")); +} + +#[test] +fn fmt_local_with_related_codes() { + let error = AppError::database_with_message("Connection failed") + .with_related_code("DB_POOL_EXHAUSTED") + .with_related_code("DB_TIMEOUT"); + let output = format!("{}", error.fmt_local_wrapper()); + assert!(output.contains("see also")); + assert!(output.contains("DB_POOL_EXHAUSTED")); +} + +#[test] +fn fmt_local_with_all_diagnostics() { + let error = AppError::internal("Critical failure") + .with_hint("Check logs for details") + .with_suggestion_cmd("View logs", "journalctl -u myapp") + .with_docs_titled("https://docs.example.com/troubleshoot", "Troubleshooting") + .with_related_code("ERR_STARTUP_FAILED"); + let output = format!("{}", error.fmt_local_wrapper()); + assert!(output.contains("hint")); + assert!(output.contains("suggestion")); + assert!(output.contains("docs")); + assert!(output.contains("see also")); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Staging format tests +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn fmt_staging_outputs_json_with_context() { + let error = AppError::service("Service unavailable"); + let output = format!("{}", error.fmt_staging_wrapper()); + assert!(output.contains(r#""kind":"Service""#)); + assert!(output.contains(r#""code":"SERVICE""#)); +} + +#[cfg(feature = "std")] +#[test] +fn fmt_staging_includes_source_chain() { + use std::io::Error as IoError; + let io_err = IoError::other("timeout"); + let error = AppError::network("Network error").with_source(io_err); + let output = format!("{}", error.fmt_staging_wrapper()); + assert!(output.contains(r#""source_chain""#)); + assert!(output.contains("timeout")); +} + +#[test] +fn fmt_staging_without_message() { + let error = AppError::bare(crate::AppErrorKind::Timeout); + let output = format!("{}", error.fmt_staging_wrapper()); + assert!(output.contains(r#""kind":"Timeout""#)); + assert!(!output.contains(r#""message""#)); +} + +#[test] +fn fmt_staging_with_metadata() { + let error = AppError::service("Service error").with_field(field::u64("retry_count", 3)); + let output = format!("{}", error.fmt_staging_wrapper()); + assert!(output.contains(r#""metadata""#)); + assert!(output.contains(r#""retry_count":3"#)); +} + +#[test] +fn fmt_staging_with_redacted_message() { + let error = AppError::internal("sensitive data").redactable(); + let output = format!("{}", error.fmt_staging_wrapper()); + assert!(!output.contains("sensitive data")); +} + +#[test] +fn fmt_staging_with_multiple_metadata_fields() { + let error = AppError::service("error") + .with_field(field::str("key1", "value1")) + .with_field(field::u64("key2", 123)) + .with_field(field::bool("key3", false)); + let output = format!("{}", error.fmt_staging_wrapper()); + assert!(output.contains(r#""key1":"value1""#)); + assert!(output.contains(r#""key2":123"#)); + assert!(output.contains(r#""key3":false"#)); +} + +#[cfg(feature = "std")] +#[test] +fn fmt_staging_with_deep_source_chain() { + use std::io::{Error as IoError, ErrorKind}; + let io1 = IoError::new(ErrorKind::NotFound, "inner error"); + let io2 = IoError::other(io1); + let error = AppError::service("outer").with_source(io2); + let output = format!("{}", error.fmt_staging_wrapper()); + assert!(output.contains(r#""source_chain""#)); + assert!(output.contains("inner error")); +} + +#[test] +fn fmt_staging_with_redacted_and_public_metadata() { + let error = AppError::internal("test") + .with_field(field::str("public", "visible")) + .with_field(field::str("password", "secret")); + let output = format!("{}", error.fmt_staging_wrapper()); + assert!(output.contains(r#""public":"visible""#)); + assert!(!output.contains("secret")); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helper wrapper for testing +// ───────────────────────────────────────────────────────────────────────────── + +use crate::app_error::core::error::Error; + +impl Error { + fn fmt_prod_wrapper(&self) -> FormatterWrapper<'_> { + FormatterWrapper { + error: self, + mode: FormatterMode::Prod + } + } + + fn fmt_local_wrapper(&self) -> FormatterWrapper<'_> { + FormatterWrapper { + error: self, + mode: FormatterMode::Local + } + } + + fn fmt_staging_wrapper(&self) -> FormatterWrapper<'_> { + FormatterWrapper { + error: self, + mode: FormatterMode::Staging + } + } +} + +enum FormatterMode { + Prod, + Local, + Staging +} + +struct FormatterWrapper<'a> { + error: &'a Error, + mode: FormatterMode +} + +impl core::fmt::Display for FormatterWrapper<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self.mode { + FormatterMode::Prod => self.error.fmt_prod(f), + FormatterMode::Local => self.error.fmt_local(f), + FormatterMode::Staging => self.error.fmt_staging(f) + } + } +} diff --git a/src/app_error/core/error.rs b/src/app_error/core/error.rs index 9bb9564..9e4c106 100644 --- a/src/app_error/core/error.rs +++ b/src/app_error/core/error.rs @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 RAprogramm +// SPDX-FileCopyrightText: 2025-2026 RAprogramm // // SPDX-License-Identifier: MIT @@ -18,7 +18,10 @@ use serde_json::Value as JsonValue; #[cfg(not(feature = "backtrace"))] use super::types::CapturedBacktrace; use super::types::MessageEditPolicy; -use crate::{AppCode, AppErrorKind, RetryAdvice, app_error::metadata::Metadata}; +use crate::{ + AppCode, AppErrorKind, RetryAdvice, + app_error::{diagnostics::Diagnostics, metadata::Metadata} +}; /// Internal representation of error state. /// @@ -49,6 +52,10 @@ pub struct ErrorInner { #[cfg(not(feature = "serde_json"))] pub details: Option, pub source: Option>, + /// Diagnostic information (hints, suggestions, documentation). + /// + /// Stored as `Option>` for zero-cost when not used. + pub diagnostics: Option>, #[cfg(feature = "backtrace")] pub backtrace: Option>, #[cfg(feature = "backtrace")] @@ -194,6 +201,7 @@ impl Error { www_authenticate: None, details: None, source: None, + diagnostics: None, #[cfg(feature = "backtrace")] backtrace: None, #[cfg(feature = "backtrace")] diff --git a/src/app_error/diagnostics.rs b/src/app_error/diagnostics.rs new file mode 100644 index 0000000..69a31d1 --- /dev/null +++ b/src/app_error/diagnostics.rs @@ -0,0 +1,509 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Diagnostic information for enhanced error reporting. +//! +//! This module provides rich diagnostic capabilities for errors, inspired by +//! compiler diagnostics (rustc, miette) but designed for runtime application +//! errors. +//! +//! # Features +//! +//! - **Hints**: Contextual advice explaining why an error occurred +//! - **Suggestions**: Actionable fixes with optional commands/code snippets +//! - **Documentation links**: URLs to detailed explanations +//! - **Related codes**: Cross-references to related error codes +//! - **Visibility control**: Per-item visibility for dev/staging/prod +//! environments +//! +//! # Example +//! +//! ```rust +//! use masterror::{AppError, DiagnosticVisibility}; +//! +//! let err = AppError::not_found("User not found") +//! .with_hint("Check if the user ID is correct") +//! .with_hint("User might have been deleted") +//! .with_suggestion_cmd("List all users to verify", "curl -X GET /api/users") +//! .with_docs("https://docs.example.com/errors/USER_NOT_FOUND"); +//! ``` +//! +//! # Display Modes +//! +//! Diagnostics are filtered based on [`DisplayMode`](crate::DisplayMode): +//! +//! | Visibility | Local | Staging | Prod | +//! |------------|-------|---------|------| +//! | `DevOnly` | ✅ | ❌ | ❌ | +//! | `Internal` | ✅ | ✅ | ❌ | +//! | `Public` | ✅ | ✅ | ✅ | + +use alloc::borrow::Cow; + +use super::inline_vec::InlineVec; + +/// Visibility of diagnostic information across environments. +/// +/// Controls where hints, suggestions, and other diagnostic information +/// are displayed based on the deployment environment. +/// +/// # Ordering +/// +/// Variants are ordered from most restrictive to least restrictive: +/// `DevOnly < Internal < Public`. This enables filtering with simple +/// comparisons. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +#[repr(u8)] +pub enum DiagnosticVisibility { + /// Shown only in Local mode (development). + /// + /// Use for internal debugging hints that may expose implementation + /// details or sensitive information. + #[default] + DevOnly = 0, + + /// Shown in Local and Staging environments. + /// + /// Use for hints that help with testing and debugging but should + /// not reach production users. + Internal = 1, + + /// Shown everywhere including Production. + /// + /// Use for user-facing hints and documentation links that help + /// end users understand and resolve errors. + Public = 2 +} + +/// A single hint providing context about an error. +/// +/// Hints explain why an error might have occurred without necessarily +/// providing a fix. They help developers and users understand the +/// error context. +/// +/// # Example +/// +/// ```rust +/// use masterror::diagnostics::{DiagnosticVisibility, Hint}; +/// +/// let hint = Hint { +/// message: "Database connection pool may be exhausted".into(), +/// visibility: DiagnosticVisibility::Internal +/// }; +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Hint { + /// The hint message. + pub message: Cow<'static, str>, + + /// Where this hint should be displayed. + pub visibility: DiagnosticVisibility +} + +impl Hint { + /// Creates a new hint with default (DevOnly) visibility. + #[must_use] + pub fn new(message: impl Into>) -> Self { + Self { + message: message.into(), + visibility: DiagnosticVisibility::DevOnly + } + } + + /// Creates a new hint with specified visibility. + #[must_use] + pub fn with_visibility( + message: impl Into>, + visibility: DiagnosticVisibility + ) -> Self { + Self { + message: message.into(), + visibility + } + } + + /// Creates a public hint visible in all environments. + #[must_use] + pub fn public(message: impl Into>) -> Self { + Self::with_visibility(message, DiagnosticVisibility::Public) + } + + /// Creates an internal hint visible in Local and Staging. + #[must_use] + pub fn internal(message: impl Into>) -> Self { + Self::with_visibility(message, DiagnosticVisibility::Internal) + } +} + +/// An actionable suggestion to fix an error. +/// +/// Suggestions provide concrete steps users can take to resolve an error, +/// optionally including a command or code snippet. +/// +/// # Example +/// +/// ```rust +/// use masterror::diagnostics::{DiagnosticVisibility, Suggestion}; +/// +/// let suggestion = Suggestion { +/// message: "Check if PostgreSQL is running".into(), +/// command: Some("systemctl status postgresql".into()), +/// visibility: DiagnosticVisibility::DevOnly +/// }; +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Suggestion { + /// Human-readable description of the suggestion. + pub message: Cow<'static, str>, + + /// Optional command or code snippet. + /// + /// When present, displayed in a distinct style (monospace, highlighted) + /// to indicate it can be copied and executed. + pub command: Option>, + + /// Where this suggestion should be displayed. + pub visibility: DiagnosticVisibility +} + +impl Suggestion { + /// Creates a new suggestion without a command. + #[must_use] + pub fn new(message: impl Into>) -> Self { + Self { + message: message.into(), + command: None, + visibility: DiagnosticVisibility::DevOnly + } + } + + /// Creates a new suggestion with a command. + #[must_use] + pub fn with_command( + message: impl Into>, + command: impl Into> + ) -> Self { + Self { + message: message.into(), + command: Some(command.into()), + visibility: DiagnosticVisibility::DevOnly + } + } + + /// Sets the visibility for this suggestion. + #[must_use] + pub fn visibility(mut self, visibility: DiagnosticVisibility) -> Self { + self.visibility = visibility; + self + } +} + +/// A link to documentation explaining the error. +/// +/// Documentation links provide detailed explanations and context that +/// don't fit in hint messages. They typically point to error catalogs, +/// API documentation, or troubleshooting guides. +/// +/// # Example +/// +/// ```rust +/// use masterror::diagnostics::{DiagnosticVisibility, DocLink}; +/// +/// let doc = DocLink { +/// url: "https://docs.example.com/errors/E001".into(), +/// title: Some("Connection Errors".into()), +/// visibility: DiagnosticVisibility::Public +/// }; +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocLink { + /// URL to documentation. + pub url: Cow<'static, str>, + + /// Optional human-readable title for the link. + pub title: Option>, + + /// Where this link should be displayed. + /// + /// Documentation links are typically `Public` since they help + /// end users understand errors. + pub visibility: DiagnosticVisibility +} + +impl DocLink { + /// Creates a new documentation link with public visibility. + #[must_use] + pub fn new(url: impl Into>) -> Self { + Self { + url: url.into(), + title: None, + visibility: DiagnosticVisibility::Public + } + } + + /// Creates a new documentation link with a title. + #[must_use] + pub fn with_title( + url: impl Into>, + title: impl Into> + ) -> Self { + Self { + url: url.into(), + title: Some(title.into()), + visibility: DiagnosticVisibility::Public + } + } + + /// Sets the visibility for this link. + #[must_use] + pub fn visibility(mut self, visibility: DiagnosticVisibility) -> Self { + self.visibility = visibility; + self + } +} + +/// Complete diagnostic information for an error. +/// +/// This structure collects all diagnostic information associated with an +/// error. It is stored in `Option>` to ensure zero cost +/// when diagnostics are not used. +/// +/// # Memory Layout +/// +/// The structure uses `InlineVec` for hints and suggestions, which stores +/// up to 4 elements inline without heap allocation. This optimizes for the +/// common case of 1-2 hints/suggestions per error. +/// +/// # Example +/// +/// ```rust +/// use masterror::diagnostics::{Diagnostics, DocLink, Hint, Suggestion}; +/// +/// let mut diag = Diagnostics::new(); +/// diag.hints.push(Hint::new("Check configuration")); +/// diag.suggestions.push(Suggestion::with_command( +/// "Restart the service", +/// "systemctl restart myapp" +/// )); +/// diag.doc_link = Some(DocLink::new("https://docs.example.com/errors")); +/// ``` +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Diagnostics { + /// Contextual hints explaining the error. + /// + /// Hints provide context without necessarily offering a solution. + /// Multiple hints can be attached to explain different aspects. + pub hints: InlineVec, + + /// Actionable suggestions to fix the error. + /// + /// Suggestions provide concrete steps to resolve the error. + /// Usually 0-2 suggestions are most helpful. + pub suggestions: InlineVec, + + /// Link to detailed documentation. + /// + /// Only one documentation link is supported per error to avoid + /// overwhelming users with choices. + pub doc_link: Option, + + /// Related error codes for cross-reference. + /// + /// Helps users discover related errors that might provide additional + /// context or alternative explanations. + pub related_codes: InlineVec> +} + +impl Diagnostics { + /// Creates an empty diagnostics container. + #[must_use] + pub const fn new() -> Self { + Self { + hints: InlineVec::new(), + suggestions: InlineVec::new(), + doc_link: None, + related_codes: InlineVec::new() + } + } + + /// Returns `true` if no diagnostic information is present. + #[must_use] + pub fn is_empty(&self) -> bool { + self.hints.is_empty() + && self.suggestions.is_empty() + && self.doc_link.is_none() + && self.related_codes.is_empty() + } + + /// Returns `true` if any diagnostic information has the given visibility + /// or higher. + #[must_use] + pub fn has_visible_content(&self, min_visibility: DiagnosticVisibility) -> bool { + self.hints.iter().any(|h| h.visibility >= min_visibility) + || self + .suggestions + .iter() + .any(|s| s.visibility >= min_visibility) + || self + .doc_link + .as_ref() + .is_some_and(|d| d.visibility >= min_visibility) + } + + /// Returns an iterator over hints visible at the given level. + pub fn visible_hints( + &self, + min_visibility: DiagnosticVisibility + ) -> impl Iterator { + self.hints + .iter() + .filter(move |h| h.visibility >= min_visibility) + } + + /// Returns an iterator over suggestions visible at the given level. + pub fn visible_suggestions( + &self, + min_visibility: DiagnosticVisibility + ) -> impl Iterator { + self.suggestions + .iter() + .filter(move |s| s.visibility >= min_visibility) + } + + /// Returns the documentation link if visible at the given level. + #[must_use] + pub fn visible_doc_link(&self, min_visibility: DiagnosticVisibility) -> Option<&DocLink> { + self.doc_link + .as_ref() + .filter(|d| d.visibility >= min_visibility) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn visibility_ordering() { + assert!(DiagnosticVisibility::DevOnly < DiagnosticVisibility::Internal); + assert!(DiagnosticVisibility::Internal < DiagnosticVisibility::Public); + } + + #[test] + fn hint_constructors() { + let hint = Hint::new("test"); + assert_eq!(hint.visibility, DiagnosticVisibility::DevOnly); + + let public = Hint::public("test"); + assert_eq!(public.visibility, DiagnosticVisibility::Public); + + let internal = Hint::internal("test"); + assert_eq!(internal.visibility, DiagnosticVisibility::Internal); + } + + #[test] + fn suggestion_constructors() { + let suggestion = Suggestion::new("do this"); + assert!(suggestion.command.is_none()); + + let with_cmd = Suggestion::with_command("do this", "some command"); + assert_eq!(with_cmd.command.as_deref(), Some("some command")); + } + + #[test] + fn doc_link_constructors() { + let link = DocLink::new("https://example.com"); + assert!(link.title.is_none()); + assert_eq!(link.visibility, DiagnosticVisibility::Public); + + let titled = DocLink::with_title("https://example.com", "Example"); + assert_eq!(titled.title.as_deref(), Some("Example")); + } + + #[test] + fn diagnostics_is_empty() { + let diag = Diagnostics::new(); + assert!(diag.is_empty()); + + let mut diag = Diagnostics::new(); + diag.hints.push(Hint::new("test")); + assert!(!diag.is_empty()); + } + + #[test] + fn diagnostics_visibility_filtering() { + let mut diag = Diagnostics::new(); + diag.hints.push(Hint::new("dev hint")); + diag.hints.push(Hint::internal("internal hint")); + diag.hints.push(Hint::public("public hint")); + + // DevOnly level sees all + let dev_hints: Vec<_> = diag.visible_hints(DiagnosticVisibility::DevOnly).collect(); + assert_eq!(dev_hints.len(), 3); + + // Internal level sees internal + public + let internal_hints: Vec<_> = diag.visible_hints(DiagnosticVisibility::Internal).collect(); + assert_eq!(internal_hints.len(), 2); + + // Public level sees only public + let public_hints: Vec<_> = diag.visible_hints(DiagnosticVisibility::Public).collect(); + assert_eq!(public_hints.len(), 1); + assert_eq!(public_hints[0].message, "public hint"); + } + + #[test] + fn diagnostics_has_visible_content() { + let mut diag = Diagnostics::new(); + assert!(!diag.has_visible_content(DiagnosticVisibility::DevOnly)); + + diag.hints.push(Hint::new("dev only")); + assert!(diag.has_visible_content(DiagnosticVisibility::DevOnly)); + assert!(!diag.has_visible_content(DiagnosticVisibility::Internal)); + assert!(!diag.has_visible_content(DiagnosticVisibility::Public)); + + diag.suggestions + .push(Suggestion::new("fix it").visibility(DiagnosticVisibility::Public)); + assert!(diag.has_visible_content(DiagnosticVisibility::Public)); + } + + #[test] + fn diagnostics_doc_link_visibility() { + let mut diag = Diagnostics::new(); + diag.doc_link = Some(DocLink::new("https://example.com")); + + assert!( + diag.visible_doc_link(DiagnosticVisibility::Public) + .is_some() + ); + assert!( + diag.visible_doc_link(DiagnosticVisibility::Internal) + .is_some() + ); + assert!( + diag.visible_doc_link(DiagnosticVisibility::DevOnly) + .is_some() + ); + + // Change to internal visibility + diag.doc_link = + Some(DocLink::new("https://example.com").visibility(DiagnosticVisibility::Internal)); + assert!( + diag.visible_doc_link(DiagnosticVisibility::Internal) + .is_some() + ); + assert!( + diag.visible_doc_link(DiagnosticVisibility::Public) + .is_none() + ); + } + + #[test] + fn cow_static_str() { + let hint = Hint::new("static string"); + assert!(matches!(hint.message, Cow::Borrowed(_))); + + let hint = Hint::new(alloc::string::String::from("owned string")); + assert!(matches!(hint.message, Cow::Owned(_))); + } +} diff --git a/src/app_error/inline_vec.rs b/src/app_error/inline_vec.rs index d24e6df..6b6dbb2 100644 --- a/src/app_error/inline_vec.rs +++ b/src/app_error/inline_vec.rs @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 RAprogramm +// SPDX-FileCopyrightText: 2025-2026 RAprogramm // // SPDX-License-Identifier: MIT @@ -37,12 +37,12 @@ const INLINE_CAPACITY: usize = 4; /// assert_eq!(vec.len(), 2); /// assert!(vec.is_inline()); // Still on stack /// ``` -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct InlineVec { storage: Storage } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, Eq)] enum Storage { /// Inline storage for 0-4 elements using fixed arrays. /// @@ -533,4 +533,381 @@ mod tests { } assert_eq!(vec[0], 10); } + + #[test] + fn test_get_three_elements() { + let mut vec: InlineVec = InlineVec::new(); + vec.push(1); + vec.push(2); + vec.push(3); + assert_eq!(vec.get(0), Some(&1)); + assert_eq!(vec.get(1), Some(&2)); + assert_eq!(vec.get(2), Some(&3)); + assert_eq!(vec.get(3), None); + } + + #[test] + fn test_get_four_elements() { + let mut vec: InlineVec = InlineVec::new(); + vec.push(1); + vec.push(2); + vec.push(3); + vec.push(4); + assert_eq!(vec.get(0), Some(&1)); + assert_eq!(vec.get(3), Some(&4)); + assert_eq!(vec.get(4), None); + } + + #[test] + fn test_get_heap() { + let mut vec: InlineVec = InlineVec::new(); + for i in 1..=6 { + vec.push(i); + } + assert!(!vec.is_inline()); + assert_eq!(vec.get(0), Some(&1)); + assert_eq!(vec.get(5), Some(&6)); + assert_eq!(vec.get(6), None); + } + + #[test] + fn test_get_mut_three_elements() { + let mut vec: InlineVec = InlineVec::new(); + vec.push(1); + vec.push(2); + vec.push(3); + *vec.get_mut(2).unwrap() = 30; + assert_eq!(vec[2], 30); + assert!(vec.get_mut(3).is_none()); + } + + #[test] + fn test_get_mut_four_elements() { + let mut vec: InlineVec = InlineVec::new(); + vec.push(1); + vec.push(2); + vec.push(3); + vec.push(4); + *vec.get_mut(3).unwrap() = 40; + assert_eq!(vec[3], 40); + assert!(vec.get_mut(4).is_none()); + } + + #[test] + fn test_get_mut_heap() { + let mut vec: InlineVec = InlineVec::new(); + for i in 1..=6 { + vec.push(i); + } + *vec.get_mut(5).unwrap() = 60; + assert_eq!(vec[5], 60); + assert!(vec.get_mut(6).is_none()); + } + + #[test] + fn test_insert_empty() { + let mut vec: InlineVec = InlineVec::new(); + vec.insert(0, 42); + assert_eq!(&*vec, &[42]); + } + + #[test] + fn test_insert_three_all_positions() { + let mut vec: InlineVec = InlineVec::new(); + vec.push(1); + vec.push(3); + vec.insert(1, 2); + assert_eq!(&*vec, &[1, 2, 3]); + + let mut vec2: InlineVec = InlineVec::new(); + vec2.push(2); + vec2.push(3); + vec2.insert(0, 1); + assert_eq!(&*vec2, &[1, 2, 3]); + + let mut vec3: InlineVec = InlineVec::new(); + vec3.push(1); + vec3.push(2); + vec3.insert(2, 3); + assert_eq!(&*vec3, &[1, 2, 3]); + } + + #[test] + fn test_insert_four_all_positions() { + let mut vec: InlineVec = InlineVec::new(); + vec.push(2); + vec.push(3); + vec.push(4); + vec.insert(0, 1); + assert_eq!(&*vec, &[1, 2, 3, 4]); + + let mut vec2: InlineVec = InlineVec::new(); + vec2.push(1); + vec2.push(3); + vec2.push(4); + vec2.insert(1, 2); + assert_eq!(&*vec2, &[1, 2, 3, 4]); + + let mut vec3: InlineVec = InlineVec::new(); + vec3.push(1); + vec3.push(2); + vec3.push(4); + vec3.insert(2, 3); + assert_eq!(&*vec3, &[1, 2, 3, 4]); + + let mut vec4: InlineVec = InlineVec::new(); + vec4.push(1); + vec4.push(2); + vec4.push(3); + vec4.insert(3, 4); + assert_eq!(&*vec4, &[1, 2, 3, 4]); + } + + #[test] + fn test_insert_heap() { + let mut vec: InlineVec = InlineVec::new(); + for i in 1..=6 { + vec.push(i); + } + vec.insert(3, 99); + assert_eq!(vec[3], 99); + assert_eq!(vec.len(), 7); + } + + #[test] + fn test_into_iter_three() { + let mut vec: InlineVec = InlineVec::new(); + vec.push(1); + vec.push(2); + vec.push(3); + let collected: alloc::vec::Vec<_> = vec.into_iter().collect(); + assert_eq!(collected, alloc::vec![1, 2, 3]); + } + + #[test] + fn test_into_iter_four() { + let mut vec: InlineVec = InlineVec::new(); + vec.push(1); + vec.push(2); + vec.push(3); + vec.push(4); + let collected: alloc::vec::Vec<_> = vec.into_iter().collect(); + assert_eq!(collected, alloc::vec![1, 2, 3, 4]); + } + + #[test] + fn test_into_iter_heap() { + let mut vec: InlineVec = InlineVec::new(); + for i in 1..=6 { + vec.push(i); + } + let collected: alloc::vec::Vec<_> = vec.into_iter().collect(); + assert_eq!(collected, alloc::vec![1, 2, 3, 4, 5, 6]); + } + + #[test] + fn test_into_iter_empty_heap() { + let mut vec: InlineVec = InlineVec::new(); + for i in 1..=5 { + vec.push(i); + } + let mut iter = vec.into_iter(); + for _ in 0..5 { + iter.next(); + } + assert!(iter.next().is_none()); + } + + #[test] + fn test_binary_search_by_key() { + let mut vec: InlineVec<(i32, &str)> = InlineVec::new(); + vec.push((1, "a")); + vec.push((3, "c")); + vec.push((5, "e")); + assert_eq!(vec.binary_search_by_key(&3, |&(k, _)| k), Ok(1)); + assert_eq!(vec.binary_search_by_key(&2, |&(k, _)| k), Err(1)); + assert_eq!(vec.binary_search_by_key(&6, |&(k, _)| k), Err(3)); + } + + #[test] + fn test_iter_mut() { + let mut vec: InlineVec = InlineVec::new(); + vec.push(1); + vec.push(2); + vec.push(3); + for val in vec.iter_mut() { + *val *= 10; + } + assert_eq!(&*vec, &[10, 20, 30]); + } + + #[test] + fn test_iter_size_hint() { + let mut vec: InlineVec = InlineVec::new(); + vec.push(1); + vec.push(2); + vec.push(3); + let mut iter = vec.iter(); + assert_eq!(iter.size_hint(), (3, Some(3))); + iter.next(); + assert_eq!(iter.size_hint(), (2, Some(2))); + iter.next(); + iter.next(); + assert_eq!(iter.size_hint(), (0, Some(0))); + } + + #[test] + fn test_exact_size_iterator() { + let mut vec: InlineVec = InlineVec::new(); + vec.push(1); + vec.push(2); + let iter = vec.iter(); + assert_eq!(iter.len(), 2); + } + + #[test] + fn test_into_iter_for_ref() { + let mut vec: InlineVec = InlineVec::new(); + vec.push(1); + vec.push(2); + let collected: alloc::vec::Vec<_> = (&vec).into_iter().copied().collect(); + assert_eq!(collected, alloc::vec![1, 2]); + } + + #[test] + fn test_default() { + let vec: InlineVec = InlineVec::default(); + assert!(vec.is_empty()); + } + + #[test] + fn test_as_slice() { + let mut vec: InlineVec = InlineVec::new(); + vec.push(1); + vec.push(2); + let slice = vec.as_slice(); + assert_eq!(slice, &[1, 2]); + } + + #[test] + fn test_as_mut_slice() { + let mut vec: InlineVec = InlineVec::new(); + vec.push(1); + vec.push(2); + let slice = vec.as_mut_slice(); + slice[0] = 10; + assert_eq!(vec[0], 10); + } + + #[test] + fn test_push_to_heap_then_more() { + let mut vec: InlineVec = InlineVec::new(); + for i in 1..=10 { + vec.push(i); + } + assert!(!vec.is_inline()); + assert_eq!(vec.len(), 10); + } + + #[test] + fn test_deref_empty() { + let vec: InlineVec = InlineVec::new(); + let slice: &[i32] = &vec; + assert!(slice.is_empty()); + } + + #[test] + fn test_deref_mut_empty() { + let mut vec: InlineVec = InlineVec::new(); + let slice: &mut [i32] = &mut vec; + assert!(slice.is_empty()); + } + + #[test] + fn test_deref_one() { + let mut vec: InlineVec = InlineVec::new(); + vec.push(42); + let slice: &[i32] = &vec; + assert_eq!(slice, &[42]); + } + + #[test] + fn test_deref_mut_one() { + let mut vec: InlineVec = InlineVec::new(); + vec.push(42); + let slice: &mut [i32] = &mut vec; + slice[0] = 99; + assert_eq!(vec[0], 99); + } + + #[test] + fn test_deref_heap() { + let mut vec: InlineVec = InlineVec::new(); + for i in 1..=6 { + vec.push(i); + } + let slice: &[i32] = &vec; + assert_eq!(slice, &[1, 2, 3, 4, 5, 6]); + } + + #[test] + fn test_deref_mut_heap() { + let mut vec: InlineVec = InlineVec::new(); + for i in 1..=6 { + vec.push(i); + } + let slice: &mut [i32] = &mut vec; + slice[5] = 60; + assert_eq!(vec[5], 60); + } + + #[test] + fn test_get_empty() { + let vec: InlineVec = InlineVec::new(); + assert!(vec.get(0).is_none()); + } + + #[test] + fn test_get_mut_empty() { + let mut vec: InlineVec = InlineVec::new(); + assert!(vec.get_mut(0).is_none()); + } + + #[test] + fn test_get_one_out_of_bounds() { + let mut vec: InlineVec = InlineVec::new(); + vec.push(1); + assert!(vec.get(1).is_none()); + } + + #[test] + fn test_get_mut_one_out_of_bounds() { + let mut vec: InlineVec = InlineVec::new(); + vec.push(1); + assert!(vec.get_mut(1).is_none()); + } + + #[test] + fn test_len_heap() { + let mut vec: InlineVec = InlineVec::new(); + for i in 1..=10 { + vec.push(i); + } + assert_eq!(vec.len(), 10); + } + + #[test] + fn test_into_iter_one() { + let mut vec: InlineVec = InlineVec::new(); + vec.push(42); + let collected: alloc::vec::Vec<_> = vec.into_iter().collect(); + assert_eq!(collected, alloc::vec![42]); + } + + #[test] + fn test_into_iter_empty() { + let vec: InlineVec = InlineVec::new(); + let collected: alloc::vec::Vec<_> = vec.into_iter().collect(); + assert!(collected.is_empty()); + } } diff --git a/src/app_error/metadata.rs b/src/app_error/metadata.rs index 672fe7e..e5d1f0b 100644 --- a/src/app_error/metadata.rs +++ b/src/app_error/metadata.rs @@ -638,4 +638,149 @@ mod tests { let text = duration_to_string(Duration::from_micros(1500)); assert_eq!(text, "0.0015s"); } + + #[test] + fn duration_to_string_whole_seconds() { + let text = duration_to_string(Duration::from_secs(5)); + assert_eq!(text, "5s"); + } + + #[test] + fn duration_to_string_with_nanos() { + let text = duration_to_string(Duration::new(1, 500_000_000)); + assert_eq!(text, "1.5s"); + } + + #[test] + fn field_value_display_all_types() { + assert_eq!(FieldValue::Str(Cow::Borrowed("hello")).to_string(), "hello"); + assert_eq!(FieldValue::I64(-42).to_string(), "-42"); + assert_eq!(FieldValue::U64(100).to_string(), "100"); + assert_eq!(FieldValue::F64(1.5).to_string(), "1.5"); + assert_eq!(FieldValue::Bool(true).to_string(), "true"); + assert_eq!(FieldValue::Bool(false).to_string(), "false"); + + let uuid = Uuid::nil(); + assert_eq!( + FieldValue::Uuid(uuid).to_string(), + "00000000-0000-0000-0000-000000000000" + ); + + let dur = Duration::from_millis(1500); + assert_eq!(FieldValue::Duration(dur).to_string(), "1.5s"); + + let ip = IpAddr::from(Ipv4Addr::LOCALHOST); + assert_eq!(FieldValue::Ip(ip).to_string(), "127.0.0.1"); + } + + #[cfg(feature = "serde_json")] + #[test] + fn field_value_display_json() { + let value = FieldValue::Json(json!({"key": "value"})); + assert!(value.to_string().contains("key")); + } + + #[test] + fn metadata_extend() { + let mut meta = Metadata::new(); + meta.extend([field::str("a", "1"), field::str("b", "2")]); + assert_eq!(meta.len(), 2); + assert_eq!(meta.get("a"), Some(&FieldValue::Str(Cow::Borrowed("1")))); + assert_eq!(meta.get("b"), Some(&FieldValue::Str(Cow::Borrowed("2")))); + } + + #[test] + fn metadata_get_field() { + let meta = Metadata::from_fields([field::i64("count", 42)]); + let field = meta.get_field("count"); + assert!(field.is_some()); + let field = field.unwrap(); + assert_eq!(field.name(), "count"); + assert_eq!(field.value(), &FieldValue::I64(42)); + } + + #[test] + fn metadata_get_nonexistent() { + let meta = Metadata::new(); + assert!(meta.get("missing").is_none()); + assert!(meta.get_field("missing").is_none()); + assert!(meta.redaction("missing").is_none()); + } + + #[test] + fn metadata_set_redaction() { + let mut meta = Metadata::from_fields([field::str("secret", "value")]); + meta.set_redaction("secret", FieldRedaction::Redact); + assert_eq!(meta.redaction("secret"), Some(FieldRedaction::Redact)); + } + + #[test] + fn metadata_set_redaction_nonexistent() { + let mut meta = Metadata::new(); + meta.set_redaction("missing", FieldRedaction::Redact); + assert!(meta.redaction("missing").is_none()); + } + + #[test] + fn metadata_iter_with_redaction() { + let meta = Metadata::from_fields([ + field::str("public", "value1"), + field::str("password", "secret") + ]); + let items: Vec<_> = meta.iter_with_redaction().collect(); + assert_eq!(items.len(), 2); + let password_item = items.iter().find(|(n, _, _)| *n == "password").unwrap(); + assert_eq!(password_item.2, FieldRedaction::Redact); + } + + #[test] + fn metadata_into_iter() { + let meta = Metadata::from_fields([field::i64("a", 1), field::i64("b", 2)]); + let fields: Vec<_> = meta.into_iter().collect(); + assert_eq!(fields.len(), 2); + } + + #[test] + fn field_default_redaction_non_sensitive() { + let field = field::str("user_id", "abc123"); + assert_eq!(field.redaction(), FieldRedaction::None); + } + + #[test] + fn field_with_redaction() { + let field = field::str("public", "value").with_redaction(FieldRedaction::Hash); + assert_eq!(field.redaction(), FieldRedaction::Hash); + } + + #[test] + fn field_redaction_default() { + assert_eq!(FieldRedaction::default(), FieldRedaction::None); + } + + #[test] + fn field_value_partial_eq() { + let v1 = FieldValue::I64(42); + let v2 = FieldValue::I64(42); + let v3 = FieldValue::I64(43); + assert_eq!(v1, v2); + assert_ne!(v1, v3); + } + + #[test] + fn metadata_len_and_is_empty() { + let empty = Metadata::new(); + assert!(empty.is_empty()); + assert_eq!(empty.len(), 0); + + let meta = Metadata::from_fields([field::i64("x", 1)]); + assert!(!meta.is_empty()); + assert_eq!(meta.len(), 1); + } + + #[test] + fn field_into_value() { + let field = field::u64("count", 100); + let value = field.into_value(); + assert_eq!(value, FieldValue::U64(100)); + } } diff --git a/src/code/app_code.rs b/src/code/app_code.rs index d46b585..bcce29f 100644 --- a/src/code/app_code.rs +++ b/src/code/app_code.rs @@ -447,4 +447,216 @@ mod tests { let err = AppCode::from_str("NOT-A-REAL-CODE").unwrap_err(); assert_eq!(err, ParseAppCodeError); } + + #[test] + fn from_str_parses_all_static_codes() { + let codes = [ + ("NOT_FOUND", AppCode::NotFound), + ("VALIDATION", AppCode::Validation), + ("CONFLICT", AppCode::Conflict), + ("USER_ALREADY_EXISTS", AppCode::UserAlreadyExists), + ("UNAUTHORIZED", AppCode::Unauthorized), + ("FORBIDDEN", AppCode::Forbidden), + ("NOT_IMPLEMENTED", AppCode::NotImplemented), + ("BAD_REQUEST", AppCode::BadRequest), + ("RATE_LIMITED", AppCode::RateLimited), + ("TELEGRAM_AUTH", AppCode::TelegramAuth), + ("INVALID_JWT", AppCode::InvalidJwt), + ("INTERNAL", AppCode::Internal), + ("DATABASE", AppCode::Database), + ("SERVICE", AppCode::Service), + ("CONFIG", AppCode::Config), + ("TURNKEY", AppCode::Turnkey), + ("TIMEOUT", AppCode::Timeout), + ("NETWORK", AppCode::Network), + ("DEPENDENCY_UNAVAILABLE", AppCode::DependencyUnavailable), + ("SERIALIZATION", AppCode::Serialization), + ("DESERIALIZATION", AppCode::Deserialization), + ("EXTERNAL_API", AppCode::ExternalApi), + ("QUEUE", AppCode::Queue), + ("CACHE", AppCode::Cache) + ]; + for (s, expected) in codes { + let parsed = AppCode::from_str(s).expect(s); + assert_eq!(parsed, expected, "mismatch for {s}"); + } + } + + #[test] + fn from_kind_covers_all_variants() { + let mappings = [ + (AppErrorKind::NotFound, AppCode::NotFound), + (AppErrorKind::Validation, AppCode::Validation), + (AppErrorKind::Conflict, AppCode::Conflict), + (AppErrorKind::Unauthorized, AppCode::Unauthorized), + (AppErrorKind::Forbidden, AppCode::Forbidden), + (AppErrorKind::NotImplemented, AppCode::NotImplemented), + (AppErrorKind::BadRequest, AppCode::BadRequest), + (AppErrorKind::RateLimited, AppCode::RateLimited), + (AppErrorKind::TelegramAuth, AppCode::TelegramAuth), + (AppErrorKind::InvalidJwt, AppCode::InvalidJwt), + (AppErrorKind::Internal, AppCode::Internal), + (AppErrorKind::Database, AppCode::Database), + (AppErrorKind::Service, AppCode::Service), + (AppErrorKind::Config, AppCode::Config), + (AppErrorKind::Turnkey, AppCode::Turnkey), + (AppErrorKind::Timeout, AppCode::Timeout), + (AppErrorKind::Network, AppCode::Network), + ( + AppErrorKind::DependencyUnavailable, + AppCode::DependencyUnavailable + ), + (AppErrorKind::Serialization, AppCode::Serialization), + (AppErrorKind::Deserialization, AppCode::Deserialization), + (AppErrorKind::ExternalApi, AppCode::ExternalApi), + (AppErrorKind::Queue, AppCode::Queue), + (AppErrorKind::Cache, AppCode::Cache) + ]; + for (kind, expected) in mappings { + assert_eq!(AppCode::from(kind), expected, "mismatch for {kind:?}"); + } + } + + #[test] + fn is_valid_literal_rejects_empty() { + assert!(AppCode::try_new(String::new()).is_err()); + } + + #[test] + fn is_valid_literal_rejects_leading_underscore() { + assert!(AppCode::try_new(String::from("_INVALID")).is_err()); + } + + #[test] + fn is_valid_literal_rejects_trailing_underscore() { + assert!(AppCode::try_new(String::from("INVALID_")).is_err()); + } + + #[test] + fn is_valid_literal_rejects_double_underscore() { + assert!(AppCode::try_new(String::from("INVALID__CODE")).is_err()); + } + + #[test] + fn is_valid_literal_rejects_lowercase() { + assert!(AppCode::try_new(String::from("invalid_code")).is_err()); + } + + #[test] + fn is_valid_literal_accepts_numbers() { + assert!(AppCode::try_new(String::from("ERROR_404")).is_ok()); + assert!(AppCode::try_new(String::from("E404")).is_ok()); + } + + #[test] + fn serde_roundtrip() { + let code = AppCode::NotFound; + let json = serde_json::to_string(&code).expect("serialize"); + assert_eq!(json, "\"NOT_FOUND\""); + let parsed: AppCode = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(parsed, code); + } + + #[test] + fn serde_custom_code_roundtrip() { + let code = AppCode::new("CUSTOM_ERROR"); + let json = serde_json::to_string(&code).expect("serialize"); + let parsed: AppCode = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(parsed, code); + } + + #[test] + fn serde_deserialize_rejects_invalid() { + let result: Result = serde_json::from_str("\"invalid-code\""); + assert!(result.is_err()); + } + + #[test] + fn parse_error_display() { + let err = ParseAppCodeError; + assert!(!err.to_string().is_empty()); + } + + #[test] + fn debug_impl() { + let code = AppCode::NotFound; + let debug = format!("{:?}", code); + assert!(debug.contains("NotFound") || debug.contains("NOT_FOUND")); + } + + #[test] + fn clone_and_eq() { + let code = AppCode::new("CLONED_CODE"); + let cloned = code.clone(); + assert_eq!(code, cloned); + } + + #[test] + fn hash_impl_same_for_equal_codes() { + use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher} + }; + + let code1 = AppCode::NotFound; + let code2 = AppCode::from_str("NOT_FOUND").unwrap(); + let mut hasher1 = DefaultHasher::new(); + let mut hasher2 = DefaultHasher::new(); + code1.hash(&mut hasher1); + code2.hash(&mut hasher2); + assert_eq!(hasher1.finish(), hasher2.finish()); + } + + #[test] + fn hash_impl_different_for_different_codes() { + use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher} + }; + + let code1 = AppCode::NotFound; + let code2 = AppCode::Internal; + let mut hasher1 = DefaultHasher::new(); + let mut hasher2 = DefaultHasher::new(); + code1.hash(&mut hasher1); + code2.hash(&mut hasher2); + assert_ne!(hasher1.finish(), hasher2.finish()); + } + + #[test] + fn parse_error_source_is_none() { + use core::error::Error; + let err = ParseAppCodeError; + assert!(err.source().is_none()); + } + + #[test] + fn hashset_works_with_app_code() { + use std::collections::HashSet; + let mut set = HashSet::new(); + set.insert(AppCode::NotFound); + set.insert(AppCode::Internal); + set.insert(AppCode::NotFound); + assert_eq!(set.len(), 2); + assert!(set.contains(&AppCode::NotFound)); + } + + #[test] + fn custom_code_ne_builtin() { + let custom = AppCode::new("CUSTOM"); + assert_ne!(custom, AppCode::NotFound); + assert_ne!(custom, AppCode::Internal); + } + + #[test] + fn try_new_with_str_slice() { + let code = AppCode::try_new("SLICE_CODE").unwrap(); + assert_eq!(code.as_str(), "SLICE_CODE"); + } + + #[test] + fn from_str_empty() { + let result = AppCode::from_str(""); + assert!(result.is_err()); + } } diff --git a/src/colored.rs b/src/colored.rs deleted file mode 100644 index 2bb2f23..0000000 --- a/src/colored.rs +++ /dev/null @@ -1,379 +0,0 @@ -// SPDX-FileCopyrightText: 2025 RAprogramm -// -// SPDX-License-Identifier: MIT - -//! Terminal color styling for error output with automatic detection. -//! -//! This module provides zero-cost color styling that automatically detects -//! terminal capabilities and respects environment-based color preferences. -//! -//! # Automatic Color Detection -//! -//! Colors are applied only when all of the following conditions are met: -//! - stderr is connected to a TTY -//! - `NO_COLOR` environment variable is not set -//! - `TERM` is not set to `dumb` -//! - Terminal supports ANSI colors -//! -//! # Platform Support -//! -//! - Linux/Unix: Full ANSI color support -//! - macOS: Full ANSI color support -//! - Windows 10+: Native ANSI support via Windows Terminal -//! - Older Windows: Graceful fallback to monochrome -//! -//! # Color Scheme -//! -//! The color scheme is designed for professional CLI tools: -//! - Critical errors: Red -//! - Warnings: Yellow -//! - Error codes: Cyan (easy to scan) -//! - Messages: Bright white (emphasis) -//! - Source context: Dimmed (secondary information) -//! - Metadata keys: Green (structured data) -//! -//! # Examples -//! -//! ```rust -//! # #[cfg(feature = "colored")] { -//! use masterror::colored::style; -//! -//! let error_text = style::error_kind_critical("ServiceUnavailable"); -//! let code_text = style::error_code("ERR_DB_001"); -//! let msg_text = style::error_message("Database connection failed"); -//! -//! eprintln!("Error: {}", error_text); -//! eprintln!("Code: {}", code_text); -//! eprintln!("{}", msg_text); -//! # } -//! ``` -//! -//! # Integration with Display -//! -//! These functions are designed for use in `Display` implementations: -//! -//! ```rust -//! # #[cfg(feature = "colored")] { -//! use std::fmt; -//! -//! use masterror::colored::style; -//! -//! struct MyError { -//! code: String, -//! message: String -//! } -//! -//! impl fmt::Display for MyError { -//! fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { -//! write!( -//! f, -//! "{}: {}", -//! style::error_code(&self.code), -//! style::error_message(&self.message) -//! ) -//! } -//! } -//! # } -//! ``` -//! -//! # Performance -//! -//! Terminal detection is cached per-process, resulting in negligible overhead. -//! Color styling only allocates when colors are actually applied. - -/// Color styling functions with automatic TTY detection. -/// -/// Each function applies appropriate ANSI color codes when stderr supports -/// colors. When colors are not supported, the original text is returned -/// unchanged. -#[cfg(feature = "std")] -pub mod style { - use owo_colors::{OwoColorize, Stream}; - - /// Style critical error kind text in red. - /// - /// Use this for error kinds that indicate critical failures requiring - /// immediate attention. - /// - /// # Examples - /// - /// ```rust - /// # #[cfg(feature = "colored")] { - /// use masterror::colored::style; - /// - /// let styled = style::error_kind_critical("ServiceUnavailable"); - /// eprintln!("Kind: {}", styled); - /// # } - /// ``` - /// - /// # Color Behavior - /// - /// - TTY: Red text - /// - Non-TTY: Plain text - /// - NO_COLOR=1: Plain text - pub fn error_kind_critical(text: impl AsRef) -> String { - text.as_ref() - .if_supports_color(Stream::Stderr, |t| t.red()) - .to_string() - } - - /// Style warning-level error kind text in yellow. - /// - /// Use this for error kinds that indicate recoverable issues or warnings. - /// - /// # Examples - /// - /// ```rust - /// # #[cfg(feature = "colored")] { - /// use masterror::colored::style; - /// - /// let styled = style::error_kind_warning("BadRequest"); - /// eprintln!("Kind: {}", styled); - /// # } - /// ``` - /// - /// # Color Behavior - /// - /// - TTY: Yellow text - /// - Non-TTY: Plain text - /// - NO_COLOR=1: Plain text - pub fn error_kind_warning(text: impl AsRef) -> String { - text.as_ref() - .if_supports_color(Stream::Stderr, |t| t.yellow()) - .to_string() - } - - /// Style error code text in cyan for easy visual scanning. - /// - /// Use this for machine-readable error codes that users need to reference - /// in documentation or support requests. - /// - /// # Examples - /// - /// ```rust - /// # #[cfg(feature = "colored")] { - /// use masterror::colored::style; - /// - /// let styled = style::error_code("ERR_DATABASE_001"); - /// eprintln!("Code: {}", styled); - /// # } - /// ``` - /// - /// # Color Behavior - /// - /// - TTY: Cyan text - /// - Non-TTY: Plain text - /// - NO_COLOR=1: Plain text - pub fn error_code(text: impl AsRef) -> String { - text.as_ref() - .if_supports_color(Stream::Stderr, |t| t.cyan()) - .to_string() - } - - /// Style error message text in bright white for maximum readability. - /// - /// Use this for the primary error message that describes what went wrong. - /// - /// # Examples - /// - /// ```rust - /// # #[cfg(feature = "colored")] { - /// use masterror::colored::style; - /// - /// let styled = style::error_message("Failed to connect to database"); - /// eprintln!("{}", styled); - /// # } - /// ``` - /// - /// # Color Behavior - /// - /// - TTY: Bright white text - /// - Non-TTY: Plain text - /// - NO_COLOR=1: Plain text - pub fn error_message(text: impl AsRef) -> String { - text.as_ref() - .if_supports_color(Stream::Stderr, |t| t.bright_white()) - .to_string() - } - - /// Style source context text with dimmed appearance. - /// - /// Use this for error source chains and contextual information that is - /// important but secondary to the main error message. - /// - /// # Examples - /// - /// ```rust - /// # #[cfg(feature = "colored")] { - /// use masterror::colored::style; - /// - /// let styled = style::source_context("Caused by: Connection timeout"); - /// eprintln!("{}", styled); - /// # } - /// ``` - /// - /// # Color Behavior - /// - /// - TTY: Dimmed text - /// - Non-TTY: Plain text - /// - NO_COLOR=1: Plain text - pub fn source_context(text: impl AsRef) -> String { - text.as_ref() - .if_supports_color(Stream::Stderr, |t| t.dimmed()) - .to_string() - } - - /// Style metadata key text in green. - /// - /// Use this for structured metadata keys in error context to visually - /// separate keys from values. - /// - /// # Examples - /// - /// ```rust - /// # #[cfg(feature = "colored")] { - /// use masterror::colored::style; - /// - /// let key = style::metadata_key("request_id"); - /// eprintln!("{}: abc123", key); - /// # } - /// ``` - /// - /// # Color Behavior - /// - /// - TTY: Green text - /// - Non-TTY: Plain text - /// - NO_COLOR=1: Plain text - pub fn metadata_key(text: impl AsRef) -> String { - text.as_ref() - .if_supports_color(Stream::Stderr, |t| t.green()) - .to_string() - } -} - -/// No-op styling for no-std builds. -#[cfg(not(feature = "std"))] -pub mod style { - /// Style critical error kind text in red. - pub fn error_kind_critical(text: impl AsRef) -> String { - text.as_ref().to_string() - } - - /// Style warning-level error kind text in yellow. - pub fn error_kind_warning(text: impl AsRef) -> String { - text.as_ref().to_string() - } - - /// Style error code text in cyan for easy visual scanning. - pub fn error_code(text: impl AsRef) -> String { - text.as_ref().to_string() - } - - /// Style error message text in bright white for maximum readability. - pub fn error_message(text: impl AsRef) -> String { - text.as_ref().to_string() - } - - /// Style source context text with dimmed appearance. - pub fn source_context(text: impl AsRef) -> String { - text.as_ref().to_string() - } - - /// Style metadata key text in green. - pub fn metadata_key(text: impl AsRef) -> String { - text.as_ref().to_string() - } -} - -#[cfg(all(test, not(feature = "std")))] -mod nostd_tests { - use super::style; - - #[test] - fn error_kind_critical_returns_plain_text() { - assert_eq!(style::error_kind_critical("test"), "test"); - } - - #[test] - fn error_kind_warning_returns_plain_text() { - assert_eq!(style::error_kind_warning("test"), "test"); - } - - #[test] - fn error_code_returns_plain_text() { - assert_eq!(style::error_code("ERR_001"), "ERR_001"); - } - - #[test] - fn error_message_returns_plain_text() { - assert_eq!(style::error_message("message"), "message"); - } - - #[test] - fn source_context_returns_plain_text() { - assert_eq!(style::source_context("context"), "context"); - } - - #[test] - fn metadata_key_returns_plain_text() { - assert_eq!(style::metadata_key("key"), "key"); - } -} - -#[cfg(all(test, feature = "std"))] -mod tests { - use super::style; - - #[test] - fn error_kind_critical_produces_output() { - let result = style::error_kind_critical("ServiceUnavailable"); - assert!(!result.is_empty()); - assert!(result.contains("ServiceUnavailable")); - } - - #[test] - fn error_kind_warning_produces_output() { - let result = style::error_kind_warning("BadRequest"); - assert!(!result.is_empty()); - assert!(result.contains("BadRequest")); - } - - #[test] - fn error_code_produces_output() { - let result = style::error_code("ERR_001"); - assert!(!result.is_empty()); - assert!(result.contains("ERR_001")); - } - - #[test] - fn error_message_produces_output() { - let result = style::error_message("Connection failed"); - assert!(!result.is_empty()); - assert!(result.contains("Connection failed")); - } - - #[test] - fn source_context_produces_output() { - let result = style::source_context("Caused by: timeout"); - assert!(!result.is_empty()); - assert!(result.contains("Caused by: timeout")); - } - - #[test] - fn metadata_key_produces_output() { - let result = style::metadata_key("request_id"); - assert!(!result.is_empty()); - assert!(result.contains("request_id")); - } - - #[test] - fn style_functions_preserve_content() { - let input = "test content with special chars: äöü"; - assert!(style::error_kind_critical(input).contains(input)); - assert!(style::error_kind_warning(input).contains(input)); - assert!(style::error_code(input).contains(input)); - assert!(style::error_message(input).contains(input)); - assert!(style::source_context(input).contains(input)); - assert!(style::metadata_key(input).contains(input)); - } -} diff --git a/src/colored/mod.rs b/src/colored/mod.rs new file mode 100644 index 0000000..9b8c0c2 --- /dev/null +++ b/src/colored/mod.rs @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Terminal color styling for error output with automatic detection. +//! +//! This module provides zero-cost color styling that automatically detects +//! terminal capabilities and respects environment-based color preferences. +//! +//! # Automatic Color Detection +//! +//! Colors are applied only when all of the following conditions are met: +//! - stderr is connected to a TTY +//! - `NO_COLOR` environment variable is not set +//! - `TERM` is not set to `dumb` +//! - Terminal supports ANSI colors +//! +//! # Platform Support +//! +//! - Linux/Unix: Full ANSI color support +//! - macOS: Full ANSI color support +//! - Windows 10+: Native ANSI support via Windows Terminal +//! - Older Windows: Graceful fallback to monochrome +//! +//! # Color Scheme +//! +//! The color scheme is designed for professional CLI tools: +//! - Critical errors: Red +//! - Warnings: Yellow +//! - Error codes: Cyan (easy to scan) +//! - Messages: Bright white (emphasis) +//! - Source context: Dimmed (secondary information) +//! - Metadata keys: Green (structured data) +//! +//! # Examples +//! +//! ```rust +//! # #[cfg(feature = "colored")] { +//! use masterror::colored::style; +//! +//! let error_text = style::error_kind_critical("ServiceUnavailable"); +//! let code_text = style::error_code("ERR_DB_001"); +//! let msg_text = style::error_message("Database connection failed"); +//! +//! eprintln!("Error: {}", error_text); +//! eprintln!("Code: {}", code_text); +//! eprintln!("{}", msg_text); +//! # } +//! ``` +//! +//! # Integration with Display +//! +//! These functions are designed for use in `Display` implementations: +//! +//! ```rust +//! # #[cfg(feature = "colored")] { +//! use std::fmt; +//! +//! use masterror::colored::style; +//! +//! struct MyError { +//! code: String, +//! message: String +//! } +//! +//! impl fmt::Display for MyError { +//! fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { +//! write!( +//! f, +//! "{}: {}", +//! style::error_code(&self.code), +//! style::error_message(&self.message) +//! ) +//! } +//! } +//! # } +//! ``` +//! +//! # Performance +//! +//! Terminal detection is cached per-process, resulting in negligible overhead. +//! Color styling only allocates when colors are actually applied. + +#[cfg(feature = "std")] +mod std_style; + +#[cfg(not(feature = "std"))] +mod nostd_style; + +#[cfg(test)] +mod tests; + +/// Color styling functions with automatic TTY detection. +/// +/// Each function applies appropriate ANSI color codes when stderr supports +/// colors. When colors are not supported, the original text is returned +/// unchanged. +pub mod style { + #[cfg(not(feature = "std"))] + pub use super::nostd_style::*; + #[cfg(feature = "std")] + pub use super::std_style::*; +} diff --git a/src/colored/nostd_style.rs b/src/colored/nostd_style.rs new file mode 100644 index 0000000..2cad6e3 --- /dev/null +++ b/src/colored/nostd_style.rs @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! No-op styling functions for no-std builds. +//! +//! All functions return the input text unchanged, providing API compatibility +//! with the std feature while avoiding any allocations beyond string +//! conversion. + +use alloc::string::{String, ToString}; + +/// Style critical error kind text in red. +pub fn error_kind_critical(text: impl AsRef) -> String { + text.as_ref().to_string() +} + +/// Style warning-level error kind text in yellow. +pub fn error_kind_warning(text: impl AsRef) -> String { + text.as_ref().to_string() +} + +/// Style error code text in cyan for easy visual scanning. +pub fn error_code(text: impl AsRef) -> String { + text.as_ref().to_string() +} + +/// Style error message text in bright white for maximum readability. +pub fn error_message(text: impl AsRef) -> String { + text.as_ref().to_string() +} + +/// Style source context text with dimmed appearance. +pub fn source_context(text: impl AsRef) -> String { + text.as_ref().to_string() +} + +/// Style metadata key text in green. +pub fn metadata_key(text: impl AsRef) -> String { + text.as_ref().to_string() +} + +/// Style hint label. +pub fn hint_label(text: impl AsRef) -> String { + text.as_ref().to_string() +} + +/// Style hint message. +pub fn hint_text(text: impl AsRef) -> String { + text.as_ref().to_string() +} + +/// Style suggestion label. +pub fn suggestion_label(text: impl AsRef) -> String { + text.as_ref().to_string() +} + +/// Style suggestion message. +pub fn suggestion_text(text: impl AsRef) -> String { + text.as_ref().to_string() +} + +/// Style command/code snippet. +pub fn command(text: impl AsRef) -> String { + text.as_ref().to_string() +} + +/// Style documentation link label. +pub fn docs_label(text: impl AsRef) -> String { + text.as_ref().to_string() +} + +/// Style URL. +pub fn url(text: impl AsRef) -> String { + text.as_ref().to_string() +} + +/// Style "see also" label. +pub fn related_label(text: impl AsRef) -> String { + text.as_ref().to_string() +} diff --git a/src/colored/std_style.rs b/src/colored/std_style.rs new file mode 100644 index 0000000..80ee422 --- /dev/null +++ b/src/colored/std_style.rs @@ -0,0 +1,269 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Color styling functions using owo_colors with automatic TTY detection. + +use owo_colors::{OwoColorize, Stream, Style}; + +/// Style critical error kind text in red. +/// +/// Use this for error kinds that indicate critical failures requiring +/// immediate attention. +/// +/// # Examples +/// +/// ```rust +/// # #[cfg(feature = "colored")] { +/// use masterror::colored::style; +/// +/// let styled = style::error_kind_critical("ServiceUnavailable"); +/// eprintln!("Kind: {}", styled); +/// # } +/// ``` +/// +/// # Color Behavior +/// +/// - TTY: Red text +/// - Non-TTY: Plain text +/// - NO_COLOR=1: Plain text +pub fn error_kind_critical(text: impl AsRef) -> String { + text.as_ref() + .if_supports_color(Stream::Stderr, |t| t.red()) + .to_string() +} + +/// Style warning-level error kind text in yellow. +/// +/// Use this for error kinds that indicate recoverable issues or warnings. +/// +/// # Examples +/// +/// ```rust +/// # #[cfg(feature = "colored")] { +/// use masterror::colored::style; +/// +/// let styled = style::error_kind_warning("BadRequest"); +/// eprintln!("Kind: {}", styled); +/// # } +/// ``` +/// +/// # Color Behavior +/// +/// - TTY: Yellow text +/// - Non-TTY: Plain text +/// - NO_COLOR=1: Plain text +pub fn error_kind_warning(text: impl AsRef) -> String { + text.as_ref() + .if_supports_color(Stream::Stderr, |t| t.yellow()) + .to_string() +} + +/// Style error code text in cyan for easy visual scanning. +/// +/// Use this for machine-readable error codes that users need to reference +/// in documentation or support requests. +/// +/// # Examples +/// +/// ```rust +/// # #[cfg(feature = "colored")] { +/// use masterror::colored::style; +/// +/// let styled = style::error_code("ERR_DATABASE_001"); +/// eprintln!("Code: {}", styled); +/// # } +/// ``` +/// +/// # Color Behavior +/// +/// - TTY: Cyan text +/// - Non-TTY: Plain text +/// - NO_COLOR=1: Plain text +pub fn error_code(text: impl AsRef) -> String { + text.as_ref() + .if_supports_color(Stream::Stderr, |t| t.cyan()) + .to_string() +} + +/// Style error message text in bright white for maximum readability. +/// +/// Use this for the primary error message that describes what went wrong. +/// +/// # Examples +/// +/// ```rust +/// # #[cfg(feature = "colored")] { +/// use masterror::colored::style; +/// +/// let styled = style::error_message("Failed to connect to database"); +/// eprintln!("{}", styled); +/// # } +/// ``` +/// +/// # Color Behavior +/// +/// - TTY: Bright white text +/// - Non-TTY: Plain text +/// - NO_COLOR=1: Plain text +pub fn error_message(text: impl AsRef) -> String { + text.as_ref() + .if_supports_color(Stream::Stderr, |t| t.bright_white()) + .to_string() +} + +/// Style source context text with dimmed appearance. +/// +/// Use this for error source chains and contextual information that is +/// important but secondary to the main error message. +/// +/// # Examples +/// +/// ```rust +/// # #[cfg(feature = "colored")] { +/// use masterror::colored::style; +/// +/// let styled = style::source_context("Caused by: Connection timeout"); +/// eprintln!("{}", styled); +/// # } +/// ``` +/// +/// # Color Behavior +/// +/// - TTY: Dimmed text +/// - Non-TTY: Plain text +/// - NO_COLOR=1: Plain text +pub fn source_context(text: impl AsRef) -> String { + text.as_ref() + .if_supports_color(Stream::Stderr, |t| t.dimmed()) + .to_string() +} + +/// Style metadata key text in green. +/// +/// Use this for structured metadata keys in error context to visually +/// separate keys from values. +/// +/// # Examples +/// +/// ```rust +/// # #[cfg(feature = "colored")] { +/// use masterror::colored::style; +/// +/// let key = style::metadata_key("request_id"); +/// eprintln!("{}: abc123", key); +/// # } +/// ``` +/// +/// # Color Behavior +/// +/// - TTY: Green text +/// - Non-TTY: Plain text +/// - NO_COLOR=1: Plain text +pub fn metadata_key(text: impl AsRef) -> String { + text.as_ref() + .if_supports_color(Stream::Stderr, |t| t.green()) + .to_string() +} + +// ───────────────────────────────────────────────────────────────────────────── +// Diagnostic styling +// ───────────────────────────────────────────────────────────────────────────── + +/// Style hint label in blue. +/// +/// # Color Behavior +/// +/// - TTY: Blue text +/// - Non-TTY: Plain text +pub fn hint_label(text: impl AsRef) -> String { + text.as_ref() + .if_supports_color(Stream::Stderr, |t| t.blue()) + .to_string() +} + +/// Style hint message in bright blue. +/// +/// # Color Behavior +/// +/// - TTY: Bright blue text +/// - Non-TTY: Plain text +pub fn hint_text(text: impl AsRef) -> String { + text.as_ref() + .if_supports_color(Stream::Stderr, |t| t.bright_blue()) + .to_string() +} + +/// Style suggestion label in magenta. +/// +/// # Color Behavior +/// +/// - TTY: Magenta text +/// - Non-TTY: Plain text +pub fn suggestion_label(text: impl AsRef) -> String { + text.as_ref() + .if_supports_color(Stream::Stderr, |t| t.magenta()) + .to_string() +} + +/// Style suggestion message in bright magenta. +/// +/// # Color Behavior +/// +/// - TTY: Bright magenta text +/// - Non-TTY: Plain text +pub fn suggestion_text(text: impl AsRef) -> String { + text.as_ref() + .if_supports_color(Stream::Stderr, |t| t.bright_magenta()) + .to_string() +} + +/// Style command/code snippet in bold bright white. +/// +/// # Color Behavior +/// +/// - TTY: Bold bright white text +/// - Non-TTY: Plain text +pub fn command(text: impl AsRef) -> String { + let style = Style::new().bold().bright_white(); + text.as_ref() + .if_supports_color(Stream::Stderr, |t| t.style(style)) + .to_string() +} + +/// Style documentation link label in cyan. +/// +/// # Color Behavior +/// +/// - TTY: Cyan text +/// - Non-TTY: Plain text +pub fn docs_label(text: impl AsRef) -> String { + text.as_ref() + .if_supports_color(Stream::Stderr, |t| t.cyan()) + .to_string() +} + +/// Style URL in underlined cyan. +/// +/// # Color Behavior +/// +/// - TTY: Underlined cyan text +/// - Non-TTY: Plain text +pub fn url(text: impl AsRef) -> String { + let style = Style::new().underline().cyan(); + text.as_ref() + .if_supports_color(Stream::Stderr, |t| t.style(style)) + .to_string() +} + +/// Style "see also" label in dimmed text. +/// +/// # Color Behavior +/// +/// - TTY: Dimmed text +/// - Non-TTY: Plain text +pub fn related_label(text: impl AsRef) -> String { + text.as_ref() + .if_supports_color(Stream::Stderr, |t| t.dimmed()) + .to_string() +} diff --git a/src/colored/tests.rs b/src/colored/tests.rs new file mode 100644 index 0000000..4e14a54 --- /dev/null +++ b/src/colored/tests.rs @@ -0,0 +1,291 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Tests for colored styling module. + +#[cfg(feature = "std")] +mod std_tests { + use crate::colored::style::*; + + // ───────────────────────────────────────────────────────────────────────── + // Basic error styling tests + // ───────────────────────────────────────────────────────────────────────── + + #[test] + fn error_kind_critical_produces_output() { + let result = error_kind_critical("ServiceUnavailable"); + assert!(!result.is_empty()); + assert!(result.contains("ServiceUnavailable")); + } + + #[test] + fn error_kind_warning_produces_output() { + let result = error_kind_warning("BadRequest"); + assert!(!result.is_empty()); + assert!(result.contains("BadRequest")); + } + + #[test] + fn error_code_produces_output() { + let result = error_code("ERR_001"); + assert!(!result.is_empty()); + assert!(result.contains("ERR_001")); + } + + #[test] + fn error_message_produces_output() { + let result = error_message("Connection failed"); + assert!(!result.is_empty()); + assert!(result.contains("Connection failed")); + } + + #[test] + fn source_context_produces_output() { + let result = source_context("Caused by: timeout"); + assert!(!result.is_empty()); + assert!(result.contains("Caused by: timeout")); + } + + #[test] + fn metadata_key_produces_output() { + let result = metadata_key("request_id"); + assert!(!result.is_empty()); + assert!(result.contains("request_id")); + } + + // ───────────────────────────────────────────────────────────────────────── + // Diagnostic styling tests + // ───────────────────────────────────────────────────────────────────────── + + #[test] + fn hint_label_produces_output() { + let result = hint_label("hint"); + assert!(!result.is_empty()); + assert!(result.contains("hint")); + } + + #[test] + fn hint_text_produces_output() { + let result = hint_text("Try restarting the service"); + assert!(!result.is_empty()); + assert!(result.contains("Try restarting the service")); + } + + #[test] + fn suggestion_label_produces_output() { + let result = suggestion_label("suggestion"); + assert!(!result.is_empty()); + assert!(result.contains("suggestion")); + } + + #[test] + fn suggestion_text_produces_output() { + let result = suggestion_text("Run cargo clean"); + assert!(!result.is_empty()); + assert!(result.contains("Run cargo clean")); + } + + #[test] + fn command_produces_output() { + let result = command("cargo build --release"); + assert!(!result.is_empty()); + assert!(result.contains("cargo build --release")); + } + + #[test] + fn docs_label_produces_output() { + let result = docs_label("docs"); + assert!(!result.is_empty()); + assert!(result.contains("docs")); + } + + #[test] + fn url_produces_output() { + let result = url("https://docs.rs/masterror"); + assert!(!result.is_empty()); + assert!(result.contains("https://docs.rs/masterror")); + } + + #[test] + fn related_label_produces_output() { + let result = related_label("see also"); + assert!(!result.is_empty()); + assert!(result.contains("see also")); + } + + // ───────────────────────────────────────────────────────────────────────── + // Content preservation tests + // ───────────────────────────────────────────────────────────────────────── + + #[test] + fn all_style_functions_preserve_content() { + let input = "test content with special chars: äöü"; + assert!(error_kind_critical(input).contains(input)); + assert!(error_kind_warning(input).contains(input)); + assert!(error_code(input).contains(input)); + assert!(error_message(input).contains(input)); + assert!(source_context(input).contains(input)); + assert!(metadata_key(input).contains(input)); + } + + #[test] + fn diagnostic_functions_preserve_content() { + let input = "content with unicode: 日本語"; + assert!(hint_label(input).contains(input)); + assert!(hint_text(input).contains(input)); + assert!(suggestion_label(input).contains(input)); + assert!(suggestion_text(input).contains(input)); + assert!(command(input).contains(input)); + assert!(docs_label(input).contains(input)); + assert!(url(input).contains(input)); + assert!(related_label(input).contains(input)); + } + + // ───────────────────────────────────────────────────────────────────────── + // Edge case tests + // ───────────────────────────────────────────────────────────────────────── + + #[test] + fn empty_string_returns_empty() { + assert!(error_kind_critical("").is_empty() || error_kind_critical("").contains("")); + assert!(error_code("").is_empty() || error_code("").contains("")); + assert!(command("").is_empty() || command("").contains("")); + } + + #[test] + fn whitespace_preserved() { + let input = " spaced text "; + assert!(error_message(input).contains(input)); + assert!(hint_text(input).contains(input)); + } + + #[test] + fn newlines_preserved() { + let input = "line1\nline2\nline3"; + assert!(error_message(input).contains(input)); + assert!(suggestion_text(input).contains(input)); + } + + #[test] + fn special_chars_preserved() { + let input = "path/to/file.rs:42:13"; + assert!(error_message(input).contains(input)); + assert!(source_context(input).contains(input)); + } + + #[test] + fn ansi_escape_sequences_in_input_preserved() { + let input = "\x1b[31mred\x1b[0m"; + let result = error_message(input); + assert!(result.contains("red")); + } +} + +#[cfg(not(feature = "std"))] +mod nostd_tests { + use crate::colored::style::*; + + // ───────────────────────────────────────────────────────────────────────── + // Basic styling returns plain text + // ───────────────────────────────────────────────────────────────────────── + + #[test] + fn error_kind_critical_returns_plain_text() { + assert_eq!(error_kind_critical("test"), "test"); + } + + #[test] + fn error_kind_warning_returns_plain_text() { + assert_eq!(error_kind_warning("test"), "test"); + } + + #[test] + fn error_code_returns_plain_text() { + assert_eq!(error_code("ERR_001"), "ERR_001"); + } + + #[test] + fn error_message_returns_plain_text() { + assert_eq!(error_message("message"), "message"); + } + + #[test] + fn source_context_returns_plain_text() { + assert_eq!(source_context("context"), "context"); + } + + #[test] + fn metadata_key_returns_plain_text() { + assert_eq!(metadata_key("key"), "key"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Diagnostic styling returns plain text + // ───────────────────────────────────────────────────────────────────────── + + #[test] + fn hint_label_returns_plain_text() { + assert_eq!(hint_label("hint"), "hint"); + } + + #[test] + fn hint_text_returns_plain_text() { + assert_eq!(hint_text("help text"), "help text"); + } + + #[test] + fn suggestion_label_returns_plain_text() { + assert_eq!(suggestion_label("suggestion"), "suggestion"); + } + + #[test] + fn suggestion_text_returns_plain_text() { + assert_eq!(suggestion_text("try this"), "try this"); + } + + #[test] + fn command_returns_plain_text() { + assert_eq!(command("cargo build"), "cargo build"); + } + + #[test] + fn docs_label_returns_plain_text() { + assert_eq!(docs_label("docs"), "docs"); + } + + #[test] + fn url_returns_plain_text() { + assert_eq!(url("https://example.com"), "https://example.com"); + } + + #[test] + fn related_label_returns_plain_text() { + assert_eq!(related_label("see also"), "see also"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Edge cases + // ───────────────────────────────────────────────────────────────────────── + + #[test] + fn empty_string_returns_empty() { + assert_eq!(error_kind_critical(""), ""); + assert_eq!(error_code(""), ""); + assert_eq!(command(""), ""); + } + + #[test] + fn unicode_preserved() { + let input = "エラー: 日本語テスト"; + assert_eq!(error_message(input), input); + assert_eq!(hint_text(input), input); + } + + #[test] + fn special_chars_preserved() { + let input = "file.rs:42:13 -> error"; + assert_eq!(source_context(input), input); + assert_eq!(suggestion_text(input), input); + } +} diff --git a/src/convert/config.rs b/src/convert/config.rs index 2432b49..bc79f48 100644 --- a/src/convert/config.rs +++ b/src/convert/config.rs @@ -116,4 +116,156 @@ mod tests { Some(&FieldValue::Str("message".into())) ); } + + #[test] + fn frozen_error_maps_correctly() { + let err = ConfigError::Frozen; + let app_err = Error::from(err); + assert!(matches!(app_err.kind, AppErrorKind::Config)); + assert_eq!( + app_err.metadata().get("config.phase"), + Some(&FieldValue::Str("frozen".into())) + ); + } + + #[test] + fn not_found_error_captures_key() { + let err = ConfigError::NotFound("database.url".into()); + let app_err = Error::from(err); + assert!(matches!(app_err.kind, AppErrorKind::Config)); + let metadata = app_err.metadata(); + assert_eq!( + metadata.get("config.phase"), + Some(&FieldValue::Str("not_found".into())) + ); + assert_eq!( + metadata.get("config.key"), + Some(&FieldValue::Str("database.url".into())) + ); + } + + #[test] + fn path_parse_error_maps_correctly() { + let err = ConfigError::PathParse { + cause: Box::new(std::io::Error::other("invalid path")) + }; + let app_err = Error::from(err); + assert!(matches!(app_err.kind, AppErrorKind::Config)); + assert_eq!( + app_err.metadata().get("config.phase"), + Some(&FieldValue::Str("path_parse".into())) + ); + } + + #[test] + fn file_parse_error_without_uri() { + let err = ConfigError::FileParse { + uri: None, + cause: Box::new(std::io::Error::other("disk")) + }; + let app_err = Error::from(err); + assert!(matches!(app_err.kind, AppErrorKind::Config)); + assert_eq!( + app_err.metadata().get("config.phase"), + Some(&FieldValue::Str("file_parse".into())) + ); + assert!(app_err.metadata().get("config.uri").is_none()); + } + + #[test] + fn file_parse_error_with_uri() { + let err = ConfigError::FileParse { + uri: Some("/etc/app/config.toml".into()), + cause: Box::new(std::io::Error::other("disk")) + }; + let app_err = Error::from(err); + assert!(matches!(app_err.kind, AppErrorKind::Config)); + let metadata = app_err.metadata(); + assert_eq!( + metadata.get("config.phase"), + Some(&FieldValue::Str("file_parse".into())) + ); + assert_eq!( + metadata.get("config.uri"), + Some(&FieldValue::Str("/etc/app/config.toml".into())) + ); + } + + #[test] + fn at_error_with_all_fields() { + let err = ConfigError::At { + origin: Some("env.toml".into()), + key: Some("database.host".into()), + error: Box::new(ConfigError::Message("invalid".into())) + }; + let app_err = Error::from(err); + assert!(matches!(app_err.kind, AppErrorKind::Config)); + let metadata = app_err.metadata(); + assert_eq!( + metadata.get("config.phase"), + Some(&FieldValue::Str("at".into())) + ); + assert_eq!( + metadata.get("config.origin"), + Some(&FieldValue::Str("env.toml".into())) + ); + assert_eq!( + metadata.get("config.key"), + Some(&FieldValue::Str("database.host".into())) + ); + } + + #[test] + fn at_error_without_optional_fields() { + let err = ConfigError::At { + origin: None, + key: None, + error: Box::new(ConfigError::Message("error".into())) + }; + let app_err = Error::from(err); + assert!(matches!(app_err.kind, AppErrorKind::Config)); + let metadata = app_err.metadata(); + assert_eq!( + metadata.get("config.phase"), + Some(&FieldValue::Str("at".into())) + ); + assert!(metadata.get("config.origin").is_none()); + assert!(metadata.get("config.key").is_none()); + } + + #[test] + fn message_error_preserves_message() { + let err = ConfigError::Message("custom error message".into()); + let app_err = Error::from(err); + assert!(matches!(app_err.kind, AppErrorKind::Config)); + let metadata = app_err.metadata(); + assert_eq!( + metadata.get("config.phase"), + Some(&FieldValue::Str("message".into())) + ); + assert_eq!( + metadata.get("config.message"), + Some(&FieldValue::Str("custom error message".into())) + ); + } + + #[test] + fn foreign_error_maps_correctly() { + let foreign_err = Box::new(std::io::Error::other("external")) + as Box; + let err = ConfigError::Foreign(foreign_err); + let app_err = Error::from(err); + assert!(matches!(app_err.kind, AppErrorKind::Config)); + assert_eq!( + app_err.metadata().get("config.phase"), + Some(&FieldValue::Str("foreign".into())) + ); + } + + #[test] + fn error_preserves_source() { + let err = ConfigError::Message("source test".into()); + let app_err = Error::from(err); + assert!(app_err.source_ref().is_some()); + } } diff --git a/src/convert/redis.rs b/src/convert/redis.rs index bce2e3c..9756aab 100644 --- a/src/convert/redis.rs +++ b/src/convert/redis.rs @@ -149,4 +149,128 @@ mod tests { let app_err: Error = redis_err.into(); assert!(matches!(app_err.kind, AppErrorKind::Cache)); } + + #[test] + fn io_error_maps_to_dependency_unavailable() { + let redis_err = RedisError::from((ErrorKind::Io, "connection timeout")); + let app_err: Error = redis_err.into(); + assert!(matches!(app_err.kind, AppErrorKind::DependencyUnavailable)); + } + + #[test] + fn connection_refused_maps_to_dependency_unavailable() { + let redis_err = RedisError::from((ErrorKind::Io, "connection refused")); + let app_err: Error = redis_err.into(); + assert!(matches!(app_err.kind, AppErrorKind::DependencyUnavailable)); + } + + #[test] + fn metadata_contains_category() { + let redis_err = RedisError::from((ErrorKind::Client, "test")); + let app_err: Error = redis_err.into(); + let metadata = app_err.metadata(); + assert!(metadata.get("redis.category").is_some()); + } + + #[test] + fn metadata_contains_timeout_flag() { + let redis_err = RedisError::from((ErrorKind::Client, "test")); + let app_err: Error = redis_err.into(); + let metadata = app_err.metadata(); + assert_eq!( + metadata.get("redis.is_timeout"), + Some(&FieldValue::Bool(false)) + ); + } + + #[test] + fn metadata_contains_cluster_error_flag() { + let redis_err = RedisError::from((ErrorKind::Client, "test")); + let app_err: Error = redis_err.into(); + let metadata = app_err.metadata(); + assert_eq!( + metadata.get("redis.is_cluster_error"), + Some(&FieldValue::Bool(false)) + ); + } + + #[test] + fn metadata_contains_connection_flags() { + let redis_err = RedisError::from((ErrorKind::Client, "test")); + let app_err: Error = redis_err.into(); + let metadata = app_err.metadata(); + assert!(metadata.get("redis.is_connection_refused").is_some()); + assert!(metadata.get("redis.is_connection_dropped").is_some()); + } + + #[test] + fn retry_method_no_retry() { + let (label, after) = retry_method_details(RetryMethod::NoRetry); + assert_eq!(label, "NoRetry"); + assert_eq!(after, None); + } + + #[test] + fn retry_method_retry_immediately() { + let (label, after) = retry_method_details(RetryMethod::RetryImmediately); + assert_eq!(label, "RetryImmediately"); + assert_eq!(after, Some(0)); + } + + #[test] + fn retry_method_ask_redirect() { + let (label, after) = retry_method_details(RetryMethod::AskRedirect); + assert_eq!(label, "AskRedirect"); + assert_eq!(after, Some(0)); + } + + #[test] + fn retry_method_moved_redirect() { + let (label, after) = retry_method_details(RetryMethod::MovedRedirect); + assert_eq!(label, "MovedRedirect"); + assert_eq!(after, Some(0)); + } + + #[test] + fn retry_method_reconnect() { + let (label, after) = retry_method_details(RetryMethod::Reconnect); + assert_eq!(label, "Reconnect"); + assert_eq!(after, Some(1)); + } + + #[test] + fn retry_method_reconnect_from_initial() { + let (label, after) = retry_method_details(RetryMethod::ReconnectFromInitialConnections); + assert_eq!(label, "ReconnectFromInitialConnections"); + assert_eq!(after, Some(1)); + } + + #[test] + fn retry_method_wait_and_retry() { + let (label, after) = retry_method_details(RetryMethod::WaitAndRetry); + assert_eq!(label, "WaitAndRetry"); + assert_eq!(after, Some(2)); + } + + #[test] + fn error_preserves_source() { + let redis_err = RedisError::from((ErrorKind::Client, "test")); + let app_err: Error = redis_err.into(); + assert!(app_err.source_ref().is_some()); + } + + #[test] + fn metadata_contains_retry_method() { + let redis_err = RedisError::from((ErrorKind::Client, "test")); + let app_err: Error = redis_err.into(); + let metadata = app_err.metadata(); + assert!(metadata.get("redis.retry_method").is_some()); + } + + #[test] + fn parse_error_maps_to_cache() { + let redis_err = RedisError::from((ErrorKind::Parse, "invalid response")); + let app_err: Error = redis_err.into(); + assert!(matches!(app_err.kind, AppErrorKind::Cache)); + } } diff --git a/src/convert/sqlx.rs b/src/convert/sqlx.rs index 09ddecb..62f844e 100644 --- a/src/convert/sqlx.rs +++ b/src/convert/sqlx.rs @@ -419,4 +419,286 @@ mod tests_sqlx { } } } + + #[test] + fn pool_timed_out_maps_to_timeout() { + let err: Error = SqlxError::PoolTimedOut.into(); + assert_eq!(err.kind, AppErrorKind::Timeout); + assert_eq!( + err.metadata().get("db.reason"), + Some(&FieldValue::Str("pool_timeout".into())) + ); + assert_eq!(err.retry.map(|r| r.after_seconds), Some(1)); + } + + #[test] + fn pool_closed_maps_to_dependency_unavailable() { + let err: Error = SqlxError::PoolClosed.into(); + assert_eq!(err.kind, AppErrorKind::DependencyUnavailable); + assert_eq!( + err.metadata().get("db.reason"), + Some(&FieldValue::Str("pool_closed".into())) + ); + } + + #[test] + fn worker_crashed_maps_to_dependency_unavailable() { + let err: Error = SqlxError::WorkerCrashed.into(); + assert_eq!(err.kind, AppErrorKind::DependencyUnavailable); + assert_eq!( + err.metadata().get("db.reason"), + Some(&FieldValue::Str("worker_crashed".into())) + ); + assert_eq!(err.retry.map(|r| r.after_seconds), Some(1)); + } + + #[test] + fn configuration_maps_to_config() { + let err: Error = + SqlxError::Configuration(Box::new(std::io::Error::other("bad config"))).into(); + assert_eq!(err.kind, AppErrorKind::Config); + assert_eq!( + err.metadata().get("db.reason"), + Some(&FieldValue::Str("configuration".into())) + ); + } + + #[test] + fn invalid_argument_maps_to_bad_request() { + let err: Error = SqlxError::InvalidArgument("wrong arg".into()).into(); + assert_eq!(err.kind, AppErrorKind::BadRequest); + assert_eq!( + err.metadata().get("db.reason"), + Some(&FieldValue::Str("invalid_argument".into())) + ); + assert_eq!( + err.metadata().get("db.argument"), + Some(&FieldValue::Str("wrong arg".into())) + ); + } + + #[test] + fn column_decode_maps_to_deserialization() { + let err: Error = SqlxError::ColumnDecode { + index: "col1".into(), + source: Box::new(std::io::Error::other("decode failed")) + } + .into(); + assert_eq!(err.kind, AppErrorKind::Deserialization); + assert_eq!( + err.metadata().get("db.reason"), + Some(&FieldValue::Str("column_decode".into())) + ); + assert_eq!( + err.metadata().get("db.column"), + Some(&FieldValue::Str("col1".into())) + ); + } + + #[test] + fn column_not_found_maps_to_internal() { + let err: Error = SqlxError::ColumnNotFound("missing_col".into()).into(); + assert_eq!(err.kind, AppErrorKind::Internal); + assert_eq!( + err.metadata().get("db.reason"), + Some(&FieldValue::Str("column_not_found".into())) + ); + assert_eq!( + err.metadata().get("db.column"), + Some(&FieldValue::Str("missing_col".into())) + ); + } + + #[test] + fn column_index_out_of_bounds_maps_to_internal() { + let err: Error = SqlxError::ColumnIndexOutOfBounds { + index: 5, len: 3 + } + .into(); + assert_eq!(err.kind, AppErrorKind::Internal); + assert_eq!( + err.metadata().get("db.reason"), + Some(&FieldValue::Str("column_index_out_of_bounds".into())) + ); + assert_eq!(err.metadata().get("db.index"), Some(&FieldValue::U64(5))); + assert_eq!(err.metadata().get("db.len"), Some(&FieldValue::U64(3))); + } + + #[test] + fn type_not_found_maps_to_internal() { + let err: Error = SqlxError::TypeNotFound { + type_name: "custom_type".into() + } + .into(); + assert_eq!(err.kind, AppErrorKind::Internal); + assert_eq!( + err.metadata().get("db.reason"), + Some(&FieldValue::Str("type_not_found".into())) + ); + assert_eq!( + err.metadata().get("db.type"), + Some(&FieldValue::Str("custom_type".into())) + ); + } + + #[test] + fn encode_maps_to_serialization() { + let err: Error = + SqlxError::Encode(Box::new(std::io::Error::other("encode failed"))).into(); + assert_eq!(err.kind, AppErrorKind::Serialization); + assert_eq!( + err.metadata().get("db.reason"), + Some(&FieldValue::Str("encode".into())) + ); + } + + #[test] + fn decode_maps_to_deserialization() { + let err: Error = + SqlxError::Decode(Box::new(std::io::Error::other("decode failed"))).into(); + assert_eq!(err.kind, AppErrorKind::Deserialization); + assert_eq!( + err.metadata().get("db.reason"), + Some(&FieldValue::Str("decode".into())) + ); + } + + #[test] + fn protocol_maps_to_dependency_unavailable() { + let err: Error = SqlxError::Protocol("protocol error".into()).into(); + assert_eq!(err.kind, AppErrorKind::DependencyUnavailable); + assert_eq!( + err.metadata().get("db.reason"), + Some(&FieldValue::Str("protocol".into())) + ); + assert_eq!(err.retry.map(|r| r.after_seconds), Some(1)); + } + + #[test] + fn tls_maps_to_network() { + let err: Error = SqlxError::Tls(Box::new(std::io::Error::other("tls failed"))).into(); + assert_eq!(err.kind, AppErrorKind::Network); + assert_eq!( + err.metadata().get("db.reason"), + Some(&FieldValue::Str("tls".into())) + ); + assert_eq!(err.retry.map(|r| r.after_seconds), Some(1)); + } + + #[test] + fn any_driver_error_maps_to_database() { + let err: Error = + SqlxError::AnyDriverError(Box::new(std::io::Error::other("driver error"))).into(); + assert_eq!(err.kind, AppErrorKind::Database); + assert_eq!( + err.metadata().get("db.reason"), + Some(&FieldValue::Str("driver_error".into())) + ); + } + + #[test] + fn invalid_savepoint_maps_to_internal() { + let err: Error = SqlxError::InvalidSavePointStatement.into(); + assert_eq!(err.kind, AppErrorKind::Internal); + assert_eq!( + err.metadata().get("db.reason"), + Some(&FieldValue::Str("invalid_savepoint".into())) + ); + } + + #[test] + fn begin_failed_maps_to_dependency_unavailable() { + let err: Error = SqlxError::BeginFailed.into(); + assert_eq!(err.kind, AppErrorKind::DependencyUnavailable); + assert_eq!( + err.metadata().get("db.reason"), + Some(&FieldValue::Str("begin_failed".into())) + ); + assert_eq!(err.retry.map(|r| r.after_seconds), Some(1)); + } + + #[test] + fn foreign_key_violation_maps_to_conflict() { + let db_err = DummyDbError { + message: "foreign key violation".into(), + code: Some("23503".into()), + constraint: Some("fk_user".into()), + table: Some("orders".into()), + kind: SqlxErrorKind::ForeignKeyViolation + }; + let err: Error = SqlxError::Database(Box::new(db_err)).into(); + assert_eq!(err.kind, AppErrorKind::Conflict); + assert_eq!(err.code, AppCode::Conflict); + } + + #[test] + fn not_null_violation_maps_to_validation() { + let db_err = DummyDbError { + message: "not null violation".into(), + code: Some("23502".into()), + constraint: None, + table: None, + kind: SqlxErrorKind::NotNullViolation + }; + let err: Error = SqlxError::Database(Box::new(db_err)).into(); + assert_eq!(err.kind, AppErrorKind::Validation); + assert_eq!(err.code, AppCode::Validation); + } + + #[test] + fn check_violation_maps_to_validation() { + let db_err = DummyDbError { + message: "check violation".into(), + code: Some("23514".into()), + constraint: Some("positive_amount".into()), + table: None, + kind: SqlxErrorKind::CheckViolation + }; + let err: Error = SqlxError::Database(Box::new(db_err)).into(); + assert_eq!(err.kind, AppErrorKind::Validation); + assert_eq!(err.code, AppCode::Validation); + } + + #[test] + fn lock_not_available_carries_retry_hint() { + let db_err = DummyDbError { + message: "lock not available".into(), + code: Some("55P03".into()), + constraint: None, + table: None, + kind: SqlxErrorKind::Other + }; + let err: Error = SqlxError::Database(Box::new(db_err)).into(); + assert_eq!(err.retry.map(|r| r.after_seconds), Some(1)); + } + + #[test] + fn database_error_without_code() { + let db_err = DummyDbError { + message: "unknown error".into(), + code: None, + constraint: None, + table: None, + kind: SqlxErrorKind::Other + }; + let err: Error = SqlxError::Database(Box::new(db_err)).into(); + assert_eq!(err.kind, AppErrorKind::Database); + assert!(err.metadata().get("db.code").is_none()); + } + + #[test] + fn database_error_captures_table() { + let db_err = DummyDbError { + message: "error".into(), + code: None, + constraint: None, + table: Some("users".into()), + kind: SqlxErrorKind::Other + }; + let err: Error = SqlxError::Database(Box::new(db_err)).into(); + assert_eq!( + err.metadata().get("db.table"), + Some(&FieldValue::Str("users".into())) + ); + } } diff --git a/src/convert/validator.rs b/src/convert/validator.rs index 601d57c..fa0118c 100644 --- a/src/convert/validator.rs +++ b/src/convert/validator.rs @@ -124,6 +124,16 @@ mod tests { val: i32 } + #[derive(Validate)] + struct MultiField { + #[validate(length(min = 5))] + name: String, + #[validate(range(min = 0, max = 100))] + age: i32, + #[validate(email)] + email: String + } + #[test] fn validation_errors_map_to_validation_kind() { let bad = Payload { @@ -137,4 +147,131 @@ mod tests { Some(&FieldValue::U64(1)) ); } + + #[test] + fn multiple_field_errors_captured() { + let bad = MultiField { + name: "abc".into(), + age: -1, + email: "not-an-email".into() + }; + let validation_errors = bad.validate().unwrap_err(); + let err: Error = validation_errors.into(); + assert!(matches!(err.kind, AppErrorKind::Validation)); + let metadata = err.metadata(); + assert_eq!( + metadata.get("validation.field_count"), + Some(&FieldValue::U64(3)) + ); + assert_eq!( + metadata.get("validation.error_count"), + Some(&FieldValue::U64(3)) + ); + } + + #[test] + fn validation_fields_metadata_present() { + let bad = MultiField { + name: "ab".into(), + age: 200, + email: "bad".into() + }; + let validation_errors = bad.validate().unwrap_err(); + let err: Error = validation_errors.into(); + let metadata = err.metadata(); + let fields = metadata.get("validation.fields"); + assert!(fields.is_some()); + if let Some(FieldValue::Str(fields_str)) = fields { + assert!( + fields_str.contains("name") + || fields_str.contains("age") + || fields_str.contains("email") + ); + } + } + + #[test] + fn validation_codes_metadata_present() { + let bad = Payload { + val: -5 + }; + let validation_errors = bad.validate().unwrap_err(); + let err: Error = validation_errors.into(); + let metadata = err.metadata(); + assert!(metadata.get("validation.codes").is_some()); + } + + #[test] + fn error_preserves_source() { + let bad = Payload { + val: 0 + }; + let validation_errors = bad.validate().unwrap_err(); + let err: Error = validation_errors.into(); + assert!(err.source_ref().is_some()); + } + + #[test] + fn single_field_error_has_correct_count() { + let bad = Payload { + val: 0 + }; + let validation_errors = bad.validate().unwrap_err(); + let err: Error = validation_errors.into(); + let metadata = err.metadata(); + assert_eq!( + metadata.get("validation.error_count"), + Some(&FieldValue::U64(1)) + ); + } + + #[test] + fn fields_truncated_to_three() { + use validator::{ValidationError, ValidationErrors}; + let mut errors = ValidationErrors::new(); + errors.add("field1", ValidationError::new("required")); + errors.add("field2", ValidationError::new("required")); + errors.add("field3", ValidationError::new("required")); + errors.add("field4", ValidationError::new("required")); + let err: Error = errors.into(); + let metadata = err.metadata(); + if let Some(FieldValue::Str(fields)) = metadata.get("validation.fields") { + let count = fields.split(',').count(); + assert!(count <= 3); + } + } + + #[test] + fn codes_truncated_to_three() { + use validator::{ValidationError, ValidationErrors}; + let mut errors = ValidationErrors::new(); + let err1 = ValidationError::new("code1"); + let err2 = ValidationError::new("code2"); + let err3 = ValidationError::new("code3"); + let err4 = ValidationError::new("code4"); + errors.add("field1", err1); + errors.add("field2", err2); + errors.add("field3", err3); + errors.add("field4", err4); + let app_err: Error = errors.into(); + let metadata = app_err.metadata(); + if let Some(FieldValue::Str(codes)) = metadata.get("validation.codes") { + let count = codes.split(',').count(); + assert!(count <= 3); + } + } + + #[test] + fn duplicate_codes_filtered() { + use validator::{ValidationError, ValidationErrors}; + let mut errors = ValidationErrors::new(); + errors.add("field1", ValidationError::new("required")); + errors.add("field2", ValidationError::new("required")); + errors.add("field3", ValidationError::new("required")); + let err: Error = errors.into(); + let metadata = err.metadata(); + if let Some(FieldValue::Str(codes)) = metadata.get("validation.codes") { + assert_eq!(codes.as_ref(), "required"); + } + } } diff --git a/src/kind.rs b/src/kind.rs index 821dfc2..dbb865e 100644 --- a/src/kind.rs +++ b/src/kind.rs @@ -359,4 +359,116 @@ mod tests { let output = BadRequest.to_string(); assert!(output.contains("Bad request")); } + + #[test] + fn http_status_all_variants() { + assert_eq!(NotFound.http_status(), 404); + assert_eq!(Validation.http_status(), 422); + assert_eq!(Conflict.http_status(), 409); + assert_eq!(Unauthorized.http_status(), 401); + assert_eq!(Forbidden.http_status(), 403); + assert_eq!(NotImplemented.http_status(), 501); + assert_eq!(Internal.http_status(), 500); + assert_eq!(BadRequest.http_status(), 400); + assert_eq!(TelegramAuth.http_status(), 401); + assert_eq!(InvalidJwt.http_status(), 401); + assert_eq!(Database.http_status(), 500); + assert_eq!(Service.http_status(), 500); + assert_eq!(Config.http_status(), 500); + assert_eq!(Turnkey.http_status(), 500); + assert_eq!(Timeout.http_status(), 504); + assert_eq!(Network.http_status(), 503); + assert_eq!(RateLimited.http_status(), 429); + assert_eq!(DependencyUnavailable.http_status(), 503); + assert_eq!(Serialization.http_status(), 500); + assert_eq!(Deserialization.http_status(), 500); + assert_eq!(ExternalApi.http_status(), 500); + assert_eq!(Queue.http_status(), 500); + assert_eq!(Cache.http_status(), 500); + } + + #[test] + fn label_all_variants() { + assert_eq!(NotFound.label(), "Not found"); + assert_eq!(Validation.label(), "Validation error"); + assert_eq!(Conflict.label(), "Conflict"); + assert_eq!(Unauthorized.label(), "Unauthorized"); + assert_eq!(Forbidden.label(), "Forbidden"); + assert_eq!(NotImplemented.label(), "Not implemented"); + assert_eq!(Internal.label(), "Internal server error"); + assert_eq!(BadRequest.label(), "Bad request"); + assert_eq!(TelegramAuth.label(), "Telegram authentication error"); + assert_eq!(InvalidJwt.label(), "Invalid JWT"); + assert_eq!(Database.label(), "Database error"); + assert_eq!(Service.label(), "Service error"); + assert_eq!(Config.label(), "Configuration error"); + assert_eq!(Turnkey.label(), "Turnkey error"); + assert_eq!(Timeout.label(), "Operation timed out"); + assert_eq!(Network.label(), "Network error"); + assert_eq!(RateLimited.label(), "Rate limit exceeded"); + assert_eq!( + DependencyUnavailable.label(), + "External dependency unavailable" + ); + assert_eq!(Serialization.label(), "Serialization error"); + assert_eq!(Deserialization.label(), "Deserialization error"); + assert_eq!(ExternalApi.label(), "External API error"); + assert_eq!(Queue.label(), "Queue processing error"); + assert_eq!(Cache.label(), "Cache error"); + } + + #[test] + fn display_all_variants() { + assert_eq!(NotFound.to_string(), NotFound.label()); + assert_eq!(Validation.to_string(), Validation.label()); + assert_eq!(Conflict.to_string(), Conflict.label()); + assert_eq!(Unauthorized.to_string(), Unauthorized.label()); + assert_eq!(Forbidden.to_string(), Forbidden.label()); + assert_eq!(NotImplemented.to_string(), NotImplemented.label()); + assert_eq!(Internal.to_string(), Internal.label()); + assert_eq!(BadRequest.to_string(), BadRequest.label()); + assert_eq!(TelegramAuth.to_string(), TelegramAuth.label()); + assert_eq!(InvalidJwt.to_string(), InvalidJwt.label()); + assert_eq!(Database.to_string(), Database.label()); + assert_eq!(Service.to_string(), Service.label()); + assert_eq!(Config.to_string(), Config.label()); + assert_eq!(Turnkey.to_string(), Turnkey.label()); + assert_eq!(Timeout.to_string(), Timeout.label()); + assert_eq!(Network.to_string(), Network.label()); + assert_eq!(RateLimited.to_string(), RateLimited.label()); + assert_eq!( + DependencyUnavailable.to_string(), + DependencyUnavailable.label() + ); + assert_eq!(Serialization.to_string(), Serialization.label()); + assert_eq!(Deserialization.to_string(), Deserialization.label()); + assert_eq!(ExternalApi.to_string(), ExternalApi.label()); + assert_eq!(Queue.to_string(), Queue.label()); + assert_eq!(Cache.to_string(), Cache.label()); + } + + #[test] + fn error_trait_impl() { + use core::error::Error; + let kind = Internal; + let err: &dyn Error = &kind; + assert!(err.source().is_none()); + } + + #[test] + fn clone_and_copy() { + let kind1 = Internal; + let kind2 = kind1; + let kind3 = kind1; + assert_eq!(kind1, kind2); + assert_eq!(kind2, kind3); + } + + #[test] + fn debug_format() { + let debug_str = format!("{:?}", Internal); + assert_eq!(debug_str, "Internal"); + let debug_str = format!("{:?}", NotFound); + assert_eq!(debug_str, "NotFound"); + } } diff --git a/src/lib.rs b/src/lib.rs index e086186..8475c54 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 RAprogramm +// SPDX-FileCopyrightText: 2025-2026 RAprogramm // // SPDX-License-Identifier: MIT @@ -371,6 +371,14 @@ pub mod turnkey; #[cfg_attr(docsrs, doc(cfg(feature = "colored")))] pub mod colored; +/// Rust compiler error explanations and best practices. +/// +/// Provides structured knowledge base for understanding compiler errors +/// with translations (en/ru/ko) and actionable fix suggestions. +#[cfg(feature = "knowledge")] +#[cfg_attr(docsrs, doc(cfg(feature = "knowledge")))] +pub use masterror_knowledge as knowledge; + /// Minimal prelude re-exporting core types for handler signatures. pub mod prelude; @@ -378,9 +386,14 @@ pub mod prelude; pub mod mapping; pub use app_error::{ - AppError, AppResult, Context, DisplayMode, Error, ErrorChain, Field, FieldRedaction, - FieldValue, MessageEditPolicy, Metadata, field + AppError, AppResult, Context, DiagnosticVisibility, Diagnostics, DisplayMode, DocLink, Error, + ErrorChain, Field, FieldRedaction, FieldValue, Hint, MessageEditPolicy, Metadata, Suggestion, + field }; +/// Diagnostic types for enhanced error reporting. +pub mod diagnostics { + pub use crate::app_error::diagnostics::*; +} pub use code::{AppCode, ParseAppCodeError}; pub use kind::AppErrorKind; /// Re-export derive macros so users only depend on this crate. diff --git a/src/response/tests.rs b/src/response/tests.rs index 94d00f9..eae263a 100644 --- a/src/response/tests.rs +++ b/src/response/tests.rs @@ -730,3 +730,171 @@ fn from_borrowed_app_error_redacts_message() { assert!(!resp.message.contains("secret123")); assert_eq!(err.message.as_deref(), Some("database password: secret123")); } + +// --- ProblemJson tests ------------------------------------------------------- + +#[test] +fn problem_json_from_error_response_empty_message() { + let resp = ErrorResponse::new(500, AppCode::Internal, "").expect("status"); + let problem = ProblemJson::from_error_response(resp); + assert!(problem.detail.is_none()); +} + +#[test] +fn problem_json_from_error_response_with_message() { + let resp = ErrorResponse::new(404, AppCode::NotFound, "user not found").expect("status"); + let problem = ProblemJson::from_error_response(resp); + assert_eq!(problem.detail.as_deref(), Some("user not found")); +} + +#[test] +fn problem_json_status_code_valid() { + let problem = ProblemJson::from_app_error(AppError::not_found("missing")); + assert_eq!(problem.status_code(), http::StatusCode::NOT_FOUND); +} + +#[test] +fn problem_json_grpc_code() { + let problem = ProblemJson::from_app_error(AppError::not_found("missing")); + assert!(problem.grpc.is_some()); + let grpc = problem.grpc.unwrap(); + assert_eq!(grpc.name, "NOT_FOUND"); + assert_eq!(grpc.value, 5); +} + +#[test] +fn problem_json_type_uri() { + let problem = ProblemJson::from_app_error(AppError::not_found("missing")); + assert!(problem.type_uri.is_some()); + assert!(problem.type_uri.unwrap().contains("not-found")); +} + +#[test] +fn problem_json_with_metadata() { + use crate::field; + let err = AppError::service("failed").with_field(field::u64("attempt", 3)); + let problem = ProblemJson::from_app_error(err); + assert!(problem.metadata.is_some()); +} + +#[test] +fn problem_json_with_redacted_metadata() { + use crate::field; + let err = AppError::internal("error") + .with_field(field::str("password", "secret")) + .with_field(field::str("user", "john")); + let problem = ProblemJson::from_app_error(err); + assert!(problem.metadata.is_some()); +} + +#[test] +fn problem_json_redacts_metadata_when_redactable() { + use crate::field; + let err = AppError::internal("error") + .with_field(field::str("data", "value")) + .redactable(); + let problem = ProblemJson::from_app_error(err); + assert!(problem.metadata.is_none()); + assert!(problem.detail.is_none()); +} + +#[test] +fn problem_json_from_ref_with_retry() { + let err = AppError::rate_limited("slow down").with_retry_after_secs(60); + let problem = ProblemJson::from_ref(&err); + assert_eq!(problem.retry_after, Some(60)); +} + +#[test] +fn problem_json_from_ref_with_www_authenticate() { + let err = AppError::unauthorized("need auth").with_www_authenticate("Bearer"); + let problem = ProblemJson::from_ref(&err); + assert_eq!(problem.www_authenticate.as_deref(), Some("Bearer")); +} + +#[test] +fn problem_json_metadata_hash_redaction() { + use crate::field; + let mut err = AppError::service("test"); + err = err.with_field(field::str("api_token", "secret_token_value")); + let problem = ProblemJson::from_app_error(err); + let metadata = problem.metadata.expect("metadata"); + let serialized = serde_json::to_string(&metadata).expect("serialize"); + assert!(!serialized.contains("secret_token_value")); +} + +#[test] +fn problem_json_metadata_last4_redaction() { + use crate::field; + let err = AppError::service("test").with_field(field::str("card_number", "4111111111111111")); + let problem = ProblemJson::from_app_error(err); + let metadata = problem.metadata.expect("metadata"); + let serialized = serde_json::to_string(&metadata).expect("serialize"); + assert!(serialized.contains("1111")); + assert!(!serialized.contains("4111111111111111")); +} + +#[test] +fn problem_json_internal_formatter() { + let problem = ProblemJson::from_app_error(AppError::not_found("user")); + let internal = problem.internal(); + let debug = format!("{:?}", internal); + assert!(debug.contains("ProblemJson")); +} + +#[test] +fn problem_metadata_value_from_field_value() { + use std::{borrow::Cow, net::IpAddr, time::Duration}; + + use uuid::Uuid; + + use crate::{FieldValue, ProblemMetadataValue}; + + let str_val = ProblemMetadataValue::from(FieldValue::Str(Cow::Borrowed("test"))); + assert!(matches!(str_val, ProblemMetadataValue::String(_))); + + let i64_val = ProblemMetadataValue::from(FieldValue::I64(-42)); + assert!(matches!(i64_val, ProblemMetadataValue::I64(-42))); + + let u64_val = ProblemMetadataValue::from(FieldValue::U64(100)); + assert!(matches!(u64_val, ProblemMetadataValue::U64(100))); + + let f64_val = ProblemMetadataValue::from(FieldValue::F64(1.5)); + assert!(matches!(f64_val, ProblemMetadataValue::F64(_))); + + let bool_val = ProblemMetadataValue::from(FieldValue::Bool(true)); + assert!(matches!(bool_val, ProblemMetadataValue::Bool(true))); + + let uuid = Uuid::nil(); + let uuid_val = ProblemMetadataValue::from(FieldValue::Uuid(uuid)); + assert!(matches!(uuid_val, ProblemMetadataValue::String(_))); + + let dur_val = ProblemMetadataValue::from(FieldValue::Duration(Duration::from_secs(5))); + assert!(matches!(dur_val, ProblemMetadataValue::Duration { .. })); + + let ip: IpAddr = "127.0.0.1".parse().unwrap(); + let ip_val = ProblemMetadataValue::from(FieldValue::Ip(ip)); + assert!(matches!(ip_val, ProblemMetadataValue::Ip(_))); +} + +#[cfg(feature = "serde_json")] +#[test] +fn problem_metadata_value_from_json() { + use serde_json::json; + + use crate::{FieldValue, ProblemMetadataValue}; + + let json_val = ProblemMetadataValue::from(FieldValue::Json(json!({"key": "value"}))); + assert!(matches!(json_val, ProblemMetadataValue::Json(_))); +} + +#[test] +fn code_mapping_accessors() { + use crate::mapping_for_code; + let mapping = mapping_for_code(&AppCode::NotFound); + assert_eq!(mapping.http_status(), 404); + assert_eq!(mapping.kind(), AppErrorKind::NotFound); + assert!(mapping.problem_type().contains("not-found")); + let grpc = mapping.grpc(); + assert_eq!(grpc.name, "NOT_FOUND"); +} diff --git a/src/result_ext.rs b/src/result_ext.rs index afe9b65..638eeee 100644 --- a/src/result_ext.rs +++ b/src/result_ext.rs @@ -312,4 +312,64 @@ mod tests { assert_eq!(source.kind, AppErrorKind::BadRequest); assert_eq!(source.message.as_deref(), Some("missing flag")); } + + #[test] + fn context_with_owned_string() { + let result: Result<(), DummyError> = Err(DummyError); + let err = result + .context(String::from("owned message")) + .expect_err("err"); + assert_eq!(err.message.as_deref(), Some("owned message")); + } + + #[test] + fn context_preserves_www_authenticate() { + let base = Error::unauthorized("need auth").with_www_authenticate("Bearer realm=\"api\""); + let err = Result::<(), Error>::Err(base) + .context("auth context") + .expect_err("err"); + assert_eq!( + err.www_authenticate.as_deref(), + Some("Bearer realm=\"api\"") + ); + } + + #[test] + fn context_preserves_retry() { + let base = Error::service("retry later").with_retry_after_secs(30); + let err = Result::<(), Error>::Err(base) + .context("wrapped") + .expect_err("err"); + assert!(err.retry.is_some()); + assert_eq!(err.retry.unwrap().after_seconds, 30); + } + + #[cfg(feature = "serde_json")] + #[test] + fn context_preserves_details() { + let base = Error::internal("error").with_details_json(serde_json::json!({"key": "value"})); + let err = Result::<(), Error>::Err(base) + .context("wrapped") + .expect_err("err"); + assert!(err.details.is_some()); + } + + #[test] + fn ctx_with_custom_code() { + let result: Result<(), DummyError> = Err(DummyError); + let err = result + .ctx(|| Context::new(AppErrorKind::NotFound).code(AppCode::NotFound)) + .expect_err("err"); + assert_eq!(err.kind, AppErrorKind::NotFound); + assert_eq!(err.code, AppCode::NotFound); + } + + #[test] + fn ctx_with_category_change() { + let result: Result<(), DummyError> = Err(DummyError); + let err = result + .ctx(|| Context::new(AppErrorKind::Internal).category(AppErrorKind::Service)) + .expect_err("err"); + assert_eq!(err.kind, AppErrorKind::Service); + } }