diff --git a/Cargo.lock b/Cargo.lock index a307650..0a65ee7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -788,6 +788,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "etcetera" version = "0.8.0" @@ -810,6 +820,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1446,6 +1462,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.0" @@ -1514,6 +1536,7 @@ dependencies = [ "sqlx", "telegram-webapp-sdk", "teloxide-core", + "tempfile", "thiserror 1.0.69", "tokio", "toml 0.8.23", @@ -2170,6 +2193,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2786,6 +2822,19 @@ dependencies = [ "uuid", ] +[[package]] +name = "tempfile" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "termcolor" version = "1.4.1" diff --git a/Cargo.toml b/Cargo.toml index c9dfacf..83945d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,7 @@ tokio = { version = "1", features = [ trybuild = "1" toml = "0.9" +tempfile = "3" [build-dependencies] serde = { version = "1", features = ["derive"] } diff --git a/build.rs b/build.rs index aa96760..e090b02 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,9 @@ -use std::{env, error::Error, path::PathBuf, process}; +use std::{ + env, + error::Error, + path::{Component, Path, PathBuf}, + process +}; #[path = "build/readme.rs"] mod readme; @@ -16,6 +21,28 @@ fn run() -> Result<(), Box> { println!("cargo:rerun-if-changed=build/readme.rs"); let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); - readme::sync_readme(&manifest_dir)?; + if is_packaged_manifest(&manifest_dir) { + readme::verify_readme(&manifest_dir)?; + } else { + readme::sync_readme(&manifest_dir)?; + } Ok(()) } + +fn is_packaged_manifest(manifest_dir: &Path) -> bool { + let mut seen_target = false; + for component in manifest_dir.components() { + match component { + Component::Normal(name) => { + if seen_target && name == "package" { + return true; + } + seen_target = name == "target"; + } + _ => { + seen_target = false; + } + } + } + false +} diff --git a/build/readme.rs b/build/readme.rs index 607be8d..089122e 100644 --- a/build/readme.rs +++ b/build/readme.rs @@ -1,7 +1,7 @@ use std::{ collections::{BTreeMap, BTreeSet}, fs, io, - path::Path + path::{Path, PathBuf} }; use serde::Deserialize; @@ -26,7 +26,9 @@ pub enum ReadmeError { /// Feature snippet group must be greater than zero. InvalidSnippetGroup, /// Placeholder in the template was not substituted. - UnresolvedPlaceholder(String) + UnresolvedPlaceholder(String), + /// README on disk differs from generated template output. + OutOfSync { path: PathBuf } } impl std::fmt::Display for ReadmeError { @@ -63,6 +65,15 @@ impl std::fmt::Display for ReadmeError { "Template placeholder '{{{{{name}}}}}' was not substituted" ) } + Self::OutOfSync { + path + } => { + write!( + f, + "README at {} is out of sync; run `cargo build` in the repository root to refresh it", + path.display() + ) + } } } } @@ -210,6 +221,36 @@ pub fn sync_readme(manifest_dir: &Path) -> Result<(), ReadmeError> { write_if_changed(&output_path, &readme) } +/// Verify that README.md matches the generated output without writing to disk. +/// +/// # Errors +/// +/// Returns an error if the README differs from the generated template or if any +/// IO/TOML operations fail. +/// +/// # Examples +/// +/// ```ignore +/// use std::path::Path; +/// +/// let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); +/// build::readme::verify_readme(manifest_dir)?; +/// ``` +pub(crate) fn verify_readme(manifest_dir: &Path) -> Result<(), ReadmeError> { + let manifest_path = manifest_dir.join("Cargo.toml"); + let template_path = manifest_dir.join("README.template.md"); + let output_path = manifest_dir.join("README.md"); + let readme = generate_readme(&manifest_path, &template_path)?; + let actual = fs::read_to_string(&output_path)?; + if actual == readme { + Ok(()) + } else { + Err(ReadmeError::OutOfSync { + path: output_path + }) + } +} + fn collect_feature_docs( feature_table: &BTreeMap>, readme_meta: &ReadmeMetadata diff --git a/tests/readme_sync.rs b/tests/readme_sync.rs index 37dde59..3117d2a 100644 --- a/tests/readme_sync.rs +++ b/tests/readme_sync.rs @@ -3,6 +3,27 @@ mod readme; use std::{error::Error, fs, io, path::PathBuf}; +use tempfile::tempdir; + +const MINIMAL_MANIFEST: &str = r#"[package] +name = "demo" +version = "1.2.3" +rust-version = "1.89" +edition = "2024" + +[features] +default = [] + +[package.metadata.masterror.readme] +feature_order = [] +conversion_lines = [] +feature_snippet_group = 2 + +[package.metadata.masterror.readme.features] +"#; + +const MINIMAL_TEMPLATE: &str = "# Demo\\n\\nVersion {{CRATE_VERSION}}\\nMSRV {{MSRV}}\\n\\nFeatures\\n{{FEATURE_BULLETS}}\\n\\nSnippet\\n{{FEATURE_SNIPPET}}\\n\\nConversions\\n{{CONVERSION_BULLETS}}\\n"; + #[test] fn readme_is_in_sync() -> Result<(), Box> { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -21,3 +42,42 @@ fn readme_is_in_sync() -> Result<(), Box> { Ok(()) } + +#[test] +fn verify_readme_succeeds_when_in_sync() -> Result<(), Box> { + let tmp = tempdir()?; + let manifest_path = tmp.path().join("Cargo.toml"); + let template_path = tmp.path().join("README.template.md"); + let readme_path = tmp.path().join("README.md"); + + fs::write(&manifest_path, MINIMAL_MANIFEST)?; + fs::write(&template_path, MINIMAL_TEMPLATE)?; + let generated = readme::generate_readme(&manifest_path, &template_path)?; + fs::write(&readme_path, generated)?; + + readme::verify_readme(tmp.path()).map_err(|err| io::Error::other(err.to_string()))?; + Ok(()) +} + +#[test] +fn verify_readme_detects_out_of_sync() -> Result<(), Box> { + let tmp = tempdir()?; + let manifest_path = tmp.path().join("Cargo.toml"); + let template_path = tmp.path().join("README.template.md"); + let readme_path = tmp.path().join("README.md"); + + fs::write(&manifest_path, MINIMAL_MANIFEST)?; + fs::write(&template_path, MINIMAL_TEMPLATE)?; + fs::write(&readme_path, "outdated")?; + + match readme::verify_readme(tmp.path()) { + Err(readme::ReadmeError::OutOfSync { + path + }) => { + assert_eq!(path, readme_path); + Ok(()) + } + Err(err) => Err(io::Error::other(format!("unexpected error: {err}")).into()), + Ok(_) => Err(io::Error::other("expected mismatch error").into()) + } +}