diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 9baf523..bf4329c 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -94,7 +94,7 @@ jobs: run: | git commit --all -m "Update changelog for version ${{ needs.setup.outputs.new-version }}" - - name: Update TOML code blocks + - name: Update README version numbers run: | import fileinput, re, sys @@ -103,6 +103,8 @@ jobs: MAJOR_MINOR = '.'.join(NEW_VERSION.split('.')[:2]) for line in fileinput.input(inplace=True): + line = re.sub(f'https://docs.rs/{NAME}/[^/]+/', + f'https://docs.rs/{NAME}/{NEW_VERSION}/', line) line = re.sub(f'{NAME} = "[^"]+"', f'{NAME} = "{MAJOR_MINOR}"', line) line = re.sub(f'{NAME} = {{ version = "[^"]+"', diff --git a/Cargo.toml b/Cargo.toml index 8823ece..05cc154 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ edition = "2018" default = ["markdown_deps_updated", "html_root_url_updated", "contains_regex"] markdown_deps_updated = ["pulldown-cmark", "semver", "toml"] html_root_url_updated = ["url", "semver", "syn", "proc-macro2"] -contains_regex = ["regex"] +contains_regex = ["regex", "semver"] [dependencies] pulldown-cmark = { version = "0.8", default-features = false, optional = true } diff --git a/README.md b/README.md index 3592aab..bebae7f 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ Contributions will be accepted under the same license. [build-status]: https://github.com/mgeisler/version-sync/actions?query=workflow%3Abuild+branch%3Amaster [codecov]: https://codecov.io/gh/mgeisler/version-sync [crates-io]: https://crates.io/crates/version-sync -[api-docs]: https://docs.rs/version-sync/ +[api-docs]: https://docs.rs/version-sync/0.9.3/ [rust-2018]: https://doc.rust-lang.org/edition-guide/rust-2018/ [mit]: LICENSE [issue-17]: https://github.com/mgeisler/version-sync/issues/17 diff --git a/src/contains_regex.rs b/src/contains_regex.rs index a865c4f..71e1687 100644 --- a/src/contains_regex.rs +++ b/src/contains_regex.rs @@ -1,11 +1,28 @@ #![cfg(feature = "contains_regex")] -use regex::{escape, RegexBuilder}; +use regex::{escape, Regex, RegexBuilder}; +use semver::{Version, VersionReq}; -use crate::helpers::{read_file, Result}; +use crate::helpers::{read_file, version_matches_request, Result}; + +/// Matches a full or partial SemVer version number. +const SEMVER_RE: &str = concat!( + r"(?P0|[1-9]\d*)", + r"(?:\.(?P0|[1-9]\d*)", + r"(?:\.(?P0|[1-9]\d*)", + r"(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)", + r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?", + r"(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?", + r")?", // Close patch plus prerelease and buildmetadata. + r")?", // Close minor. +); /// Check that `path` contain the regular expression given by /// `template`. /// +/// This function only checks that there is at least one match for the +/// `template` given. Use [`check_only_contains_regex`] if you want to +/// ensure that all references to your package version is up to date. +/// /// 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. @@ -47,6 +64,104 @@ pub fn check_contains_regex( } } +/// Check that `path` only contains matches to the regular expression +/// given by `template`. +/// +/// While the [`check_contains_regex`] function verifies the existance +/// of _at least one match_, this function verifies that _all matches_ +/// use the correct version number. Use this if you have a file which +/// should always reference the current version of your package. +/// +/// The check proceeds in two steps: +/// +/// 1. Replace `{version}` in `template` by a regular expression which +/// will match _any_ SemVer version number. This allows, say, +/// `"docs.rs/{name}/{version}/"` to match old and outdated +/// occurrences of your package. +/// +/// 2. Find all matches in the file and check the version number in +/// each match for compatibility with `pkg_version`. It is enough +/// for the version number to be compatible, meaning that +/// `"foo/{version}/bar" matches `"foo/1.2/bar"` when `pkg_version` +/// is `"1.2.3"`. +/// +/// It is an error if there are no matches for `template` at all. +/// +/// 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 any of the matches are incompatible with `pkg_version`, an +/// `Err` is returned with a succinct error message. Status +/// information has then already been printed on `stdout`. +pub fn check_only_contains_regex( + path: &str, + template: &str, + pkg_name: &str, + pkg_version: &str, +) -> Result<()> { + let version = Version::parse(pkg_version) + .map_err(|err| format!("bad package version {:?}: {}", pkg_version, err))?; + + let pattern = template + .replace("{name}", &escape(pkg_name)) + .replace("{version}", SEMVER_RE); + let re = RegexBuilder::new(&pattern) + .multi_line(true) + .build() + .map_err(|err| format!("could not parse template: {}", err))?; + + let semver_re = Regex::new(&SEMVER_RE).unwrap(); + + let text = read_file(path).map_err(|err| format!("could not read {}: {}", path, err))?; + + println!("Searching for \"{}\" in {}...", template, path); + let mut errors = 0; + let mut has_match = false; + + for m in re.find_iter(&text) { + has_match = true; + let line_no = text[..m.start()].lines().count() + 1; + + for semver in semver_re.find_iter(m.as_str()) { + let semver_request = VersionReq::parse(semver.as_str()) + .map_err(|err| format!("could not parse version: {}", err))?; + let result = version_matches_request(&version, &semver_request); + match result { + Err(err) => { + errors += 1; + println!( + "{} (line {}) ... found \"{}\", which does not match version \"{}\": {}", + path, + line_no, + semver.as_str(), + pkg_version, + err + ); + } + Ok(()) => { + println!("{} (line {}) ... ok", path, line_no); + } + } + } + } + + if !has_match { + return Err(format!( + "{} ... found no matches for \"{}\"", + path, template + )); + } + + if errors > 0 { + return Err(format!("{} ... found {} errors", path, errors)); + } + + return Ok(()); +} + #[cfg(test)] mod tests { use super::*; @@ -115,8 +230,6 @@ mod tests { use std::io::Write; let mut file = tempfile::NamedTempFile::new().unwrap(); - println!("Path: {}", file.path().to_str().unwrap()); - file.write_all(b"first line\r\nsecond line\r\nthird line\r\n") .unwrap(); assert_eq!( @@ -124,4 +237,103 @@ mod tests { Ok(()) ) } + + #[test] + fn semver_regex() { + // We anchor the regex here to better match the behavior when + // users call check_only_contains_regex with a string like + // "foo {version}" which also contains more than just + // "{version}". + let re = Regex::new(&format!("^{}$", SEMVER_RE)).unwrap(); + assert!(re.is_match("1.2.3")); + assert!(re.is_match("1.2")); + assert!(re.is_match("1")); + assert!(re.is_match("1.2.3-foo.bar.baz.42+build123.2021.12.11")); + assert!(!re.is_match("01")); + assert!(!re.is_match("01.02.03")); + } + + #[test] + fn only_contains_success() { + use std::io::Write; + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all( + b"first: docs.rs/foo/1.2.3/foo/fn.bar.html + second: docs.rs/foo/1.2.3/foo/fn.baz.html", + ) + .unwrap(); + + assert_eq!( + check_only_contains_regex( + file.path().to_str().unwrap(), + "docs.rs/{name}/{version}/{name}/", + "foo", + "1.2.3" + ), + Ok(()) + ) + } + + #[test] + fn only_contains_success_compatible() { + use std::io::Write; + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all( + b"first: docs.rs/foo/1.2/foo/fn.bar.html + second: docs.rs/foo/1/foo/fn.baz.html", + ) + .unwrap(); + + assert_eq!( + check_only_contains_regex( + file.path().to_str().unwrap(), + "docs.rs/{name}/{version}/{name}/", + "foo", + "1.2.3" + ), + Ok(()) + ) + } + + #[test] + fn only_contains_failure() { + use std::io::Write; + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all( + b"first: docs.rs/foo/1.0.0/foo/ <- error + second: docs.rs/foo/2.0.0/foo/ <- ok + third: docs.rs/foo/3.0.0/foo/ <- error", + ) + .unwrap(); + + assert_eq!( + check_only_contains_regex( + file.path().to_str().unwrap(), + "docs.rs/{name}/{version}/{name}/", + "foo", + "2.0.0" + ), + Err(format!("{} ... found 2 errors", file.path().display())) + ) + } + + #[test] + fn only_contains_fails_if_no_match() { + use std::io::Write; + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all(b"not a match").unwrap(); + + assert_eq!( + check_only_contains_regex( + file.path().to_str().unwrap(), + "docs.rs/{name}/{version}/{name}/", + "foo", + "1.2.3" + ), + Err(format!( + r#"{} ... found no matches for "docs.rs/{{name}}/{{version}}/{{name}}/""#, + file.path().display() + )) + ); + } } diff --git a/src/helpers.rs b/src/helpers.rs index 248f015..ad0d567 100644 --- a/src/helpers.rs +++ b/src/helpers.rs @@ -43,7 +43,11 @@ pub fn indent(text: &str) -> String { } /// Verify that the version range request matches the given version. -#[cfg(any(feature = "html_root_url_updated", feature = "markdown_deps_updated"))] +#[cfg(any( + feature = "html_root_url_updated", + feature = "markdown_deps_updated", + feature = "contains_regex" +))] pub fn version_matches_request( version: &semver::Version, request: &semver::VersionReq, diff --git a/src/lib.rs b/src/lib.rs index 550c5a5..002adf2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,9 @@ //! version. See [`assert_contains_regex`] and //! [`assert_contains_substring`]. //! +//! * A `README.md` file which should only mention the current +//! version. See [`assert_only_contains_regex`]. +//! //! * The [`html_root_url`] attribute that tells other crates where to //! find your documentation. See [`assert_html_root_url_updated`]. //! @@ -65,7 +68,8 @@ //! //! * `markdown_deps_updated` enables [`assert_markdown_deps_updated`]. //! * `html_root_url_updated` enables [`assert_html_root_url_updated`]. -//! * `contains_regex` enables [`assert_contains_regex`]. +//! * `contains_regex` enables [`assert_contains_regex`] and +//! [`assert_only_contains_regex`]. //! //! All of these features are enabled by default. If you disable all //! of them, you can still use [`assert_contains_substring`] to @@ -88,7 +92,7 @@ mod html_root_url; mod markdown_deps; #[cfg(feature = "contains_regex")] -pub use crate::contains_regex::check_contains_regex; +pub use crate::contains_regex::{check_contains_regex, check_only_contains_regex}; pub use crate::contains_substring::check_contains_substring; #[cfg(feature = "html_root_url_updated")] pub use crate::html_root_url::check_html_root_url; @@ -325,3 +329,70 @@ macro_rules! assert_contains_regex { } }; } + +/// Assert that all 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 README file. You do this +/// by specifying a regular expression which will be matched against +/// the contents of the file. +/// +/// The macro calls [`check_only_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. +/// +/// This macro is enabled by the `contains_regex` feature. +/// +/// # Usage +/// +/// The typical way to use this macro is from an integration test: +/// +/// ```rust +/// #[test] +/// # fn fake_hidden_test_case() {} +/// # // The above function ensures test_readme_mentions_version is +/// # // compiled. +/// fn test_readme_links_are_updated() { +/// version_sync::assert_only_contains_regex!("README.md", "docs.rs/{name}/{version}/"); +/// } +/// +/// # fn main() { +/// # test_readme_links_are_updated(); +/// # } +/// ``` +/// +/// 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 all links +/// to docs.rs for your crate contain the current version of your +/// crate. +/// +/// The regular expression can contain placeholders which are replaced +/// as follows: +/// +/// * `{version}`: the version number of your package. +/// * `{name}`: the name of your package. +/// +/// The `{version}` placeholder will match compatible versions, +/// meaning that `{version}` will match all of `1.2.3`, `1.2`, and `1` +/// when your package is at version `1.2.3`. +/// +/// # Panics +/// +/// If the regular expression cannot be found or if some matches are +/// not updated, `panic!` will be invoked and your integration test +/// will fail. +#[macro_export] +#[cfg(feature = "contains_regex")] +macro_rules! assert_only_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_only_contains_regex($path, $format, pkg_name, pkg_version) { + panic!("{}", err); + } + }; +} diff --git a/tests/version-numbers.rs b/tests/version-numbers.rs index 277ad64..bd654cf 100644 --- a/tests/version-numbers.rs +++ b/tests/version-numbers.rs @@ -13,6 +13,12 @@ fn test_readme_changelog() { ); } +#[test] +#[cfg(feature = "contains_regex")] +fn test_readme_links_are_updated() { + version_sync::assert_only_contains_regex!("README.md", "docs.rs/{name}/{version}/"); +} + #[test] #[cfg(feature = "html_root_url_updated")] fn test_html_root_url() {