use chrono::{DateTime, FixedOffset};
use std::cmp::Ordering;
use std::fmt;
use super::VersionField;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PreReleaseTag {
pub name: String,
pub number: Option<i64>,
pub promote_tag_even_if_name_is_empty: bool,
}
impl PreReleaseTag {
pub fn new(name: impl Into<String>, number: Option<i64>, promote: bool) -> Self {
Self {
name: name.into(),
number,
promote_tag_even_if_name_is_empty: promote,
}
}
pub fn has_tag(&self) -> bool {
!self.name.is_empty() || (self.number.is_some() && self.promote_tag_even_if_name_is_empty)
}
pub fn parse(input: &str) -> Self {
if input.trim().is_empty() {
return Self::default();
}
let re = regex::Regex::new(r"(?<name>.*?)\.?(?<number>\d+)?$").unwrap();
if let Some(c) = re.captures(input) {
let name = c.name("name").map(|m| m.as_str()).unwrap_or("").to_string();
let number = c
.name("number")
.and_then(|m| m.as_str().parse::<i64>().ok());
return Self {
name,
number,
promote_tag_even_if_name_is_empty: true,
};
}
Self {
name: input.to_string(),
number: None,
promote_tag_even_if_name_is_empty: true,
}
}
pub fn format(&self, legacy_dash: bool) -> String {
let _ = legacy_dash;
match self.number {
Some(n) if !self.name.is_empty() => format!("{}.{}", self.name, n),
Some(n) => n.to_string(),
None => self.name.clone(),
}
}
}
impl Ord for PreReleaseTag {
fn cmp(&self, other: &Self) -> Ordering {
match (self.has_tag(), other.has_tag()) {
(false, false) => Ordering::Equal,
(false, true) => Ordering::Greater,
(true, false) => Ordering::Less,
(true, true) => self
.name
.to_lowercase()
.cmp(&other.name.to_lowercase())
.then(self.number.unwrap_or(-1).cmp(&other.number.unwrap_or(-1))),
}
}
}
impl PartialOrd for PreReleaseTag {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BuildMetaData {
pub commits_since_tag: Option<i64>,
pub branch: Option<String>,
pub sha: Option<String>,
pub short_sha: Option<String>,
pub commit_date: Option<DateTime<FixedOffset>>,
pub other_metadata: Option<String>,
pub version_source_sha: Option<String>,
pub version_source_distance: i64,
pub uncommitted_changes: i64,
pub version_source_increment: VersionField,
}
impl BuildMetaData {
fn sanitize(s: &str) -> String {
let re = regex::Regex::new(r"[^0-9A-Za-z\-.]").unwrap();
re.replace_all(s, "-").into_owned()
}
pub fn format_short(&self) -> String {
self.commits_since_tag
.map(|c| c.to_string())
.unwrap_or_default()
}
pub fn format_full(&self) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(c) = self.commits_since_tag {
parts.push(c.to_string());
}
if let Some(b) = &self.branch {
parts.push(format!("Branch.{}", Self::sanitize(b)));
}
if let Some(s) = &self.sha {
parts.push(format!("Sha.{}", s));
}
if let Some(o) = &self.other_metadata {
if !o.is_empty() {
parts.push(Self::sanitize(o));
}
}
parts.join(".")
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SemanticVersion {
pub major: i64,
pub minor: i64,
pub patch: i64,
pub pre_release_tag: PreReleaseTag,
pub build_metadata: BuildMetaData,
}
impl SemanticVersion {
pub fn new(major: i64, minor: i64, patch: i64) -> Self {
Self {
major,
minor,
patch,
..Default::default()
}
}
pub fn major_minor_patch(&self) -> String {
format!("{}.{}.{}", self.major, self.minor, self.patch)
}
pub fn parse(input: &str, tag_prefix: &str) -> Option<Self> {
Self::parse_with(input, tag_prefix, false)
}
pub fn parse_with(input: &str, tag_prefix: &str, strict: bool) -> Option<Self> {
let trimmed = input.trim();
let body = if tag_prefix.is_empty() {
trimmed.to_string()
} else {
let re = regex::Regex::new(&format!("^({})", tag_prefix)).ok()?;
re.replace(trimmed, "").into_owned()
};
let body = body.trim();
if strict {
Self::parse_strict(body)
} else {
Self::parse_loose(body)
}
}
fn parse_strict(body: &str) -> Option<Self> {
let re = regex::Regex::new(
r"^(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)(?:-(?<tag>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?<meta>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$",
)
.ok()?;
let c = re.captures(body)?;
Some(Self {
major: c.name("major")?.as_str().parse().ok()?,
minor: c.name("minor")?.as_str().parse().ok()?,
patch: c.name("patch")?.as_str().parse().ok()?,
pre_release_tag: c
.name("tag")
.map(|m| PreReleaseTag::parse(m.as_str()))
.unwrap_or_default(),
build_metadata: BuildMetaData::default(),
})
}
fn parse_loose(body: &str) -> Option<Self> {
let re = regex::Regex::new(
r"^(?<major>\d+)(\.(?<minor>\d+))?(\.(?<patch>\d+))?(\.(?<fourth>\d+))?(-(?<tag>[^+]*))?(\+(?<meta>.*))?$",
)
.ok()?;
let c = re.captures(body)?;
let major = c.name("major")?.as_str().parse().ok()?;
let minor = c
.name("minor")
.and_then(|m| m.as_str().parse().ok())
.unwrap_or(0);
let patch = c
.name("patch")
.and_then(|m| m.as_str().parse().ok())
.unwrap_or(0);
let pre_release_tag = c
.name("tag")
.map(|m| PreReleaseTag::parse(m.as_str()))
.unwrap_or_default();
let build_metadata = BuildMetaData {
commits_since_tag: c.name("fourth").and_then(|m| m.as_str().parse().ok()),
..Default::default()
};
Some(Self {
major,
minor,
patch,
pre_release_tag,
build_metadata,
})
}
pub fn cmp_core(&self, other: &Self) -> Ordering {
self.major
.cmp(&other.major)
.then(self.minor.cmp(&other.minor))
.then(self.patch.cmp(&other.patch))
}
pub fn increment(&self, field: VersionField, label: Option<&str>, force: bool) -> Self {
let mut v = self.clone();
let has_pre = self.pre_release_tag.has_tag();
let bump_core = !has_pre || force;
match field {
VersionField::None => {}
VersionField::Patch if bump_core => v.patch += 1,
VersionField::Minor if bump_core => {
v.minor += 1;
v.patch = 0;
}
VersionField::Major if bump_core => {
v.major += 1;
v.minor = 0;
v.patch = 0;
}
_ => {}
}
if bump_core && field != VersionField::None {
v.pre_release_tag = PreReleaseTag::default();
}
if let Some(l) = label {
if v.pre_release_tag.has_tag() && v.pre_release_tag.name == l {
v.pre_release_tag.number = Some(v.pre_release_tag.number.unwrap_or(0) + 1);
} else {
v.pre_release_tag = PreReleaseTag::new(l, Some(1), l.is_empty());
}
}
v
}
}
impl fmt::Display for SemanticVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.major_minor_patch())?;
if self.pre_release_tag.has_tag() {
write!(f, "-{}", self.pre_release_tag.format(false))?;
}
Ok(())
}
}
impl Ord for SemanticVersion {
fn cmp(&self, other: &Self) -> Ordering {
self.cmp_core(other)
.then(self.pre_release_tag.cmp(&other.pre_release_tag))
}
}
impl PartialOrd for SemanticVersion {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::version::VersionField;
#[test]
fn parse_basic() {
let v = SemanticVersion::parse("v1.2.3", "[vV]?").unwrap();
assert_eq!((v.major, v.minor, v.patch), (1, 2, 3));
assert!(!v.pre_release_tag.has_tag());
}
#[test]
fn parse_partial_and_prerelease() {
let v = SemanticVersion::parse("1.2", "[vV]?").unwrap();
assert_eq!((v.major, v.minor, v.patch), (1, 2, 0));
let v = SemanticVersion::parse("2.0.0-beta.4", "[vV]?").unwrap();
assert_eq!(v.pre_release_tag.name, "beta");
assert_eq!(v.pre_release_tag.number, Some(4));
}
#[test]
fn ordering_stable_gt_prerelease() {
let stable = SemanticVersion::parse("1.0.0", "").unwrap();
let pre = SemanticVersion::parse("1.0.0-alpha.1", "").unwrap();
assert!(stable > pre);
}
#[test]
fn increment_empty_label_promotes_number() {
let base = SemanticVersion::new(0, 0, 0);
let v = base.increment(VersionField::Patch, Some(""), false);
assert_eq!(v.major_minor_patch(), "0.0.1");
assert_eq!(v.to_string(), "0.0.1-1");
assert_eq!(v.pre_release_tag.number, Some(1));
}
#[test]
fn increment_named_label_resets_to_one() {
let base = SemanticVersion::new(1, 0, 0);
let v = base.increment(VersionField::Minor, Some("alpha"), false);
assert_eq!(v.to_string(), "1.1.0-alpha.1");
}
#[test]
fn increment_same_label_bumps_number() {
let mut base = SemanticVersion::new(1, 1, 0);
base.pre_release_tag = PreReleaseTag::new("alpha", Some(1), false);
let v = base.increment(VersionField::Minor, Some("alpha"), false);
assert_eq!(v.to_string(), "1.1.0-alpha.2");
}
#[test]
fn strict_rejects_partial_version() {
assert!(SemanticVersion::parse_with("1.2", "[vV]?", true).is_none());
assert!(SemanticVersion::parse_with("1", "[vV]?", true).is_none());
assert!(SemanticVersion::parse_with("1.2.3", "[vV]?", true).is_some());
}
#[test]
fn loose_accepts_partial_version() {
let v = SemanticVersion::parse_with("1.2", "[vV]?", false).unwrap();
assert_eq!((v.major, v.minor, v.patch), (1, 2, 0));
let v = SemanticVersion::parse_with("v1", "[vV]?", false).unwrap();
assert_eq!((v.major, v.minor, v.patch), (1, 0, 0));
}
#[test]
fn strict_rejects_four_part_and_leading_zero() {
assert!(SemanticVersion::parse_with("1.2.3.4", "[vV]?", true).is_none());
assert!(SemanticVersion::parse_with("01.02.03", "[vV]?", true).is_none());
assert!(SemanticVersion::parse_with("1.2.3", "[vV]?", true).is_some());
}
#[test]
fn loose_accepts_four_part_and_leading_zero() {
let v = SemanticVersion::parse_with("1.2.3.4", "[vV]?", false).unwrap();
assert_eq!((v.major, v.minor, v.patch), (1, 2, 3));
assert_eq!(v.build_metadata.commits_since_tag, Some(4));
let v = SemanticVersion::parse_with("01.02.03", "[vV]?", false).unwrap();
assert_eq!((v.major, v.minor, v.patch), (1, 2, 3));
let v = SemanticVersion::parse_with("1.2.3", "[vV]?", false).unwrap();
assert_eq!(v.build_metadata.commits_since_tag, None);
}
#[test]
fn increment_none_keeps_core() {
let base = SemanticVersion::new(2, 0, 0);
let v = base.increment(VersionField::None, Some(""), false);
assert_eq!(v.major_minor_patch(), "2.0.0");
assert_eq!(v.to_string(), "2.0.0-1");
}
#[test]
fn prerelease_tag_parse_empty_returns_default() {
let t = PreReleaseTag::parse("");
assert!(!t.has_tag());
assert_eq!(t.name, "");
assert_eq!(t.number, None);
}
#[test]
fn prerelease_tag_format_number_only() {
let t = PreReleaseTag::new("", Some(3), true);
assert_eq!(t.format(false), "3");
}
#[test]
fn prerelease_tag_format_name_and_number() {
let t = PreReleaseTag::new("rc", Some(2), false);
assert_eq!(t.format(false), "rc.2");
}
#[test]
fn prerelease_tag_ordering_both_without_tag() {
let a = PreReleaseTag::default();
let b = PreReleaseTag::default();
assert_eq!(a.cmp(&b), std::cmp::Ordering::Equal);
assert_eq!(a.partial_cmp(&b), Some(std::cmp::Ordering::Equal));
}
#[test]
fn prerelease_tag_ordering_with_vs_without() {
let stable = PreReleaseTag::default();
let pre = PreReleaseTag::new("alpha", Some(1), false);
assert!(stable > pre);
assert!(pre < stable);
}
#[test]
fn build_metadata_format_short_none() {
let meta = BuildMetaData::default();
assert_eq!(meta.format_short(), "");
}
#[test]
fn build_metadata_format_short_value() {
let meta = BuildMetaData {
commits_since_tag: Some(5),
..Default::default()
};
assert_eq!(meta.format_short(), "5");
}
#[test]
fn build_metadata_format_full_all_fields() {
let meta = BuildMetaData {
commits_since_tag: Some(3),
branch: Some("feature/foo".into()),
sha: Some("abc1234".into()),
other_metadata: Some("extra!info".into()),
..Default::default()
};
let full = meta.format_full();
assert!(full.contains("3"), "commits: {full}");
assert!(
full.contains("Branch.feature-foo"),
"branch sanitize: {full}"
);
assert!(full.contains("Sha.abc1234"), "sha: {full}");
assert!(full.contains("extra-info"), "other sanitize: {full}");
}
#[test]
fn build_metadata_format_full_empty_other_omitted() {
let meta = BuildMetaData {
commits_since_tag: Some(1),
other_metadata: Some(String::new()),
..Default::default()
};
let full = meta.format_full();
assert_eq!(full, "1");
}
#[test]
fn semver_display_no_prerelease() {
let v = SemanticVersion::new(1, 2, 3);
assert_eq!(v.to_string(), "1.2.3");
}
#[test]
fn semver_partial_ord() {
let a = SemanticVersion::new(1, 0, 0);
let b = SemanticVersion::new(2, 0, 0);
assert!(a < b);
assert!(a.partial_cmp(&b) == Some(std::cmp::Ordering::Less));
}
#[test]
fn increment_major_resets_minor_patch() {
let base = SemanticVersion::new(1, 2, 3);
let v = base.increment(VersionField::Major, None, true);
assert_eq!((v.major, v.minor, v.patch), (2, 0, 0));
}
}