use semver::{Prerelease, Version};
use crate::Result;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Level {
Major,
Minor,
Patch,
}
pub fn parse(raw: &str) -> Result<Version> {
Version::parse(raw).map_err(|err| format!("`{raw}` is not a valid semver version: {err}"))
}
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 `..`"
))
}
}
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")?,
),
};
Ok(next)
}
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(¤t.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}");
}
}