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 + + [![Crates.io](https://img.shields.io/crates/v/masterror)](https://crates.io/crates/masterror) [![docs.rs](https://img.shields.io/docsrs/masterror)](https://docs.rs/masterror) [![Downloads](https://img.shields.io/crates/d/masterror)](https://crates.io/crates/masterror) @@ -7,8 +9,8 @@ ![License](https://img.shields.io/badge/License-MIT%20or%20Apache--2.0-informational) [![CI](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) -Small, pragmatic error model for API-heavy Rust services. -Core is framework-agnostic; integrations are opt-in via feature flags. +Small, pragmatic error model for API-heavy Rust services. +Core is framework-agnostic; integrations are opt-in via feature flags. Stable categories, conservative HTTP mapping, no `unsafe`. - Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse` @@ -22,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 + + + +[![Crates.io](https://img.shields.io/crates/v/masterror)](https://crates.io/crates/masterror) +[![docs.rs](https://img.shields.io/docsrs/masterror)](https://docs.rs/masterror) +[![Downloads](https://img.shields.io/crates/d/masterror)](https://crates.io/crates/masterror) +![MSRV](https://img.shields.io/badge/MSRV-{{MSRV}}-blue) +![License](https://img.shields.io/badge/License-MIT%20or%20Apache--2.0-informational) +[![CI](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) + +Small, pragmatic error model for API-heavy Rust services. +Core is framework-agnostic; integrations are opt-in via feature flags. +Stable categories, conservative HTTP mapping, no `unsafe`. + +- Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse` +- Optional Axum/Actix integration +- Optional OpenAPI schema (via `utoipa`) +- Conversions from `sqlx`, `reqwest`, `redis`, `validator`, `config`, `tokio` + +--- + +### TL;DR + +~~~toml +[dependencies] +masterror = { version = "{{CRATE_VERSION}}", default-features = false } +# or with features: +# masterror = { version = "{{CRATE_VERSION}}", features = [ +{{FEATURE_SNIPPET}} +# ] } +~~~ + +*Since v0.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(()) +}