pax_global_header00006660000000000000000000000064144771306020014516gustar00rootroot0000000000000052 comment=6a4c2333f782652d2f6b859d5dbe349e4529fe9e version-sync-0.9.5/000077500000000000000000000000001447713060200141705ustar00rootroot00000000000000version-sync-0.9.5/.github/000077500000000000000000000000001447713060200155305ustar00rootroot00000000000000version-sync-0.9.5/.github/workflows/000077500000000000000000000000001447713060200175655ustar00rootroot00000000000000version-sync-0.9.5/.github/workflows/build.yml000066400000000000000000000041671447713060200214170ustar00rootroot00000000000000name: build on: pull_request: push: branches: - master schedule: - cron: "30 18 * * 5" # Every Friday at 18:30 UTC env: CARGO_TERM_COLOR: always RUSTFLAGS: -D warnings jobs: build: name: Build on ${{ matrix.os }} (${{ matrix.rust }}) runs-on: ${{ matrix.os }} strategy: matrix: os: - ubuntu-latest - windows-latest rust: - stable - nightly steps: - name: Checkout repository uses: actions/checkout@v2 - name: Install ${{ matrix.rust }} Rust run: rustup default ${{ matrix.rust }} - uses: Swatinem/rust-cache@v2 # Verify that features work by themselves # Features should not interfere with each other - name: Build and test with no default feature run: cargo test --no-default-features - name: Build and test with markdown_deps_updated feature run: cargo test --no-default-features --features markdown_deps_updated - name: Build and test with html_root_url_updated feature run: cargo test --no-default-features --features html_root_url_updated - name: Build and test with contains_regex feature run: cargo test --no-default-features --features contains_regex - name: Build and test with all features run: cargo test --all-features build-documentation: name: Build documentation runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v2 - name: Build documentation and check intra-doc links env: RUSTDOCFLAGS: --deny rustdoc::broken_intra_doc_links run: cargo doc --all-features --no-deps format: name: Format runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v3 # We use an unstable rustfmt feature and we thus need the # nightly channel to enforce the formatting. - name: Setup Rust nightly run: rustup default nightly - name: Install rustfmt run: rustup component add rustfmt - name: Check Formatting uses: dprint/check@v2.2 version-sync-0.9.5/.github/workflows/coverage.yml000066400000000000000000000006121447713060200221020ustar00rootroot00000000000000name: coverage on: push: branches: - master env: CARGO_TERM_COLOR: always jobs: coverage: name: Generate coverage runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v2 - name: Run cargo-tarpaulin uses: actions-rs/tarpaulin@v0.1 - name: Upload to codecov.io uses: codecov/codecov-action@v1 version-sync-0.9.5/.github/workflows/prepare-release.yml000066400000000000000000000005071447713060200233660ustar00rootroot00000000000000name: Prepare Release PR on: push: branches: - "release-*" jobs: prepare-release: uses: mgeisler/rust-release-gh-action/.github/workflows/prepare-release.yml@main with: name: "Martin Geisler" email: "martin@geisler.net" changelog-file: "README.md" changelog-heading-level: "###" version-sync-0.9.5/.github/workflows/publish-crate.yml000066400000000000000000000003741447713060200230560ustar00rootroot00000000000000name: Publish Crate on: push: branches: - master paths: - Cargo.toml jobs: publish: uses: mgeisler/rust-release-gh-action/.github/workflows/publish-crate.yml@main secrets: CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} version-sync-0.9.5/.gitignore000066400000000000000000000000641447713060200161600ustar00rootroot00000000000000/target/ Cargo.lock *~ **/*.rs.bk [._]*.sw? [._]sw? version-sync-0.9.5/Cargo.toml000066400000000000000000000024521447713060200161230ustar00rootroot00000000000000[package] name = "version-sync" version = "0.9.5" authors = ["Martin Geisler "] categories = ["development-tools", "rust-patterns"] documentation = "https://docs.rs/version-sync/" edition = "2021" keywords = ["version"] license = "MIT" readme = "README.md" repository = "https://github.com/mgeisler/version-sync" description = "Crate for ensuring that version numbers in README files and other files are kept in sync with the crate version." [features] 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", "semver"] [dependencies] proc-macro2 = { version = "1.0.36", default-features = false, features = ["span-locations"], optional = true } pulldown-cmark = { version = "0.9.1", default-features = false, optional = true } regex = { version = "1.5.4", default-features = false, features = ["std", "unicode"], optional = true } semver = { version = "1.0.5", optional = true } syn = { version = "2.0.15", default-features = false, features = ["parsing", "printing", "full"], optional = true } toml = { version = "0.7.8", optional = true } url = { version = "2.2.2", optional = true } [dev-dependencies] tempfile = "3.3.0" version-sync-0.9.5/LICENSE000066400000000000000000000020571447713060200152010ustar00rootroot00000000000000MIT License Copyright (c) 2017 Martin Geisler Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. version-sync-0.9.5/README.md000066400000000000000000000213171447713060200154530ustar00rootroot00000000000000# Keep your Version Numbers in Sync with Cargo.toml [![](https://github.com/mgeisler/version-sync/workflows/build/badge.svg)][build-status] [![](https://codecov.io/gh/mgeisler/version-sync/branch/master/graph/badge.svg)][codecov] [![](https://img.shields.io/crates/v/version-sync.svg)][crates-io] [![](https://docs.rs/version-sync/badge.svg)][api-docs] Rust projects typically reference the crate version number in several places, such as the `README.md` file. The version-sync crate makes it easy to add an integration test that checks that `README.md` is updated when the crate version changes. ## Usage Add this to your `Cargo.toml`: ```toml [dev-dependencies] version-sync = "0.9" ``` Then create a `tests/version-numbers.rs` file with: ```rust #[test] fn test_readme_deps() { version_sync::assert_markdown_deps_updated!("README.md"); } #[test] fn test_html_root_url() { version_sync::assert_html_root_url_updated!("src/lib.rs"); } ``` This integration test will ensure that the dependencies mentioned in your `README.md` file are kept in sync with your crate version and that your `html_root_url` points to the correct documentation on docs.rs. If everything is well, the test passes: ``` $ cargo test Finished debug [unoptimized + debuginfo] target(s) in 0.0 secs Running target/debug/deps/version_numbers-504f17c82f1defea running 2 tests test test_readme_deps ... ok test test_html_root_url ... ok test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured ``` If the README or `html_root_url` is out of sync with the crate version, the tests fail. In this example, the crate is called `your-crate` and the version number in `Cargo.toml` has been changed to 0.2.0 while the `README.md` and `html_root_url` still use 0.1.2. The tests now fail and the problematic TOML code and attribute are shown: ``` $ cargo test Finished debug [unoptimized + debuginfo] target(s) in 0.0 secs Running target/debug/deps/version_numbers-f399bac3e468d035 running 2 tests test test_readme_deps ... FAILED test test_html_root_url ... FAILED failures: ---- test_readme_deps stdout ---- Checking code blocks in README.md... README.md (line 20) ... expected minor version 2, found 1 in [dev-dependencies] your-crate = "0.1" thread 'test_readme_deps' panicked at 'dependency errors in README.md', tests/version-numbers.rs:6 note: Run with `RUST_BACKTRACE=1` for a backtrace. ---- test_html_root_url stdout ---- Checking doc attributes in src/lib.rs... src/lib.rs ... expected minor version 2, found 1 in #![doc(html_root_url = "https://docs.rs/your-crate/0.1.2")] thread 'test_html_root_url' panicked at 'html_root_url errors in src/lib.rs', tests/version-numbers.rs:11 failures: test_html_root_url test_readme_deps test result: FAILED. 0 passed; 2 failed; 0 ignored; 0 measured error: test failed ``` ### Excluding TOML Code You can add `no_sync` to the language line in a code block to exclude it from the checks done by `assert_markdown_deps_updated!`: ````markdown ```toml,no_sync [dependencies] your_crate = "0.1.2" ``` ```` ## Release History This is a changelog describing the most important changes per release. ### Version 0.9.5 (2023-09-09) - [#122](https://github.com/mgeisler/version-sync/pull/122): Use precise dependency versions in `Cargo.toml`. - [#123](https://github.com/mgeisler/version-sync/pull/123): Migrate to the Rust 2021 edition. - [#126](https://github.com/mgeisler/version-sync/pull/126): Update syn to latest version. - [#127](https://github.com/mgeisler/version-sync/pull/127): Update toml to latest version. ### Version 0.9.4 (2021-12-14) - [#115](https://github.com/mgeisler/version-sync/pull/115): Expand `assert_markdown_deps_updated!` to also check version numbers such as `=1.2.3`, `>1.2.3`, and `>=1.2.3`. - [#119](https://github.com/mgeisler/version-sync/pull/119): Add new `assert_only_contains_regex!` macro which can ensure that all version numbers in a file are updated. ### Version 0.9.3 (2021-09-20) - [#108](https://github.com/mgeisler/version-sync/pull/108): Make all dependencies optional. Thanks @rlee287! - [#109](https://github.com/mgeisler/version-sync/pull/109): Forbid the use of unsafe code. - [#110](https://github.com/mgeisler/version-sync/pull/110): Add simple `check_contains_version` function. ### Version 0.9.2 (2021-02-13) - [#94](https://github.com/mgeisler/version-sync/pull/94): Update pulldown-cmark to 0.8. - [#95](https://github.com/mgeisler/version-sync/pull/95): Fix `non_fmt_panic` lint error in latest nightly. ### Version 0.9.1 (2020-07-07) - [#91](https://github.com/mgeisler/version-sync/pull/91): Pull in fewer dependencies. This optimizes the build time by 1-2 seconds. - [#92](https://github.com/mgeisler/version-sync/pull/92): Normalize `\r\n` to `\n` to ensure `^` and `$` always match line boundaries. ### Version 0.9.0 (2020-03-30) Drop support for Rust 1.31.0 since our dependencies keep releasing new patch patch versions that push up the minimum required Rust version. At the time of writing, the code compiles with Rust 1.36, but this will likely become outdated soon. Issues closed: - [#83][issue-83]: version-sync fails to parse TOML blocks when inside blockquotes - [#84][issue-84]: Release update to crates.io with syn 1.0 ### Version 0.8.1 (2019-04-03) Dependencies were relaxed to make it easier to upgrade version-sync. ### Version 0.8.0 (2019-03-28) We now use [Rust 2018][rust-2018], which means we require Rust version 1.31.0 or later. The `assert_html_root_url_updated!` macro will again report accurate line numbers based on span information from the syn crate. ### Version 0.7.0 (2019-01-14) Special characters are now correctly escaped in the `{name}` and `{version}` placeholders in `assert_contains_regex!`. Dependencies were updated and version-sync now requires Rust version 1.27.2 or later. ### Version 0.6.0 (2018-11-22) You can use `assert_contains_regex!` to grep files for the current version number. The search is done with a regular expression where `{version}` is replaced with the current version number. Git dependencies are now always accepted, which means that blocks like ````markdown ```toml [dependencies] your_crate = { git = "..." } ``` ```` will work without you having to add `no_sync`. Issues closed: - [#17][issue-17]: Allow to check non-markdown sources - [#39][issue-39]: Version 0.5 requires Rust version 1.21.0 - [#42][issue-42]: Handle Git dependencies ### Version 0.5.0 (2017-11-19) Dependencies were updated and version-sync now requires Rust version 1.21 or later. Error messages from `assert_html_root_url_updated!` now again include line numbers (based on a heuristic until the syn crate can provide the information). ### Version 0.4.0 (2017-11-01) This release replaces the dependency on the abandoned syntex_syntax with with a dependency on the much lighter syn crate. This improves compilation speed. Unfortunately, the syn crate does not provide information about line numbers, so error messages are are no longer as good. We might be able to work around that in a later version. ### Version 0.3.1 (2017-09-26) This release fixes a small problem with the handling of pre-release identifiers. Issues closed: - [#19][issue-19]: Pre-release identifiers were ignored. ### Version 0.3.0 (2017-09-23) When checking dependencies in READMEs, TOML blocks can now be excluded from the check by adding `no_sync` to the language line: ````markdown ```toml,no_sync [dependencies] your_crate = "0.1" ``` ```` This TOML block will not be checked. This is similar to `no_run` for Rust code blocks. ### Version 0.2.0 (2017-09-20) Added `assert_html_root_url_updated!` which will check that the `html_root_url` attribute points to the correct version of the crate documentation on docs.rs. ### Version 0.1.3 (2017-09-18) First public release with support for finding outdated version numbers in `dependencies` and `dev-dependencies`. Versions 0.1.0 to 0.1.2 were released under the name check-versions. ## License Version-sync can be distributed according to the [MIT license][mit]. 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/0.9/ [rust-2018]: https://doc.rust-lang.org/edition-guide/rust-2018/ [mit]: LICENSE [issue-17]: https://github.com/mgeisler/version-sync/issues/17 [issue-19]: https://github.com/mgeisler/version-sync/issues/19 [issue-39]: https://github.com/mgeisler/version-sync/issues/39 [issue-42]: https://github.com/mgeisler/version-sync/issues/42 [issue-83]: https://github.com/mgeisler/version-sync/issues/83 [issue-84]: https://github.com/mgeisler/version-sync/issues/84 version-sync-0.9.5/dprint.json000066400000000000000000000007711447713060200163700ustar00rootroot00000000000000{ "markdown": { "textWrap": "always" }, "exec": { "commands": [{ "exts": ["rs"], "command": "rustfmt" }] }, "excludes": ["target/"], "plugins": [ "https://plugins.dprint.dev/exec-0.4.3.json@42343548b8022c99b1d750be6b894fe6b6c7ee25f72ae9f9082226dd2e515072", "https://plugins.dprint.dev/markdown-0.16.1.wasm", "https://plugins.dprint.dev/prettier-0.27.0.json@3557a62b4507c55a47d8cde0683195b14d13c41dda66d0f0b0e111aed107e2fe", "https://plugins.dprint.dev/toml-0.5.4.wasm" ] } version-sync-0.9.5/rustfmt.toml000066400000000000000000000001431447713060200165670ustar00rootroot00000000000000edition = "2018" # https://github.com/rust-lang/rustfmt/issues/4991 imports_granularity = "Module" version-sync-0.9.5/src/000077500000000000000000000000001447713060200147575ustar00rootroot00000000000000version-sync-0.9.5/src/contains_regex.rs000066400000000000000000000250431447713060200203410ustar00rootroot00000000000000#![cfg(feature = "contains_regex")] use regex::{escape, Regex, RegexBuilder}; use semver::{Version, VersionReq}; 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. /// /// 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 placeholders in the template. let pattern = template .replace("{name}", &escape(pkg_name)) .replace("{version}", &escape(pkg_version)); let mut builder = RegexBuilder::new(&pattern); builder.multi_line(true); let re = builder .build() .map_err(|err| format!("could not parse template: {}", err))?; let text = read_file(path).map_err(|err| format!("could not read {}: {}", path, err))?; println!("Searching for \"{pattern}\" in {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 \"{pattern}\" in {path}")), } } /// 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 \"{template}\" in {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!("{path} (line {line_no}) ... ok"); } } } } if !has_match { return Err(format!("{path} ... found no matches for \"{template}\"")); } if errors > 0 { return Err(format!("{path} ... found {errors} errors")); } Ok(()) } #[cfg(test)] mod tests { use super::*; use std::io::Write; #[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([ r"could not parse template: regex parse error:", r" Version 1\.2\.3 [ups", r" ^", r"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 escaping() { assert_eq!( check_contains_regex( "README.md", "escaped: {name}-{version}, not escaped: foo*bar-1.2.3", "foo*bar", "1.2.3" ), Err([ r#"could not find "escaped: foo\*bar-1\.2\.3,"#, r#"not escaped: foo*bar-1.2.3" in README.md"# ] .join(" ")) ) } #[test] fn good_pattern() { assert_eq!( check_contains_regex("README.md", "{name}", "version-sync", "1.2.3"), Ok(()) ) } #[test] fn line_boundaries() { // The regex crate doesn't treat \r\n as a line boundary // (https://github.com/rust-lang/regex/issues/244), so // version-sync makes sure to normalize \r\n to \n when // reading files. let mut file = tempfile::NamedTempFile::new().unwrap(); file.write_all(b"first line\r\nsecond line\r\nthird line\r\n") .unwrap(); assert_eq!( check_contains_regex(file.path().to_str().unwrap(), "^second line$", "", ""), 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() { 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() { 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() { 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() { 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() )) ); } } version-sync-0.9.5/src/contains_substring.rs000066400000000000000000000040061447713060200212430ustar00rootroot00000000000000use crate::helpers::{read_file, Result}; /// Check that `path` contain the substring 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. /// /// See [`check_contains_regex`](crate::check_contains_regex) if you /// want to match with a regular expression instead. /// /// # Errors /// /// If the template 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_substring( 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 pattern = template .replace("{name}", pkg_name) .replace("{version}", pkg_version); let text = read_file(path).map_err(|err| format!("could not read {}: {}", path, err))?; println!("Searching for \"{pattern}\" in {path}..."); match text.find(&pattern) { Some(idx) => { let line_no = text[..idx].lines().count(); println!("{} (line {}) ... ok", path, line_no + 1); Ok(()) } None => Err(format!("could not find \"{pattern}\" in {path}")), } } #[cfg(test)] mod tests { use super::*; #[test] fn pattern_not_found() { assert_eq!( check_contains_substring("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 pattern_found() { assert_eq!( check_contains_substring("README.md", "{name}", "version-sync", "1.2.3"), Ok(()) ) } } version-sync-0.9.5/src/helpers.rs000066400000000000000000000212301447713060200167650ustar00rootroot00000000000000use std::fs::File; use std::io::{self, Read}; /// The common result type, our errors will be simple strings. pub type Result = std::result::Result; #[cfg(any(feature = "html_root_url_updated", feature = "markdown_deps_updated"))] fn join(iter: T, sep: &str) -> String where T: IntoIterator, T::Item: std::fmt::Display, { let mut buf = String::new(); let mut iter = iter.into_iter(); if let Some(item) = iter.next() { let item = item.to_string(); buf.push_str(&item); } else { return buf; } for item in iter { buf.push_str(sep); let item = item.to_string(); buf.push_str(&item); } buf } /// Return all data from `path`. Line boundaries are normalized from /// "\r\n" to "\n" to make sure "^" and "$" will match them. See /// https://github.com/rust-lang/regex/issues/244 for details. pub fn read_file(path: &str) -> io::Result { let mut file = File::open(path)?; let mut buf = String::new(); file.read_to_string(&mut buf)?; Ok(buf.replace("\r\n", "\n")) } /// Indent every line in text by four spaces. #[cfg(any(feature = "html_root_url_updated", feature = "markdown_deps_updated"))] pub fn indent(text: &str) -> String { join(text.lines().map(|line| String::from(" ") + line), "\n") } /// Verify that the version range request matches the given version. #[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, ) -> Result<()> { use semver::Op; for comparator in &request.comparators { match comparator.op { Op::Tilde | Op::Caret | Op::Exact | Op::Greater | Op::GreaterEq | Op::Wildcard => { if comparator.major != version.major { return Err(format!( "expected major version {}, found {}", version.major, comparator.major, )); } if let Some(minor) = comparator.minor { if minor != version.minor { return Err(format!( "expected minor version {}, found {}", version.minor, minor )); } } if let Some(patch) = comparator.patch { if patch != version.patch { return Err(format!( "expected patch version {}, found {}", version.patch, patch )); } } if comparator.pre != version.pre { return Err(format!( "expected pre-release \"{}\", found \"{}\"", version.pre, comparator.pre )); } } _ => {} // We cannot check other operators. } } Ok(()) } #[cfg(test)] mod tests { #[cfg(any(feature = "html_root_url_updated", feature = "markdown_deps_updated"))] use semver::{Version, VersionReq}; #[cfg(any(feature = "html_root_url_updated", feature = "markdown_deps_updated"))] use super::*; #[cfg(any(feature = "html_root_url_updated", feature = "markdown_deps_updated"))] mod test_version_matches_request { use super::*; #[test] fn implicit_compatible() { let version = Version::parse("1.2.3").unwrap(); let request = VersionReq::parse("1.2.3").unwrap(); assert_eq!(version_matches_request(&version, &request), Ok(())); let request = VersionReq::parse("1.2.0").unwrap(); assert!(version_matches_request(&version, &request).is_err()); } #[test] fn compatible() { let version = Version::parse("1.2.3").unwrap(); let request = VersionReq::parse("^1.2.3").unwrap(); assert_eq!(version_matches_request(&version, &request), Ok(())); let request = VersionReq::parse("^1.2.0").unwrap(); assert!(version_matches_request(&version, &request).is_err()); } #[test] fn tilde() { let version = Version::parse("1.2.3").unwrap(); let request = VersionReq::parse("~1.2.3").unwrap(); assert_eq!(version_matches_request(&version, &request), Ok(())); let request = VersionReq::parse("~1.2.0").unwrap(); assert!(version_matches_request(&version, &request).is_err()); } #[test] fn exact() { let version = Version::parse("1.2.3").unwrap(); let request = VersionReq::parse("=1.2.3").unwrap(); assert_eq!(version_matches_request(&version, &request), Ok(())); let request = VersionReq::parse("=1.2.0").unwrap(); assert!(version_matches_request(&version, &request).is_err()); } #[test] fn greater_or_equal() { let version = Version::parse("1.2.3").unwrap(); let request = VersionReq::parse(">=1.2.3").unwrap(); assert_eq!(version_matches_request(&version, &request), Ok(())); let request = VersionReq::parse(">=1.2.0").unwrap(); assert!(version_matches_request(&version, &request).is_err()); } #[test] fn wildcard() { let version = Version::parse("1.2.3").unwrap(); let request = VersionReq::parse("1.2.*").unwrap(); assert_eq!(version_matches_request(&version, &request), Ok(())); let request = VersionReq::parse("1.3.*").unwrap(); assert!(version_matches_request(&version, &request).is_err()); } #[test] fn greater() { let version = Version::parse("1.2.3").unwrap(); let request = VersionReq::parse(">1.2.3").unwrap(); assert_eq!(version_matches_request(&version, &request), Ok(())); let request = VersionReq::parse(">1.2.0").unwrap(); assert!(version_matches_request(&version, &request).is_err()); } #[test] fn no_patch() { let version = Version::parse("1.2.3").unwrap(); let request = VersionReq::parse("1.2").unwrap(); assert_eq!(version_matches_request(&version, &request), Ok(())); } #[test] fn no_minor() { let version = Version::parse("1.2.3").unwrap(); let request = VersionReq::parse("1").unwrap(); assert_eq!(version_matches_request(&version, &request), Ok(())); } #[test] fn multiple_comparators() { let version = Version::parse("1.2.3").unwrap(); let request = VersionReq::parse(">= 1.2.3, < 2.0").unwrap(); assert_eq!(version_matches_request(&version, &request), Ok(())); let request = VersionReq::parse(">= 1.2.0, < 2.0").unwrap(); assert!(version_matches_request(&version, &request).is_err()); } #[test] fn unhandled_operator() { let version = Version::parse("1.2.3").unwrap(); let request = VersionReq::parse("< 2.0").unwrap(); assert_eq!(version_matches_request(&version, &request), Ok(())); } #[test] fn bad_major() { let version = Version::parse("2.0.0").unwrap(); let request = VersionReq::parse("1.2.3").unwrap(); assert_eq!( version_matches_request(&version, &request), Err(String::from("expected major version 2, found 1")) ); } #[test] fn bad_minor() { let version = Version::parse("1.3.0").unwrap(); let request = VersionReq::parse("1.2.3").unwrap(); assert_eq!( version_matches_request(&version, &request), Err(String::from("expected minor version 3, found 2")) ); } #[test] fn bad_patch() { let version = Version::parse("1.2.4").unwrap(); let request = VersionReq::parse("1.2.3").unwrap(); assert_eq!( version_matches_request(&version, &request), Err(String::from("expected patch version 4, found 3")) ); } #[test] fn bad_pre_release() { let version = Version::parse("1.2.3-rc2").unwrap(); let request = VersionReq::parse("1.2.3-rc1").unwrap(); assert_eq!( version_matches_request(&version, &request), Err(String::from("expected pre-release \"rc2\", found \"rc1\"")) ); } } } version-sync-0.9.5/src/html_root_url.rs000066400000000000000000000246301447713060200202230ustar00rootroot00000000000000#![cfg(feature = "html_root_url_updated")] use semver::{Version, VersionReq}; use syn::spanned::Spanned; use syn::token; use url::Url; use crate::helpers::{indent, read_file, version_matches_request, Result}; fn url_matches(value: &str, pkg_name: &str, version: &Version) -> Result<()> { let url = Url::parse(value).map_err(|err| format!("parse error: {}", err))?; // We can only reason about docs.rs. if url.domain().is_some() && url.domain() != Some("docs.rs") { return Ok(()); } // Since docs.rs redirects HTTP traffic to HTTPS, we will ensure // that the scheme is "https" here. if url.scheme() != "https" { return Err(format!("expected \"https\", found {:?}", url.scheme())); } let mut path_segments = url .path_segments() .ok_or_else(|| String::from("no path in URL"))?; // The package name should not be empty. let name = path_segments .next() .and_then(|path| if path.is_empty() { None } else { Some(path) }) .ok_or_else(|| String::from("missing package name"))?; // The version number should not be empty. let request = path_segments .next() .and_then(|path| if path.is_empty() { None } else { Some(path) }) .ok_or_else(|| String::from("missing version number"))?; // Finally, we check that the package name and version matches. if name != pkg_name { Err(format!("expected package \"{pkg_name}\", found \"{name}\"")) } else { // The Rust API Guidelines[1] suggest using an exact version // number, but we have relaxed this a little and allow the // user to specify the version as just "1" or "1.2". We might // make this more strict in the future. // // [1]: https://rust-lang-nursery.github.io/api-guidelines/documentation.html // #crate-sets-html_root_url-attribute-c-html-root VersionReq::parse(request) .map_err(|err| format!("could not parse version in URL: {}", err)) .and_then(|request| version_matches_request(version, &request)) } } /// Check version numbers in `html_root_url` attributes. /// /// This function parses the Rust source file in `path` and looks for /// `html_root_url` attributes. Such an attribute must specify a valid /// URL and if the URL points to docs.rs, it must be point to the /// documentation for `pkg_name` and `pkg_version`. /// /// # Errors /// /// If any attribute fails the check, an `Err` is returned with a /// succinct error message. Status information has then already been /// printed on `stdout`. pub fn check_html_root_url(path: &str, pkg_name: &str, pkg_version: &str) -> Result<()> { let code = read_file(path).map_err(|err| format!("could not read {}: {}", path, err))?; let version = Version::parse(pkg_version) .map_err(|err| format!("bad package version {:?}: {}", pkg_version, err))?; let krate: syn::File = syn::parse_file(&code) .map_err(|_| format!("could not parse {}: please run \"cargo build\"", path))?; println!("Checking doc attributes in {path}..."); for attr in krate.attrs { if let syn::AttrStyle::Outer = attr.style { continue; } if !attr.path().is_ident("doc") { continue; } if let syn::Meta::List(ref list) = attr.meta { list.parse_nested_meta(|meta| { if meta.path.is_ident("html_root_url") { let check_result = match meta.value() { Ok(value) => match value.parse()? { syn::Lit::Str(ref s) => url_matches(&s.value(), pkg_name, &version), _ => return Ok(()), }, Err(_err) => Err(String::from("html_root_url attribute without URL")), }; // FIXME: the proc-macro2-0.4.27 crate hides accurate span // information behind a procmacro2_semver_exempt flag: the // start line is correct, but the end line is always equal // to the start. Luckily, most html_root_url attributes // are on a single line, so the code below works okay. let first_line = attr.span().start().line; let last_line = attr.span().end().line; // Getting the source code for a span is tracked upstream: // https://github.com/alexcrichton/proc-macro2/issues/110. let source_lines = code.lines().take(last_line).skip(first_line - 1); match check_result { Ok(()) => { println!("{} (line {}) ... ok", path, first_line); return Ok(()); } Err(err) => { println!("{} (line {}) ... {} in", path, first_line, err); for line in source_lines { println!("{}", indent(line)); } return Err(meta.error(format!("html_root_url errors in {}", path))); } } } // Need to advance the input stream by parsing it. // Otherwise syn gets stuck parsing the wrong tokens. else if meta.input.peek(token::Eq) { let value = meta.value()?; value.parse::()?; } else if meta.input.peek(token::Paren) { let value; syn::parenthesized!(value in meta.input); // There can be multiple elements before end. while !value.is_empty() { value.parse::()?; } } else { return Err(meta.error("unknown doc attribute")); } Ok(()) }) .map_err(|err| err.to_string())?; } } Ok(()) } #[cfg(test)] mod test_url_matches { use super::*; #[test] fn good_url() { let ver = Version::parse("1.2.3").unwrap(); assert_eq!( url_matches("https://docs.rs/foo/1.2.3", "foo", &ver), Ok(()) ); } #[test] fn trailing_slash() { let ver = Version::parse("1.2.3").unwrap(); assert_eq!( url_matches("https://docs.rs/foo/1.2.3/", "foo", &ver), Ok(()) ); } #[test] fn without_patch() { let ver = Version::parse("1.2.3").unwrap(); assert_eq!(url_matches("https://docs.rs/foo/1.2/", "foo", &ver), Ok(())); } #[test] fn without_minor() { let ver = Version::parse("1.2.3").unwrap(); assert_eq!(url_matches("https://docs.rs/foo/1/", "foo", &ver), Ok(())); } #[test] fn different_domain() { let ver = Version::parse("1.2.3").unwrap(); assert_eq!(url_matches("https://example.net/foo/", "bar", &ver), Ok(())); } #[test] fn different_domain_http() { let ver = Version::parse("1.2.3").unwrap(); assert_eq!( url_matches("http://example.net/foo/1.2.3", "foo", &ver), Ok(()) ); } #[test] fn http_url() { let ver = Version::parse("1.2.3").unwrap(); assert_eq!( url_matches("http://docs.rs/foo/1.2.3", "foo", &ver), Err(String::from("expected \"https\", found \"http\"")) ); } #[test] fn bad_scheme() { let ver = Version::parse("1.2.3").unwrap(); assert_eq!( url_matches("mailto:foo@example.net", "foo", &ver), Err(String::from("expected \"https\", found \"mailto\"")) ); } #[test] fn no_package() { let ver = Version::parse("1.2.3").unwrap(); assert_eq!( url_matches("https://docs.rs", "foo", &ver), Err(String::from("missing package name")) ); } #[test] fn no_package_trailing_slash() { let ver = Version::parse("1.2.3").unwrap(); assert_eq!( url_matches("https://docs.rs/", "foo", &ver), Err(String::from("missing package name")) ); } #[test] fn no_version() { let ver = Version::parse("1.2.3").unwrap(); assert_eq!( url_matches("https://docs.rs/foo", "foo", &ver), Err(String::from("missing version number")) ); } #[test] fn no_version_trailing_slash() { let ver = Version::parse("1.2.3").unwrap(); assert_eq!( url_matches("https://docs.rs/foo/", "foo", &ver), Err(String::from("missing version number")) ); } #[test] fn bad_url() { let ver = Version::parse("1.2.3").unwrap(); assert_eq!( url_matches("docs.rs/foo/bar", "foo", &ver), Err(String::from("parse error: relative URL without a base")) ); } #[test] fn bad_pkg_version() { let ver = Version::parse("1.2.3").unwrap(); assert_eq!( url_matches("https://docs.rs/foo/1.2.bad/", "foo", &ver), Err(String::from( "could not parse version in URL: \ unexpected character 'b' while parsing patch version number" )) ); } #[test] fn wrong_pkg_name() { let ver = Version::parse("1.2.3").unwrap(); assert_eq!( url_matches("https://docs.rs/foo/1.2.3/", "bar", &ver), Err(String::from("expected package \"bar\", found \"foo\"")) ); } } #[cfg(test)] mod test_check_html_root_url { use super::*; #[test] fn bad_path() { let no_such_file = if cfg!(unix) { "No such file or directory (os error 2)" } else { "The system cannot find the file specified. (os error 2)" }; let errmsg = format!("could not read no-such-file.md: {no_such_file}"); assert_eq!( check_html_root_url("no-such-file.md", "foobar", "1.2.3"), Err(errmsg) ); } #[test] fn bad_pkg_version() { // This uses the src/lib.rs file from this crate. assert_eq!( check_html_root_url("src/lib.rs", "foobar", "1.2"), Err(String::from( "bad package version \"1.2\": unexpected end of input while parsing minor version number" )) ); } } version-sync-0.9.5/src/lib.rs000066400000000000000000000333721447713060200161030ustar00rootroot00000000000000//! `version-sync` provides macros for keeping version numbers in sync //! with your crate version. //! //! # Library Overview //! //! 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 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`] 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`]. //! //! Except for [`assert_contains_substring`], the macros are gated //! behind individual features, as detailed below. //! //! A typical configuration will use an integration test to verify //! that all version numbers are in sync. Create a //! `tests/version-numbers.rs` file with: //! //! ```rust //! #[test] //! # fn fake_hidden_test_case_1() {} //! # #[cfg(feature = "markdown_deps_updated")] //! fn test_readme_deps_updated() { //! version_sync::assert_markdown_deps_updated!("README.md"); //! } //! //! #[test] //! # fn fake_hidden_test_case_2() {} //! fn test_readme_mentions_version() { //! version_sync::assert_contains_substring!("README.md", "Version {version}"); //! } //! //! #[test] //! # fn fake_hidden_test_case_3() {} //! # #[cfg(feature = "html_root_url_updated")] //! fn test_html_root_url() { //! version_sync::assert_html_root_url_updated!("src/lib.rs"); //! } //! //! # fn main() { //! # #[cfg(feature = "markdown_deps_updated")] //! # test_readme_deps_updated(); //! # test_readme_mentions_version(); //! # #[cfg(feature = "html_root_url_updated")] //! # test_html_root_url(); //! # } //! ``` //! //! When you run `cargo test`, your version numbers will be //! automatically checked. //! //! # Cargo Features //! //! In case you only need some of the macros above, you can disable //! them individually using Cargo features. The features are: //! //! * `markdown_deps_updated` enables [`assert_markdown_deps_updated`]. //! * `html_root_url_updated` enables [`assert_html_root_url_updated`]. //! * `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 //! quickly check that a given file contains the current crate //! version. //! //! [`html_root_url`]: https://rust-lang-nursery.github.io/api-guidelines/documentation.html#crate-sets-html_root_url-attribute-c-html-root #![doc(html_root_url = "https://docs.rs/version-sync/0.9.5")] #![forbid(unsafe_code)] #![deny(missing_docs)] mod contains_regex; mod contains_substring; mod helpers; mod html_root_url; mod markdown_deps; #[cfg(feature = "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; #[cfg(feature = "markdown_deps_updated")] pub use crate::markdown_deps::check_markdown_deps; /// Assert that dependencies on the current package are up to date. /// /// The macro will call [`check_markdown_deps`] on the file name given /// in order to check that the TOML examples found all depend on a /// current version of your package. The package name is automatically /// taken from the `$CARGO_PKG_NAME` environment variable and the /// version is taken from `$CARGO_PKG_VERSION`. These environment /// variables are automatically set by Cargo when compiling your /// crate. /// /// This macro is enabled by the `markdown_deps_updated` 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_deps is compiled. /// fn test_readme_deps() { /// version_sync::assert_markdown_deps_updated!("README.md"); /// } /// /// # fn main() { /// # test_readme_deps(); /// # } /// ``` /// /// 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. /// /// # Panics /// /// If any TOML code block fails the check, `panic!` will be invoked. #[macro_export] #[cfg(feature = "markdown_deps_updated")] macro_rules! assert_markdown_deps_updated { ($path:expr) => { let pkg_name = env!("CARGO_PKG_NAME"); let pkg_version = env!("CARGO_PKG_VERSION"); if let Err(err) = $crate::check_markdown_deps($path, pkg_name, pkg_version) { panic!("{}", err); } }; } /// Assert that the `html_root_url` attribute is up to date. /// /// Library crates can [set `html_root_url`][api-guidelines] to point /// to their documentation so that `cargo doc --no-deps` in other /// projects can generate correct links when referring the library. /// /// The macro will call [`check_html_root_url`] on the file name given /// in order to check that the `html_root_url` is points to the /// current version of your package documentation on docs.rs. Use /// [`assert_contains_regex!`] instead if you don't host the /// documentation on docs.rs. /// /// The package name is automatically taken from the `$CARGO_PKG_NAME` /// environment variable and the version is taken from /// `$CARGO_PKG_VERSION`. These environment variables are /// automatically set by Cargo when compiling your crate. /// /// This macro is enabled by the `html_root_url_updated` 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_html_root_url is compiled. /// fn test_html_root_url() { /// version_sync::assert_html_root_url_updated!("src/lib.rs"); /// } /// /// # fn main() { /// # test_html_root_url(); /// # } /// ``` /// /// Tests are run with the current directory set to directory where /// your `Cargo.toml` file is, so this will find the `src/lib.rs` /// crate root. /// /// # Panics /// /// If the `html_root_url` fails the check, `panic!` will be invoked. /// /// [api-guidelines]: https://rust-lang-nursery.github.io/api-guidelines/documentation.html#crate-sets-html_root_url-attribute-c-html-root #[macro_export] #[cfg(feature = "html_root_url_updated")] macro_rules! assert_html_root_url_updated { ($path:expr) => { let pkg_name = env!("CARGO_PKG_NAME"); let pkg_version = env!("CARGO_PKG_VERSION"); if let Err(err) = $crate::check_html_root_url($path, pkg_name, pkg_version) { panic!("{}", err); } }; } /// Assert that versions numbers are up to date via substring matching. /// /// 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 template which will be matched against the /// content of the file. /// /// The macro calls [`check_contains_substring`] 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 /// #[test] /// # fn fake_hidden_test_case() {} /// # // The above function ensures test_readme_mentions_version is /// # // compiled. /// fn test_readme_mentions_version() { /// version_sync::assert_contains_substring!("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 template can contain placeholders which are replaced before /// the 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. /// /// See [`assert_contains_regex`] if you want to search for a regular /// expression instead. /// /// # Panics /// /// If the substring cannot be found, `panic!` will be invoked and /// your integration test will fail. #[macro_export] macro_rules! assert_contains_substring { ($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_substring($path, $format, pkg_name, pkg_version) { panic!("{}", err); } }; } /// 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 contents of 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. /// /// 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_mentions_version() { /// version_sync::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. #[macro_export] #[cfg(feature = "contains_regex")] 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); } }; } /// 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); } }; } version-sync-0.9.5/src/markdown_deps.rs000066400000000000000000000241531447713060200201670ustar00rootroot00000000000000#![cfg(feature = "markdown_deps_updated")] use pulldown_cmark::CodeBlockKind::Fenced; use pulldown_cmark::{Event, Parser, Tag}; use semver::{Version, VersionReq}; use toml::Value; use crate::helpers::{indent, read_file, version_matches_request, Result}; /// A fenced code block. #[derive(Debug, Clone, PartialEq, Eq)] struct CodeBlock { /// Text between the fences. content: String, /// Line number starting with 1. first_line: usize, } /// Extract a dependency on the given package from a TOML code block. fn extract_version_request(pkg_name: &str, block: &str) -> Result { match block.parse::() { Ok(value) => { let version = value .get("dependencies") .or_else(|| value.get("dev-dependencies")) .and_then(|deps| deps.get(pkg_name)) .and_then(|dep| { dep.get("version") // pkg_name = { version = "1.2.3" } .and_then(|version| version.as_str()) // pkg_name = { git = "..." } .or_else(|| dep.get("git").and(Some("*"))) // pkg_name = "1.2.3" .or_else(|| dep.as_str()) }); match version { Some(version) => VersionReq::parse(version) .map_err(|err| format!("could not parse dependency: {}", err)), None => Err(format!("no dependency on {pkg_name}")), } } Err(err) => Err(format!("{err}")), } } /// Check if a code block language line says the block is TOML code. fn is_toml_block(lang: &str) -> bool { // Split the language line as LangString::parse from rustdoc: // https://github.com/rust-lang/rust/blob/1.20.0/src/librustdoc/html/markdown.rs#L922 let mut has_toml = false; for token in lang.split(|c: char| !(c == '_' || c == '-' || c.is_alphanumeric())) { match token.trim() { "no_sync" => return false, "toml" => has_toml = true, _ => {} } } has_toml } /// Find all TOML code blocks in a Markdown text. fn find_toml_blocks(text: &str) -> Vec { let parser = Parser::new(text); let mut code_blocks = Vec::new(); let mut current_block = None; for (event, range) in parser.into_offset_iter() { match event { Event::Start(Tag::CodeBlock(Fenced(lang))) if is_toml_block(&lang) => { // Count number of newlines before the ```. This gives // us the line number of the fence, counted from 0. let line_count = text[..range.start].chars().filter(|&ch| ch == '\n').count(); current_block = Some(CodeBlock { first_line: line_count + 2, content: String::new(), }); } Event::Text(code) => { if let Some(block) = current_block.as_mut() { block.content.push_str(&code); } } Event::End(Tag::CodeBlock(_)) => { if let Some(block) = current_block.take() { code_blocks.push(block); } } _ => {} } } code_blocks } /// Check dependencies in Markdown code blocks. /// /// This function finds all TOML code blocks in `path` and looks for /// dependencies on `pkg_name` in those blocks. A code block fails the /// check if it has a dependency on `pkg_name` that doesn't match /// `pkg_version`, or if it has no dependency on `pkg_name` at all. /// /// # Examples /// /// Consider a package named `foo` with version 1.2.3. The following /// TOML block will pass the test: /// /// ~~~markdown /// ```toml /// [dependencies] /// foo = "1.2.3" /// ``` /// ~~~ /// /// Both `dependencies` and `dev-dependencies` are examined. If you /// want to skip a block, add `no_sync` to the language line: /// /// ~~~markdown /// ```toml,no_sync /// [dependencies] /// foo = "1.2.3" /// ``` /// ~~~ /// /// Code blocks also fail the check if they cannot be parsed as TOML. /// /// # Errors /// /// If any block fails the check, an `Err` is returned with a succinct /// error message. Status information has then already been printed on /// `stdout`. pub fn check_markdown_deps(path: &str, pkg_name: &str, pkg_version: &str) -> Result<()> { let text = read_file(path).map_err(|err| format!("could not read {}: {}", path, err))?; let version = Version::parse(pkg_version) .map_err(|err| format!("bad package version {:?}: {}", pkg_version, err))?; println!("Checking code blocks in {path}..."); let mut failed = false; for block in find_toml_blocks(&text) { let result = extract_version_request(pkg_name, &block.content) .and_then(|request| version_matches_request(&version, &request)); match result { Err(err) => { failed = true; println!("{} (line {}) ... {} in", path, block.first_line, err); println!("{}\n", indent(&block.content)); } Ok(()) => println!("{} (line {}) ... ok", path, block.first_line), } } if failed { return Err(format!("dependency errors in {path}")); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn empty_markdown_file() { assert_eq!(find_toml_blocks(""), vec![]); } #[test] fn indented_code_block() { assert_eq!(find_toml_blocks(" code block\n"), vec![]); } #[test] fn empty_toml_block() { assert_eq!( find_toml_blocks("```toml\n```"), vec![CodeBlock { content: String::new(), first_line: 2 }] ); } #[test] fn no_close_fence() { assert_eq!( find_toml_blocks("```toml\n"), vec![CodeBlock { content: String::new(), first_line: 2 }] ); } #[test] fn nonempty_toml_block() { let text = "Preceding text.\n\ ```toml\n\ foo\n\ ```\n\ Trailing text"; assert_eq!( find_toml_blocks(text), vec![CodeBlock { content: String::from("foo\n"), first_line: 3 }] ); } #[test] fn blockquote_toml_block() { let text = "> This is a blockquote\n\ >\n\ > ```toml\n\ > foo\n\ > \n\ > bar\n\ >\n\ > ```\n\ "; assert_eq!( find_toml_blocks(text), vec![CodeBlock { content: String::from("foo\n\n bar\n\n"), first_line: 4 }] ); } #[test] fn is_toml_block_simple() { assert!(!is_toml_block("rust")); } #[test] fn is_toml_block_comma() { assert!(is_toml_block("foo,toml")); } #[test] fn is_toml_block_no_sync() { assert!(!is_toml_block("toml,no_sync")); assert!(!is_toml_block("toml, no_sync")); } #[test] fn simple() { let block = "[dependencies]\n\ foobar = '1.5'"; let request = extract_version_request("foobar", block); assert_eq!(request.unwrap(), VersionReq::parse("1.5").unwrap()); } #[test] fn table() { let block = "[dependencies]\n\ foobar = { version = '1.5', default-features = false }"; let request = extract_version_request("foobar", block); assert_eq!(request.unwrap(), VersionReq::parse("1.5").unwrap()); } #[test] fn git_dependency() { // Git dependencies are translated into a "*" dependency // and are thus always accepted. let block = "[dependencies]\n\ foobar = { git = 'https://example.net/foobar.git' }"; let request = extract_version_request("foobar", block); assert_eq!(request.unwrap(), VersionReq::parse("*").unwrap()); } #[test] fn dev_dependencies() { let block = "[dev-dependencies]\n\ foobar = '1.5'"; let request = extract_version_request("foobar", block); assert_eq!(request.unwrap(), VersionReq::parse("1.5").unwrap()); } #[test] fn bad_version() { let block = "[dependencies]\n\ foobar = '1.5.bad'"; let request = extract_version_request("foobar", block); assert_eq!( request.unwrap_err(), "could not parse dependency: \ unexpected character 'b' while parsing patch version number" ); } #[test] fn missing_dependency() { let block = "[dependencies]\n\ baz = '1.5.8'"; let request = extract_version_request("foobar", block); assert_eq!(request.unwrap_err(), "no dependency on foobar"); } #[test] fn empty() { let request = extract_version_request("foobar", ""); assert_eq!(request.unwrap_err(), "no dependency on foobar"); } #[test] fn bad_toml() { let block = "[dependencies]\n\ foobar = 1.5.8"; let request = extract_version_request("foobar", block); assert!(request.is_err()); } #[test] fn bad_path() { let no_such_file = if cfg!(unix) { "No such file or directory (os error 2)" } else { "The system cannot find the file specified. (os error 2)" }; let errmsg = format!("could not read no-such-file.md: {no_such_file}"); assert_eq!( check_markdown_deps("no-such-file.md", "foobar", "1.2.3"), Err(errmsg) ); } #[test] fn bad_pkg_version() { // This uses the README.md file from this crate. assert_eq!( check_markdown_deps("README.md", "foobar", "1.2"), Err(String::from( "bad package version \"1.2\": unexpected end of input while parsing minor version number" )) ); } } version-sync-0.9.5/tests/000077500000000000000000000000001447713060200153325ustar00rootroot00000000000000version-sync-0.9.5/tests/version-numbers.rs000066400000000000000000000012151447713060200210350ustar00rootroot00000000000000#[test] #[cfg(feature = "markdown_deps_updated")] fn test_readme_deps() { version_sync::assert_markdown_deps_updated!("README.md"); } #[test] #[cfg(feature = "contains_regex")] fn test_readme_changelog() { version_sync::assert_contains_regex!( "README.md", r"^### Version {version} \(20\d\d-\d\d-\d\d\)$" ); } #[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() { version_sync::assert_html_root_url_updated!("src/lib.rs"); }