diff --git a/Cargo.toml b/Cargo.toml index ce83ccc..77f3556 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,3 +26,6 @@ syn = { version = "0.15", features = ["full"] } toml = "0.4" url = "1.5.1" itertools = "0.7" +regex = "1.0" +# Pulled in via regex. Version 1.2 requires Rust 1.24. +lazy_static = "~1.1" diff --git a/src/lib.rs b/src/lib.rs index 874c1cc..f1f6c86 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,12 +3,15 @@ //! //! When making a release of a Rust project, you typically need to //! adjust some version numbers in your code and documentation. This -//! crate gives you macros that covers the two usual cases where +//! crate gives you macros that covers some typical cases where //! version numbers need updating: //! //! * TOML examples in the `README.md` files that show how to add a //! dependency on your crate. See [`assert_markdown_deps_updated`]. //! +//! * A `Changelog.md` file that should at least mention the current +//! version. See [`assert_contains_regex`]. +//! //! * The [`html_root_url`] attribute that tells other crates where to //! find your documentation. See [`assert_html_root_url_updated`]. //! @@ -44,12 +47,14 @@ //! [`html_root_url`]: https://rust-lang-nursery.github.io/api-guidelines/documentation.html#crate-sets-html_root_url-attribute-c-html-root //! [`assert_markdown_deps_updated`]: macro.assert_markdown_deps_updated.html //! [`assert_html_root_url_updated`]: macro.assert_html_root_url_updated.html +//! [`assert_contains_regex`]: macro.assert_contains_regex.html #![doc(html_root_url = "https://docs.rs/version-sync/0.5.0")] #![deny(missing_docs)] extern crate itertools; extern crate pulldown_cmark; +extern crate regex; extern crate semver_parser; extern crate syn; extern crate toml; @@ -61,6 +66,7 @@ use std::result; use itertools::join; use pulldown_cmark::{Event, Parser, Tag}; +use regex::Regex; use semver_parser::range::parse as parse_request; use semver_parser::range::{Op, VersionReq}; use semver_parser::version::parse as parse_version; @@ -535,6 +541,129 @@ macro_rules! assert_html_root_url_updated { }; } +/// Check that `path` contain the regular expression given by +/// `template`. +/// +/// The placeholders `{name}` and `{version}` will be replaced with +/// `pkg_name` and `pkg_version`, if they are present in `template`. +/// It is okay if `template` do not contain these placeholders. +/// +/// The matching is done in multi-line mode, which means that `^` in +/// the regular expression will match the beginning of any line in the +/// file, not just the very beginning of the file. +/// +/// # Errors +/// +/// If the regular expression cannot be found, an `Err` is returned +/// with a succinct error message. Status information has then already +/// been printed on `stdout`. +pub fn check_contains_regex( + path: &str, + template: &str, + pkg_name: &str, + pkg_version: &str, +) -> Result<()> { + // Expand the optional {name} and {version} placeholders in the + // template. This is almost like + // + // format!(template, name = pkg_name, version = pkg_version) + // + // but allows the user to leave out unnecessary placeholders. + let orig_regex = template + .replace("{name}", pkg_name) + .replace("{version}", pkg_version); + + // We start by constructing a Regex from the original string. This + // ensurs that any errors refer to the string the user passed + // instead of the string we use internally. + let re = match Regex::new(&orig_regex) { + Ok(_) => { + // We now know that the regex is valid, so we can enable + // multi-line mode by prepending "(?m)". + let regex = String::from("(?m)") + &orig_regex; + Regex::new(®ex).unwrap() + } + Err(err) => return Err(format!("could not parse template: {}", err)), + }; + let text = read_file(path).map_err(|err| format!("could not read {}: {}", path, err))?; + + println!("Searching for {:?} in {}...", orig_regex, path); + match re.find(&text) { + Some(m) => { + let line_no = text[..m.start()].lines().count(); + println!("{} (line {}) ... ok", path, line_no + 1); + Ok(()) + } + None => Err(format!("could not find {:?} in {}", orig_regex, path)), + } +} + +/// Assert that versions numbers are up to date via a regex. +/// +/// This macro allows you verify that the current version number is +/// mentioned in a particular file, such as a changelog file. You do +/// this by specifying a regular expression which will be matched +/// against the file. +/// +/// The macro calls [`check_contains_regex`] on the file name given. +/// The package name and current package version is automatically +/// taken from the `$CARGO_PKG_NAME` and `$CARGO_PKG_VERSION` +/// environment variables. These environment variables are +/// automatically set by Cargo when compiling your crate. +/// +/// # Usage +/// +/// The typical way to use this macro is from an integration test: +/// +/// ```rust +/// #[macro_use] +/// extern crate version_sync; +/// +/// #[test] +/// # fn fake_hidden_test_case() {} +/// # // The above function ensures test_readme_mentions_version is +/// # // compiled. +/// fn test_readme_mentions_version() { +/// assert_contains_regex!("README.md", "^### Version {version}"); +/// } +/// +/// # fn main() { +/// # test_readme_mentions_version(); +/// # } +/// ``` +/// +/// Tests are run with the current directory set to directory where +/// your `Cargo.toml` file is, so this will find a `README.md` file +/// next to your `Cargo.toml` file. It will then check that there is a +/// heading mentioning the current version of your crate. +/// +/// The regular expression can contain placeholders which are replaced +/// before the regular expression search begins: +/// +/// * `{version}`: the current version number of your package. +/// * `{name}`: the name of your package. +/// +/// This way you can search for things like `"Latest version of {name} +/// is: {version}"` and make sure you update your READMEs and +/// changelogs consistently. +/// +/// # Panics +/// +/// If the regular expression cannot be found, `panic!` will be +/// invoked and your integration test will fail. +/// +/// [`check_contains_regex`]: fn.check_contains_regex.html +#[macro_export] +macro_rules! assert_contains_regex { + ($path:expr, $format:expr) => { + let pkg_name = env!("CARGO_PKG_NAME"); + let pkg_version = env!("CARGO_PKG_VERSION"); + if let Err(err) = $crate::check_contains_regex($path, $format, pkg_name, pkg_version) { + panic!(err); + } + }; +} + #[cfg(test)] mod tests { use super::*; @@ -967,4 +1096,45 @@ mod tests { ); } } + + mod test_check_contains_regex { + use super::*; + + #[test] + fn bad_regex() { + // Check that the error from a bad pattern doesn't contain + // the (?m) prefix. + assert_eq!( + check_contains_regex("README.md", "Version {version} [ups", "foobar", "1.2.3"), + Err(String::from( + [ + "could not parse template: regex parse error:", + " Version 1.2.3 [ups", + " ^", + "error: unclosed character class" + ] + .join("\n") + )) + ) + } + + #[test] + fn not_found() { + assert_eq!( + check_contains_regex("README.md", "should not be found", "foobar", "1.2.3"), + Err(String::from( + "could not find \"should not be found\" in README.md" + )) + ) + } + + #[test] + fn good_pattern() { + assert_eq!( + check_contains_regex("README.md", "{name}", "version-sync", "1.2.3"), + Ok(()) + ) + } + + } } diff --git a/tests/version-numbers.rs b/tests/version-numbers.rs index 411eea5..997b30d 100644 --- a/tests/version-numbers.rs +++ b/tests/version-numbers.rs @@ -6,6 +6,11 @@ fn test_readme_deps() { assert_markdown_deps_updated!("README.md"); } +#[test] +fn test_readme_changelog() { + assert_contains_regex!("README.md", "^### Version {version}"); +} + #[test] fn test_html_root_url() { assert_html_root_url_updated!("src/lib.rs");