From d59cf4552bd3759a81b20205afdc235c08a69851 Mon Sep 17 00:00:00 2001
From: RA <70325462+RAprogramm@users.noreply.github.com>
Date: Wed, 17 Sep 2025 10:52:18 +0700
Subject: [PATCH] Automate README generation from metadata
---
Cargo.lock | 57 +++++-
Cargo.toml | 85 ++++++++
README.md | 125 ++++--------
README.template.md | 231 ++++++++++++++++++++++
build.rs | 21 ++
build/readme.rs | 378 ++++++++++++++++++++++++++++++++++++
masterror-derive/src/lib.rs | 2 +-
tests/readme_sync.rs | 22 +++
8 files changed, 833 insertions(+), 88 deletions(-)
create mode 100644 README.template.md
create mode 100644 build.rs
create mode 100644 build/readme.rs
create mode 100644 tests/readme_sync.rs
diff --git a/Cargo.lock b/Cargo.lock
index aa1a5b2..171a391 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -449,7 +449,7 @@ dependencies = [
"serde",
"serde-untagged",
"serde_json",
- "toml",
+ "toml 0.9.5",
"winnow",
"yaml-rust2",
]
@@ -1508,6 +1508,7 @@ dependencies = [
"telegram-webapp-sdk",
"teloxide-core",
"tokio",
+ "toml 0.8.23",
"tracing",
"utoipa",
"validator",
@@ -2280,6 +2281,15 @@ dependencies = [
"serde",
]
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "serde_spanned"
version = "1.0.0"
@@ -2883,6 +2893,18 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.11",
+ "toml_edit",
+]
+
[[package]]
name = "toml"
version = "0.9.5"
@@ -2890,12 +2912,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8"
dependencies = [
"serde",
- "serde_spanned",
- "toml_datetime",
+ "serde_spanned 1.0.0",
+ "toml_datetime 0.7.0",
"toml_parser",
"winnow",
]
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "toml_datetime"
version = "0.7.0"
@@ -2905,6 +2936,20 @@ dependencies = [
"serde",
]
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap 2.11.1",
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.11",
+ "toml_write",
+ "winnow",
+]
+
[[package]]
name = "toml_parser"
version = "1.0.2"
@@ -2914,6 +2959,12 @@ dependencies = [
"winnow",
]
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
[[package]]
name = "tower"
version = "0.5.2"
diff --git a/Cargo.toml b/Cargo.toml
index f8c3339..84afc47 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,6 +8,7 @@ license = "MIT OR Apache-2.0"
documentation = "https://docs.rs/masterror"
repository = "https://github.com/RAprogramm/masterror"
readme = "README.md"
+build = "build.rs"
categories = ["rust-patterns", "web-programming"]
keywords = ["error", "api", "framework"]
@@ -76,5 +77,89 @@ tokio = { version = "1", features = [
"time",
], default-features = false }
+toml = "0.8"
+
+[build-dependencies]
+serde = { version = "1", features = ["derive"] }
+toml = "0.8"
+
+[package.metadata.masterror.readme]
+feature_order = [
+ "axum",
+ "actix",
+ "openapi",
+ "serde_json",
+ "sqlx",
+ "reqwest",
+ "redis",
+ "validator",
+ "config",
+ "tokio",
+ "multipart",
+ "teloxide",
+ "telegram-webapp-sdk",
+ "frontend",
+ "turnkey",
+]
+feature_snippet_group = 4
+conversion_lines = [
+ "`std::io::Error` → Internal",
+ "`String` → BadRequest",
+ "`sqlx::Error` → NotFound/Database",
+ "`redis::RedisError` → Cache",
+ "`reqwest::Error` → Timeout/Network/ExternalApi",
+ "`axum::extract::multipart::MultipartError` → BadRequest",
+ "`validator::ValidationErrors` → Validation",
+ "`config::ConfigError` → Config",
+ "`tokio::time::error::Elapsed` → Timeout",
+ "`teloxide_core::RequestError` → RateLimited/Network/ExternalApi/Deserialization/Internal",
+ "`telegram_webapp_sdk::utils::validate_init_data::ValidationError` → TelegramAuth",
+]
+
+[package.metadata.masterror.readme.features.axum]
+description = "IntoResponse integration with structured JSON bodies"
+
+[package.metadata.masterror.readme.features.actix]
+description = "Actix Web ResponseError and Responder implementations"
+
+[package.metadata.masterror.readme.features.openapi]
+description = "Generate utoipa OpenAPI schema for ErrorResponse"
+
+[package.metadata.masterror.readme.features.serde_json]
+description = "Attach structured JSON details to AppError"
+
+[package.metadata.masterror.readme.features.sqlx]
+description = "Classify sqlx::Error variants into AppError kinds"
+
+[package.metadata.masterror.readme.features.redis]
+description = "Map redis::RedisError into cache-aware AppError"
+
+[package.metadata.masterror.readme.features.validator]
+description = "Convert validator::ValidationErrors into validation failures"
+
+[package.metadata.masterror.readme.features.config]
+description = "Propagate config::ConfigError as configuration issues"
+
+[package.metadata.masterror.readme.features.multipart]
+description = "Handle axum multipart extraction errors"
+
+[package.metadata.masterror.readme.features.tokio]
+description = "Classify tokio::time::error::Elapsed as timeout"
+
+[package.metadata.masterror.readme.features.reqwest]
+description = "Classify reqwest::Error as timeout/network/external API"
+
+[package.metadata.masterror.readme.features.teloxide]
+description = "Convert teloxide_core::RequestError into domain errors"
+
+[package.metadata.masterror.readme.features."telegram-webapp-sdk"]
+description = "Surface Telegram WebApp validation failures"
+
+[package.metadata.masterror.readme.features.frontend]
+description = "Log to the browser console and convert to JsValue on WASM"
+
+[package.metadata.masterror.readme.features.turnkey]
+description = "Ship Turnkey-specific error taxonomy and conversions"
+
[lib]
crate-type = ["cdylib", "rlib"]
diff --git a/README.md b/README.md
index a920457..ff14d8f 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# masterror · Framework-agnostic application error types
+
+
[](https://crates.io/crates/masterror)
[](https://docs.rs/masterror)
[](https://crates.io/crates/masterror)
@@ -7,8 +9,8 @@

[](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain)
-Small, pragmatic error model for API-heavy Rust services.
-Core is framework-agnostic; integrations are opt-in via feature flags.
+Small, pragmatic error model for API-heavy Rust services.
+Core is framework-agnostic; integrations are opt-in via feature flags.
Stable categories, conservative HTTP mapping, no `unsafe`.
- Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse`
@@ -22,11 +24,13 @@ Stable categories, conservative HTTP mapping, no `unsafe`.
~~~toml
[dependencies]
-masterror = { version = "0.4", default-features = false }
+masterror = { version = "0.4.0", default-features = false }
# or with features:
-# masterror = { version = "0.4", features = [
-# "axum", "actix", "serde_json", "openapi",
-# "sqlx", "reqwest", "redis", "validator", "config", "tokio", "teloxide"
+# masterror = { version = "0.4.0", features = [
+# "axum", "actix", "openapi", "serde_json",
+# "sqlx", "reqwest", "redis", "validator",
+# "config", "tokio", "multipart", "teloxide",
+# "telegram-webapp-sdk", "frontend", "turnkey"
# ] }
~~~
@@ -54,16 +58,18 @@ masterror = { version = "0.4", default-features = false }
~~~toml
[dependencies]
# lean core
-masterror = { version = "0.4", default-features = false }
+masterror = { version = "0.4.0", default-features = false }
# with Axum/Actix + JSON + integrations
-# masterror = { version = "0.4", features = [
-# "axum", "actix", "serde_json", "openapi",
-# "sqlx", "reqwest", "redis", "validator", "config", "tokio", "teloxide"
+# masterror = { version = "0.4.0", features = [
+# "axum", "actix", "openapi", "serde_json",
+# "sqlx", "reqwest", "redis", "validator",
+# "config", "tokio", "multipart", "teloxide",
+# "telegram-webapp-sdk", "frontend", "turnkey"
# ] }
~~~
-**MSRV:** 1.89
+**MSRV:** 1.89
**No unsafe:** forbidden by crate.
@@ -120,64 +126,7 @@ assert_eq!(resp.status, 401);
~~~rust
// features = ["axum", "serde_json"]
-use masterror::{AppError, AppResult};
-use axum::{routing::get, Router};
-
-async fn handler() -> AppResult<&'static str> {
- Err(AppError::forbidden("No access"))
-}
-
-let app = Router::new().route("/demo", get(handler));
-~~~
-
-
-
-
- Actix
-
-~~~rust
-// features = ["actix", "serde_json"]
-use actix_web::{get, App, HttpServer, Responder};
-use masterror::prelude::*;
-
-#[get("/err")]
-async fn err() -> AppResult<&'static str> {
- Err(AppError::forbidden("No access"))
-}
-
-#[get("/payload")]
-async fn payload() -> impl Responder {
- ErrorResponse::new(422, AppCode::Validation, "Validation failed")
- .expect("status")
-}
-~~~
-
-
-
-
-
-
- OpenAPI
-
-~~~toml
-[dependencies]
-masterror = { version = "0.4", features = ["openapi", "serde_json"] }
-utoipa = "5"
-~~~
-
-
-
-
- Browser (WASM)
-
-~~~rust
-// features = ["frontend"]
-use masterror::{AppError, AppErrorKind, AppResult};
-use masterror::frontend::{BrowserConsoleError, BrowserConsoleExt};
-
-fn report() -> AppResult<(), BrowserConsoleError> {
- let err = AppError::bad_request("missing field");
- let payload = err.to_js_value()?;
+...
assert!(payload.is_object());
#[cfg(target_arch = "wasm32")]
@@ -195,13 +144,21 @@ fn report() -> AppResult<(), BrowserConsoleError> {
Feature flags
-- `axum` — IntoResponse
-- `actix` — ResponseError/Responder
-- `openapi` — utoipa schema
-- `serde_json` — JSON details
-- `sqlx`, `redis`, `reqwest`, `validator`, `config`, `tokio`, `multipart`, `teloxide`, `telegram-webapp-sdk`
-- `frontend` — convert errors into `JsValue` and log via `console.error` (WASM)
-- `turnkey` — domain taxonomy and conversions for Turnkey errors
+- `axum` — IntoResponse integration with structured JSON bodies
+- `actix` — Actix Web ResponseError and Responder implementations
+- `openapi` — Generate utoipa OpenAPI schema for ErrorResponse
+- `serde_json` — Attach structured JSON details to AppError
+- `sqlx` — Classify sqlx::Error variants into AppError kinds
+- `reqwest` — Classify reqwest::Error as timeout/network/external API
+- `redis` — Map redis::RedisError into cache-aware AppError
+- `validator` — Convert validator::ValidationErrors into validation failures
+- `config` — Propagate config::ConfigError as configuration issues
+- `tokio` — Classify tokio::time::error::Elapsed as timeout
+- `multipart` — Handle axum multipart extraction errors
+- `teloxide` — Convert teloxide_core::RequestError into domain errors
+- `telegram-webapp-sdk` — Surface Telegram WebApp validation failures
+- `frontend` — Log to the browser console and convert to JsValue on WASM
+- `turnkey` — Ship Turnkey-specific error taxonomy and conversions
@@ -228,13 +185,13 @@ fn report() -> AppResult<(), BrowserConsoleError> {
Minimal core:
~~~toml
-masterror = { version = "0.4", default-features = false }
+masterror = { version = "0.4.0", default-features = false }
~~~
API (Axum + JSON + deps):
~~~toml
-masterror = { version = "0.4", features = [
+masterror = { version = "0.4.0", features = [
"axum", "serde_json", "openapi",
"sqlx", "reqwest", "redis", "validator", "config", "tokio"
] }
@@ -243,7 +200,7 @@ masterror = { version = "0.4", features = [
API (Actix + JSON + deps):
~~~toml
-masterror = { version = "0.4", features = [
+masterror = { version = "0.4.0", features = [
"actix", "serde_json", "openapi",
"sqlx", "reqwest", "redis", "validator", "config", "tokio"
] }
@@ -274,16 +231,16 @@ assert_eq!(app.kind, AppErrorKind::RateLimited);
Migration 0.2 → 0.3
-- Use `ErrorResponse::new(status, AppCode::..., "msg")` instead of legacy
+- Use `ErrorResponse::new(status, AppCode::..., "msg")` instead of legacy
- New helpers: `.with_retry_after_secs`, `.with_retry_after_duration`, `.with_www_authenticate`
-- `ErrorResponse::new_legacy` is temporary shim
+- `ErrorResponse::new_legacy` is temporary shim
Versioning & MSRV
-Semantic versioning. Breaking API/wire contract → major bump.
+Semantic versioning. Breaking API/wire contract → major bump.
MSRV = 1.89 (may raise in minor, never in patch).
@@ -291,8 +248,8 @@ MSRV = 1.89 (may raise in minor, never in patch).
Non-goals
-- Not a general-purpose error aggregator like `anyhow`
-- Not a replacement for your domain errors
+- Not a general-purpose error aggregator like `anyhow`
+- Not a replacement for your domain errors
diff --git a/README.template.md b/README.template.md
new file mode 100644
index 0000000..9342f58
--- /dev/null
+++ b/README.template.md
@@ -0,0 +1,231 @@
+# masterror · Framework-agnostic application error types
+
+
+
+[](https://crates.io/crates/masterror)
+[](https://docs.rs/masterror)
+[](https://crates.io/crates/masterror)
+
+
+[](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain)
+
+Small, pragmatic error model for API-heavy Rust services.
+Core is framework-agnostic; integrations are opt-in via feature flags.
+Stable categories, conservative HTTP mapping, no `unsafe`.
+
+- Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse`
+- Optional Axum/Actix integration
+- Optional OpenAPI schema (via `utoipa`)
+- Conversions from `sqlx`, `reqwest`, `redis`, `validator`, `config`, `tokio`
+
+---
+
+### TL;DR
+
+~~~toml
+[dependencies]
+masterror = { version = "{{CRATE_VERSION}}", default-features = false }
+# or with features:
+# masterror = { version = "{{CRATE_VERSION}}", features = [
+{{FEATURE_SNIPPET}}
+# ] }
+~~~
+
+*Since v0.4.0: optional `frontend` feature for WASM/browser console logging.*
+*Since v0.3.0: stable `AppCode` enum and extended `ErrorResponse` with retry/authentication metadata.*
+
+---
+
+
+ Why this crate?
+
+- **Stable taxonomy.** Small set of `AppErrorKind` categories mapping conservatively to HTTP.
+- **Framework-agnostic.** No assumptions, no `unsafe`, MSRV pinned.
+- **Opt-in integrations.** Zero default features; you enable what you need.
+- **Clean wire contract.** `ErrorResponse { status, code, message, details?, retry?, www_authenticate? }`.
+- **One log at boundary.** Log once with `tracing`.
+- **Less boilerplate.** Built-in conversions, compact prelude.
+- **Consistent workspace.** Same error surface across crates.
+
+
+
+
+ Installation
+
+~~~toml
+[dependencies]
+# lean core
+masterror = { version = "{{CRATE_VERSION}}", default-features = false }
+
+# with Axum/Actix + JSON + integrations
+# masterror = { version = "{{CRATE_VERSION}}", features = [
+{{FEATURE_SNIPPET}}
+# ] }
+~~~
+
+**MSRV:** {{MSRV}}
+**No unsafe:** forbidden by crate.
+
+
+
+
+ Quick start
+
+Create an error:
+
+~~~rust
+use masterror::{AppError, AppErrorKind};
+
+let err = AppError::new(AppErrorKind::BadRequest, "Flag must be set");
+assert!(matches!(err.kind, AppErrorKind::BadRequest));
+~~~
+
+With prelude:
+
+~~~rust
+use masterror::prelude::*;
+
+fn do_work(flag: bool) -> AppResult<()> {
+ if !flag {
+ return Err(AppError::bad_request("Flag must be set"));
+ }
+ Ok(())
+}
+~~~
+
+
+
+
+ Error response payload
+
+~~~rust
+use masterror::{AppError, AppErrorKind, AppCode, ErrorResponse};
+use std::time::Duration;
+
+let app_err = AppError::new(AppErrorKind::Unauthorized, "Token expired");
+let resp: ErrorResponse = (&app_err).into()
+ .with_retry_after_duration(Duration::from_secs(30))
+ .with_www_authenticate(r#"Bearer realm="api", error="invalid_token""#);
+
+assert_eq!(resp.status, 401);
+~~~
+
+
+
+
+ Web framework integrations
+
+
+ Axum
+
+~~~rust
+// features = ["axum", "serde_json"]
+...
+ assert!(payload.is_object());
+
+ #[cfg(target_arch = "wasm32")]
+ err.log_to_browser_console()?;
+
+ Ok(())
+}
+~~~
+
+- On non-WASM targets `log_to_browser_console` returns
+ `BrowserConsoleError::UnsupportedTarget`.
+
+
+
+
+ Feature flags
+
+{{FEATURE_BULLETS}}
+
+
+
+
+ Conversions
+
+{{CONVERSION_BULLETS}}
+
+
+
+
+ Typical setups
+
+Minimal core:
+
+~~~toml
+masterror = { version = "{{CRATE_VERSION}}", default-features = false }
+~~~
+
+API (Axum + JSON + deps):
+
+~~~toml
+masterror = { version = "{{CRATE_VERSION}}", features = [
+ "axum", "serde_json", "openapi",
+ "sqlx", "reqwest", "redis", "validator", "config", "tokio"
+] }
+~~~
+
+API (Actix + JSON + deps):
+
+~~~toml
+masterror = { version = "{{CRATE_VERSION}}", features = [
+ "actix", "serde_json", "openapi",
+ "sqlx", "reqwest", "redis", "validator", "config", "tokio"
+] }
+~~~
+
+
+
+
+ Turnkey
+
+~~~rust
+// features = ["turnkey"]
+use masterror::turnkey::{classify_turnkey_error, TurnkeyError, TurnkeyErrorKind};
+use masterror::{AppError, AppErrorKind};
+
+// Classify a raw SDK/provider error
+let kind = classify_turnkey_error("429 Too Many Requests");
+assert!(matches!(kind, TurnkeyErrorKind::RateLimited));
+
+// Wrap into AppError
+let e = TurnkeyError::new(TurnkeyErrorKind::RateLimited, "throttled upstream");
+let app: AppError = e.into();
+assert_eq!(app.kind, AppErrorKind::RateLimited);
+~~~
+
+
+
+
+ Migration 0.2 → 0.3
+
+- Use `ErrorResponse::new(status, AppCode::..., "msg")` instead of legacy
+- New helpers: `.with_retry_after_secs`, `.with_retry_after_duration`, `.with_www_authenticate`
+- `ErrorResponse::new_legacy` is temporary shim
+
+
+
+
+ Versioning & MSRV
+
+Semantic versioning. Breaking API/wire contract → major bump.
+MSRV = {{MSRV}} (may raise in minor, never in patch).
+
+
+
+
+ Non-goals
+
+- Not a general-purpose error aggregator like `anyhow`
+- Not a replacement for your domain errors
+
+
+
+
+ License
+
+Apache-2.0 OR MIT, at your option.
+
+
diff --git a/build.rs b/build.rs
new file mode 100644
index 0000000..aa96760
--- /dev/null
+++ b/build.rs
@@ -0,0 +1,21 @@
+use std::{env, error::Error, path::PathBuf, process};
+
+#[path = "build/readme.rs"]
+mod readme;
+
+fn main() {
+ if let Err(err) = run() {
+ eprintln!("error: {err}");
+ process::exit(1);
+ }
+}
+
+fn run() -> Result<(), Box> {
+ println!("cargo:rerun-if-changed=Cargo.toml");
+ println!("cargo:rerun-if-changed=README.template.md");
+ println!("cargo:rerun-if-changed=build/readme.rs");
+
+ let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?);
+ readme::sync_readme(&manifest_dir)?;
+ Ok(())
+}
diff --git a/build/readme.rs b/build/readme.rs
new file mode 100644
index 0000000..607be8d
--- /dev/null
+++ b/build/readme.rs
@@ -0,0 +1,378 @@
+use std::{
+ collections::{BTreeMap, BTreeSet},
+ fs, io,
+ path::Path
+};
+
+use serde::Deserialize;
+
+/// Error type describing issues while generating the README file.
+#[derive(Debug)]
+pub enum ReadmeError {
+ /// Wrapper for IO errors.
+ Io(io::Error),
+ /// Wrapper for TOML deserialization errors.
+ Toml(toml::de::Error),
+ /// Required metadata section is missing.
+ MissingMetadata(&'static str),
+ /// One or more crate features do not have documentation metadata.
+ MissingFeatureMetadata(Vec),
+ /// The feature ordering references an unknown feature.
+ UnknownFeatureInOrder(String),
+ /// The feature ordering lists the same feature more than once.
+ DuplicateFeatureInOrder(String),
+ /// Metadata is defined for features that are not part of the manifest.
+ UnknownMetadataFeature(Vec),
+ /// Feature snippet group must be greater than zero.
+ InvalidSnippetGroup,
+ /// Placeholder in the template was not substituted.
+ UnresolvedPlaceholder(String)
+}
+
+impl std::fmt::Display for ReadmeError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Io(err) => write!(f, "IO error: {err}"),
+ Self::Toml(err) => write!(f, "Failed to parse Cargo.toml: {err}"),
+ Self::MissingMetadata(path) => write!(f, "Missing metadata section {path}"),
+ Self::MissingFeatureMetadata(features) => {
+ write!(f, "Missing metadata for features: {}", features.join(", "))
+ }
+ Self::UnknownFeatureInOrder(feature) => {
+ write!(f, "Feature order references unknown feature '{feature}'")
+ }
+ Self::DuplicateFeatureInOrder(feature) => {
+ write!(
+ f,
+ "Feature '{feature}' listed multiple times in feature_order"
+ )
+ }
+ Self::UnknownMetadataFeature(features) => {
+ write!(
+ f,
+ "Metadata defined for unknown features: {}",
+ features.join(", ")
+ )
+ }
+ Self::InvalidSnippetGroup => {
+ write!(f, "feature_snippet_group must be greater than zero")
+ }
+ Self::UnresolvedPlaceholder(name) => {
+ write!(
+ f,
+ "Template placeholder '{{{{{name}}}}}' was not substituted"
+ )
+ }
+ }
+ }
+}
+
+impl std::error::Error for ReadmeError {}
+
+impl From for ReadmeError {
+ fn from(value: io::Error) -> Self {
+ Self::Io(value)
+ }
+}
+
+impl From for ReadmeError {
+ fn from(value: toml::de::Error) -> Self {
+ Self::Toml(value)
+ }
+}
+
+#[derive(Debug, Deserialize)]
+struct Manifest {
+ package: Package,
+ #[serde(default)]
+ features: BTreeMap>
+}
+
+#[derive(Debug, Deserialize)]
+struct Package {
+ version: String,
+ #[serde(rename = "rust-version")]
+ rust_version: Option,
+ #[serde(default)]
+ metadata: Option
+}
+
+#[derive(Debug, Deserialize)]
+struct PackageMetadata {
+ #[serde(default)]
+ masterror: Option
+}
+
+#[derive(Debug, Deserialize)]
+struct MasterrorMetadata {
+ #[serde(default)]
+ readme: Option
+}
+
+#[derive(Clone, Debug, Deserialize)]
+struct ReadmeMetadata {
+ #[serde(default)]
+ feature_order: Vec,
+ #[serde(default)]
+ feature_snippet_group: Option,
+ #[serde(default)]
+ conversion_lines: Vec,
+ #[serde(default)]
+ features: BTreeMap
+}
+
+#[derive(Clone, Debug, Deserialize)]
+struct FeatureMetadata {
+ description: String,
+ #[serde(default)]
+ extra: Vec
+}
+
+#[derive(Clone, Debug)]
+struct FeatureDoc {
+ name: String,
+ description: String,
+ extra: Vec
+}
+
+/// Generate README.md from Cargo metadata and a template.
+///
+/// # Errors
+///
+/// Returns an error if Cargo.toml, the template, or metadata are invalid.
+///
+/// # Examples
+///
+/// ```ignore
+/// use std::path::PathBuf;
+///
+/// let manifest = PathBuf::from("Cargo.toml");
+/// let template = PathBuf::from("README.template.md");
+/// let readme = build::readme::generate_readme(&manifest, &template)?;
+/// ```
+pub fn generate_readme(manifest_path: &Path, template_path: &Path) -> Result {
+ let manifest_raw = fs::read_to_string(manifest_path)?;
+ let manifest: Manifest = toml::from_str(&manifest_raw)?;
+ let Manifest {
+ package,
+ features
+ } = manifest;
+ let Package {
+ version,
+ rust_version,
+ metadata
+ } = package;
+
+ let readme_meta = metadata
+ .and_then(|meta| meta.masterror)
+ .and_then(|meta| meta.readme)
+ .ok_or(ReadmeError::MissingMetadata(
+ "package.metadata.masterror.readme"
+ ))?;
+
+ let feature_docs = collect_feature_docs(&features, &readme_meta)?;
+ let snippet_group = readme_meta.feature_snippet_group.unwrap_or(4);
+ if snippet_group == 0 {
+ return Err(ReadmeError::InvalidSnippetGroup);
+ }
+
+ let template_raw = fs::read_to_string(template_path)?;
+ render_readme(
+ &template_raw,
+ &version,
+ rust_version.as_deref().unwrap_or("unknown"),
+ &feature_docs,
+ snippet_group,
+ &readme_meta.conversion_lines
+ )
+}
+
+/// Synchronize README.md on disk with the generated output.
+///
+/// # Errors
+///
+/// Returns an error if reading or writing files fails or metadata is invalid.
+///
+/// # Examples
+///
+/// ```ignore
+/// use std::path::PathBuf;
+///
+/// let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+/// build::readme::sync_readme(&manifest_dir)?;
+/// ```
+#[cfg_attr(test, allow(dead_code))]
+pub fn sync_readme(manifest_dir: &Path) -> Result<(), ReadmeError> {
+ let manifest_path = manifest_dir.join("Cargo.toml");
+ let template_path = manifest_dir.join("README.template.md");
+ let output_path = manifest_dir.join("README.md");
+ let readme = generate_readme(&manifest_path, &template_path)?;
+ write_if_changed(&output_path, &readme)
+}
+
+fn collect_feature_docs(
+ feature_table: &BTreeMap>,
+ readme_meta: &ReadmeMetadata
+) -> Result, ReadmeError> {
+ let feature_names: BTreeSet = feature_table
+ .keys()
+ .filter(|name| name.as_str() != "default")
+ .cloned()
+ .collect();
+
+ let mut missing_docs = Vec::new();
+ let mut docs_map = BTreeMap::new();
+ for name in &feature_names {
+ if let Some(meta) = readme_meta.features.get(name) {
+ docs_map.insert(
+ name.clone(),
+ FeatureDoc {
+ name: name.clone(),
+ description: meta.description.clone(),
+ extra: meta.extra.clone()
+ }
+ );
+ } else {
+ missing_docs.push(name.clone());
+ }
+ }
+
+ if !missing_docs.is_empty() {
+ return Err(ReadmeError::MissingFeatureMetadata(missing_docs));
+ }
+
+ let unknown_metadata: Vec = readme_meta
+ .features
+ .keys()
+ .filter(|name| name.as_str() != "default" && !feature_names.contains(*name))
+ .cloned()
+ .collect();
+
+ if !unknown_metadata.is_empty() {
+ return Err(ReadmeError::UnknownMetadataFeature(unknown_metadata));
+ }
+
+ let mut ordered = Vec::new();
+ for name in &readme_meta.feature_order {
+ if name == "default" {
+ continue;
+ }
+ if !feature_names.contains(name) {
+ return Err(ReadmeError::UnknownFeatureInOrder(name.clone()));
+ }
+ if let Some(doc) = docs_map.remove(name) {
+ ordered.push(doc);
+ } else {
+ return Err(ReadmeError::DuplicateFeatureInOrder(name.clone()));
+ }
+ }
+
+ ordered.extend(docs_map.into_values());
+ Ok(ordered)
+}
+
+fn render_readme(
+ template: &str,
+ version: &str,
+ rust_version: &str,
+ features: &[FeatureDoc],
+ snippet_group: usize,
+ conversions: &[String]
+) -> Result {
+ let feature_bullets = render_feature_bullets(features);
+ let feature_snippet = render_feature_snippet(features, snippet_group);
+ let conversion_bullets = render_conversion_bullets(conversions);
+
+ let mut rendered = template.replace("{{CRATE_VERSION}}", version);
+ rendered = rendered.replace("{{MSRV}}", rust_version);
+ rendered = rendered.replace("{{FEATURE_BULLETS}}", &feature_bullets);
+ rendered = rendered.replace("{{FEATURE_SNIPPET}}", &feature_snippet);
+ rendered = rendered.replace("{{CONVERSION_BULLETS}}", &conversion_bullets);
+
+ if let Some(name) = find_placeholder(&rendered) {
+ return Err(ReadmeError::UnresolvedPlaceholder(name));
+ }
+
+ Ok(rendered)
+}
+
+fn render_feature_bullets(features: &[FeatureDoc]) -> String {
+ let mut lines = Vec::new();
+ for feature in features {
+ lines.push(format!("- `{}` — {}", feature.name, feature.description));
+ if !feature.extra.is_empty() {
+ for note in &feature.extra {
+ lines.push(format!(" - {note}"));
+ }
+ }
+ }
+ lines.join("\n")
+}
+
+fn render_conversion_bullets(conversions: &[String]) -> String {
+ conversions
+ .iter()
+ .map(|entry| format!("- {entry}"))
+ .collect::>()
+ .join("\n")
+}
+
+fn render_feature_snippet(features: &[FeatureDoc], group_size: usize) -> String {
+ if features.is_empty() {
+ return String::new();
+ }
+
+ let mut items = Vec::with_capacity(features.len());
+ for feature in features {
+ items.push(format!("\"{}\"", feature.name));
+ }
+
+ let chunk_size = group_size;
+ let chunk_count = items.len().div_ceil(chunk_size);
+ let mut lines = Vec::with_capacity(chunk_count);
+ for (index, chunk) in items.chunks(chunk_size).enumerate() {
+ let mut line = String::from("# ");
+ line.push_str(&chunk.join(", "));
+ if index + 1 != chunk_count {
+ line.push(',');
+ }
+ lines.push(line);
+ }
+
+ lines.join("\n")
+}
+
+fn find_placeholder(rendered: &str) -> Option {
+ let start = rendered.find("{{")?;
+ let after = &rendered[start + 2..];
+ if let Some(end_offset) = after.find("}}") {
+ let name = after[..end_offset].trim();
+ if name.is_empty() {
+ Some(String::from(""))
+ } else {
+ Some(name.to_string())
+ }
+ } else {
+ let snippet: String = after.chars().take(32).collect();
+ Some(snippet)
+ }
+}
+
+#[cfg_attr(test, allow(dead_code))]
+fn write_if_changed(path: &Path, contents: &str) -> Result<(), ReadmeError> {
+ match fs::read_to_string(path) {
+ Ok(existing) => {
+ if existing == contents {
+ return Ok(());
+ }
+ }
+ Err(err) => {
+ if err.kind() != io::ErrorKind::NotFound {
+ return Err(ReadmeError::Io(err));
+ }
+ }
+ }
+
+ fs::write(path, contents)?;
+ Ok(())
+}
diff --git a/masterror-derive/src/lib.rs b/masterror-derive/src/lib.rs
index 111b0c2..2d00622 100644
--- a/masterror-derive/src/lib.rs
+++ b/masterror-derive/src/lib.rs
@@ -20,7 +20,7 @@ use syn::{
/// Derive [`std::error::Error`] and [`core::fmt::Display`] for structs and
/// enums.
///
-/// ```
+/// ```ignore
/// use masterror::Error;
///
/// #[derive(Debug, Error)]
diff --git a/tests/readme_sync.rs b/tests/readme_sync.rs
new file mode 100644
index 0000000..06ce3fb
--- /dev/null
+++ b/tests/readme_sync.rs
@@ -0,0 +1,22 @@
+#[path = "../build/readme.rs"]
+mod readme;
+
+use std::{error::Error, fs, io, path::PathBuf};
+
+#[test]
+fn readme_is_in_sync() -> Result<(), Box> {
+ let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+ let manifest_path = manifest_dir.join("Cargo.toml");
+ let template_path = manifest_dir.join("README.template.md");
+ let readme_path = manifest_dir.join("README.md");
+
+ let generated = readme::generate_readme(&manifest_path, &template_path)?;
+ let actual = fs::read_to_string(&readme_path)?;
+
+ if actual != generated {
+ let message = "README.md is out of date; run `cargo build` to regenerate";
+ return Err(io::Error::new(io::ErrorKind::Other, message).into());
+ }
+
+ Ok(())
+}