semver-sync 0.1.1

Keep one version across every crate in a cargo workspace; bumps are checked as docker tags before any manifest is written.
//! Version arithmetic, free of files and cargo.

use semver::{Prerelease, Version};

use crate::Result;

/// How far to move a workspace version.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Level {
    Major,
    Minor,
    Patch,
}

/// Parse a version, naming the offending text in the error.
pub fn parse(raw: &str) -> Result<Version> {
    Version::parse(raw).map_err(|err| format!("`{raw}` is not a valid semver version: {err}"))
}

/// Every version here also becomes a docker tag, and tags are stricter than
/// semver: a letter or digit first, then `[A-Za-z0-9._-]`, and no `..`.
pub fn check_image_tag(raw: &str) -> Result<()> {
    let starts = raw
        .chars()
        .next()
        .is_some_and(|ch| ch.is_ascii_alphanumeric());
    let charset = raw
        .chars()
        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'));

    if starts && charset && !raw.contains("..") {
        Ok(())
    } else {
        Err(format!(
            "`{raw}` is not usable as a version and a docker tag: start with a letter or \
             digit, then [A-Za-z0-9._-], no `..`"
        ))
    }
}

/// Move to the next release of `level`, dropping the prerelease tag on the way.
/// `1.2.3-rc.4` bumped to patch is `1.2.4`, not `1.2.3`.
pub fn bump(current: &Version, level: Level) -> Result<Version> {
    let next = match level {
        Level::Major => Version::new(bump_u64(current.major, "major")?, 0, 0),
        Level::Minor => Version::new(current.major, bump_u64(current.minor, "minor")?, 0),
        Level::Patch => Version::new(
            current.major,
            current.minor,
            bump_u64(current.patch, "patch")?,
        ),
    };

    // Built from numbers alone, so no tag and no build metadata carry over.
    Ok(next)
}

/// `<major>.<minor>.<patch>-<tag>.<number>`, and the numbers never move.
///
/// An untagged `1.2.3` gets `1.2.3-rc.0`; `rc` again counts to `rc.1`; another
/// tag starts its own series at `hotfix.0`.
pub fn tag(current: &Version, tag: &str) -> Result<Version> {
    check_prerelease_tag(tag)?;

    let mut next = Version::new(current.major, current.minor, current.patch);
    next.pre = if first_id(&current.pre) == tag {
        let mut ids: Vec<String> = current.pre.split('.').map(str::to_string).collect();

        match ids.last().and_then(|id| id.parse::<u64>().ok()) {
            Some(last) => {
                let index = ids.len() - 1;
                ids[index] = last
                    .checked_add(1)
                    .ok_or("the tag number has no successor")?
                    .to_string();
            }
            None => ids.push("0".to_string()),
        }

        prelease(&ids.join("."))?
    } else {
        prelease(&format!("{tag}.0"))?
    };

    Ok(next)
}

fn first_id(prerelease: &Prerelease) -> &str {
    prerelease.as_str().split('.').next().unwrap_or_default()
}

fn prelease(raw: &str) -> Result<Prerelease> {
    Prerelease::new(raw).map_err(|err| format!("`{raw}` is not a valid prerelease: {err}"))
}

fn bump_u64(value: u64, name: &str) -> Result<u64> {
    value
        .checked_add(1)
        .ok_or_else(|| format!("the {name} number has no successor"))
}

fn check_prerelease_tag(tag: &str) -> Result<()> {
    let valid = !tag.is_empty()
        && tag
            .chars()
            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-');

    if valid {
        Ok(())
    } else {
        Err(format!(
            "`{tag}` is not a usable tag: use letters, digits and hyphens"
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn version(raw: &str) -> Version {
        Version::parse(raw).expect("test version")
    }

    fn bumped(current: &str, level: Level) -> String {
        bump(&version(current), level).expect("bump").to_string()
    }

    fn tagged(current: &str, name: &str) -> String {
        tag(&version(current), name).expect("tag").to_string()
    }

    #[test]
    fn level_bumps_move_the_numbers() {
        assert_eq!(bumped("1.2.3", Level::Major), "2.0.0");
        assert_eq!(bumped("1.2.3", Level::Minor), "1.3.0");
        assert_eq!(bumped("1.2.3", Level::Patch), "1.2.4");
    }

    #[test]
    fn level_bumps_drop_the_tag_before_moving() {
        assert_eq!(bumped("1.2.3-rc.4", Level::Patch), "1.2.4");
        assert_eq!(bumped("1.2.3-rc.4", Level::Minor), "1.3.0");
        assert_eq!(bumped("1.2.3-rc.0", Level::Major), "2.0.0");
        assert_eq!(bumped("1.2.3+g1a2b", Level::Patch), "1.2.4");
    }

    #[test]
    fn tag_starts_at_zero_and_never_moves_the_numbers() {
        assert_eq!(tagged("1.2.3", "rc"), "1.2.3-rc.0");
        assert_eq!(tagged("0.1.0", "hotfix"), "0.1.0-hotfix.0");
    }

    #[test]
    fn the_same_tag_counts_up() {
        assert_eq!(tagged("1.2.3-rc.0", "rc"), "1.2.3-rc.1");
        assert_eq!(tagged("1.2.3-rc.9", "rc"), "1.2.3-rc.10");
        assert_eq!(tagged("1.2.3-rc", "rc"), "1.2.3-rc.0");
    }

    #[test]
    fn another_tag_starts_its_own_series() {
        assert_eq!(tagged("1.2.3-rc.4", "hotfix"), "1.2.3-hotfix.0");
    }

    #[test]
    fn tagging_drops_build_metadata() {
        assert_eq!(tagged("1.2.3+g1a2b", "rc"), "1.2.3-rc.0");
    }

    #[test]
    fn rejects_unusable_tags() {
        for bad in ["", "rc 1", "rc_1", "rc.1"] {
            assert!(tag(&version("1.2.3"), bad).is_err(), "`{bad}` accepted");
        }
    }

    #[test]
    fn image_tags_are_stricter_than_semver() {
        for good in ["0.2.0", "1.0.0-rc.0", "1.0.0_hotfix.1"] {
            assert!(check_image_tag(good).is_ok(), "`{good}` rejected");
        }

        for bad in ["1.2.3+g1a2b", "-1.2.3", "1.2..3", "1.2.3-rc!1"] {
            let err = check_image_tag(bad).expect_err("`{bad}` accepted");
            assert!(err.contains("docker tag"), "{err}");
        }
    }

    #[test]
    fn parse_names_the_bad_input() {
        let err = parse("1.2").unwrap_err();
        assert!(err.contains("`1.2`"), "{err}");
        assert!(parse("1.2.3").is_ok());
    }

    #[test]
    fn a_number_with_no_successor_is_an_error() {
        let ceiling = Version::new(u64::MAX, 0, 0);
        let err = bump(&ceiling, Level::Major).expect_err("wrapped around");

        assert!(err.contains("major"), "{err}");
    }
}