use crate::error::{PachaError, Result};
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ModelVersion {
pub major: u32,
pub minor: u32,
pub patch: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub prerelease: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub build: Option<String>,
}
impl ModelVersion {
#[must_use]
pub fn new(major: u32, minor: u32, patch: u32) -> Self {
Self {
major,
minor,
patch,
prerelease: None,
build: None,
}
}
#[must_use]
pub fn zero() -> Self {
Self::new(0, 0, 0)
}
#[must_use]
pub fn initial() -> Self {
Self::new(1, 0, 0)
}
#[must_use]
pub fn with_prerelease(mut self, prerelease: impl Into<String>) -> Self {
self.prerelease = Some(prerelease.into());
self
}
#[must_use]
pub fn with_build(mut self, build: impl Into<String>) -> Self {
self.build = Some(build.into());
self
}
#[must_use]
pub fn bump_major(&self) -> Self {
Self::new(self.major + 1, 0, 0)
}
#[must_use]
pub fn bump_minor(&self) -> Self {
Self::new(self.major, self.minor + 1, 0)
}
#[must_use]
pub fn bump_patch(&self) -> Self {
Self::new(self.major, self.minor, self.patch + 1)
}
#[must_use]
pub fn is_prerelease(&self) -> bool {
self.prerelease.is_some()
}
#[must_use]
pub fn is_stable(&self) -> bool {
self.major >= 1 && self.prerelease.is_none()
}
pub fn parse(s: &str) -> Result<Self> {
s.parse()
}
}
impl Default for ModelVersion {
fn default() -> Self {
Self::initial()
}
}
impl fmt::Display for ModelVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
if let Some(ref pre) = self.prerelease {
write!(f, "-{pre}")?;
}
if let Some(ref build) = self.build {
write!(f, "+{build}")?;
}
Ok(())
}
}
impl FromStr for ModelVersion {
type Err = PachaError;
fn from_str(s: &str) -> Result<Self> {
let (version_pre, build) = match s.split_once('+') {
Some((v, b)) => (v, Some(b.to_string())),
None => (s, None),
};
let (version, prerelease) = match version_pre.split_once('-') {
Some((v, p)) => (v, Some(p.to_string())),
None => (version_pre, None),
};
let parts: Vec<&str> = version.split('.').collect();
if parts.len() != 3 {
return Err(PachaError::InvalidVersion(format!(
"expected MAJOR.MINOR.PATCH, got '{s}'"
)));
}
let major = parts[0]
.parse::<u32>()
.map_err(|_| PachaError::InvalidVersion(format!("invalid major version in '{s}'")))?;
let minor = parts[1]
.parse::<u32>()
.map_err(|_| PachaError::InvalidVersion(format!("invalid minor version in '{s}'")))?;
let patch = parts[2]
.parse::<u32>()
.map_err(|_| PachaError::InvalidVersion(format!("invalid patch version in '{s}'")))?;
Ok(Self {
major,
minor,
patch,
prerelease,
build,
})
}
}
impl PartialOrd for ModelVersion {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ModelVersion {
fn cmp(&self, other: &Self) -> Ordering {
match self.major.cmp(&other.major) {
Ordering::Equal => {}
ord => return ord,
}
match self.minor.cmp(&other.minor) {
Ordering::Equal => {}
ord => return ord,
}
match self.patch.cmp(&other.patch) {
Ordering::Equal => {}
ord => return ord,
}
match (&self.prerelease, &other.prerelease) {
(None, None) => Ordering::Equal,
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(Some(a), Some(b)) => a.cmp(b),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn test_version_new() {
let v = ModelVersion::new(1, 2, 3);
assert_eq!(v.major, 1);
assert_eq!(v.minor, 2);
assert_eq!(v.patch, 3);
assert!(v.prerelease.is_none());
assert!(v.build.is_none());
}
#[test]
fn test_version_display() {
assert_eq!(ModelVersion::new(1, 2, 3).to_string(), "1.2.3");
assert_eq!(
ModelVersion::new(1, 0, 0)
.with_prerelease("beta.1")
.to_string(),
"1.0.0-beta.1"
);
assert_eq!(
ModelVersion::new(1, 0, 0).with_build("run-123").to_string(),
"1.0.0+run-123"
);
assert_eq!(
ModelVersion::new(1, 0, 0)
.with_prerelease("rc.1")
.with_build("abc")
.to_string(),
"1.0.0-rc.1+abc"
);
}
#[test]
fn test_version_parse() {
assert_eq!(
"1.2.3".parse::<ModelVersion>().unwrap(),
ModelVersion::new(1, 2, 3)
);
assert_eq!(
"0.0.0".parse::<ModelVersion>().unwrap(),
ModelVersion::zero()
);
let with_pre: ModelVersion = "1.0.0-beta.1".parse().unwrap();
assert_eq!(with_pre.prerelease, Some("beta.1".to_string()));
let with_build: ModelVersion = "1.0.0+run-123".parse().unwrap();
assert_eq!(with_build.build, Some("run-123".to_string()));
let full: ModelVersion = "2.1.0-rc.1+build.456".parse().unwrap();
assert_eq!(full.major, 2);
assert_eq!(full.minor, 1);
assert_eq!(full.patch, 0);
assert_eq!(full.prerelease, Some("rc.1".to_string()));
assert_eq!(full.build, Some("build.456".to_string()));
}
#[test]
fn test_version_parse_errors() {
assert!("1.2".parse::<ModelVersion>().is_err());
assert!("1.2.3.4".parse::<ModelVersion>().is_err());
assert!("a.b.c".parse::<ModelVersion>().is_err());
assert!("1.2.three".parse::<ModelVersion>().is_err());
}
#[test]
fn test_version_bump() {
let v = ModelVersion::new(1, 2, 3);
assert_eq!(v.bump_major(), ModelVersion::new(2, 0, 0));
assert_eq!(v.bump_minor(), ModelVersion::new(1, 3, 0));
assert_eq!(v.bump_patch(), ModelVersion::new(1, 2, 4));
}
#[test]
fn test_version_ordering() {
let v100 = ModelVersion::new(1, 0, 0);
let v110 = ModelVersion::new(1, 1, 0);
let v111 = ModelVersion::new(1, 1, 1);
let v200 = ModelVersion::new(2, 0, 0);
assert!(v100 < v110);
assert!(v110 < v111);
assert!(v111 < v200);
}
#[test]
fn test_prerelease_ordering() {
let stable = ModelVersion::new(1, 0, 0);
let beta = ModelVersion::new(1, 0, 0).with_prerelease("beta");
let alpha = ModelVersion::new(1, 0, 0).with_prerelease("alpha");
assert!(beta < stable);
assert!(alpha < stable);
assert!(alpha < beta);
}
#[test]
fn test_is_stable() {
assert!(ModelVersion::new(1, 0, 0).is_stable());
assert!(ModelVersion::new(2, 5, 3).is_stable());
assert!(!ModelVersion::new(0, 9, 0).is_stable());
assert!(!ModelVersion::new(1, 0, 0)
.with_prerelease("beta")
.is_stable());
}
#[test]
fn test_serialization() {
let v = ModelVersion::new(1, 2, 3).with_prerelease("rc.1");
let json = serde_json::to_string(&v).unwrap();
let deserialized: ModelVersion = serde_json::from_str(&json).unwrap();
assert_eq!(v, deserialized);
}
proptest! {
#[test]
fn prop_version_roundtrip(major: u32, minor: u32, patch: u32) {
let v = ModelVersion::new(major, minor, patch);
let s = v.to_string();
let parsed: ModelVersion = s.parse().unwrap();
prop_assert_eq!(v, parsed);
}
#[test]
fn prop_bump_major_resets(major in 0u32..1000, minor in 0u32..1000, patch in 0u32..1000) {
let v = ModelVersion::new(major, minor, patch);
let bumped = v.bump_major();
prop_assert_eq!(bumped.major, major + 1);
prop_assert_eq!(bumped.minor, 0);
prop_assert_eq!(bumped.patch, 0);
}
#[test]
fn prop_bump_minor_resets_patch(major in 0u32..1000, minor in 0u32..1000, patch in 0u32..1000) {
let v = ModelVersion::new(major, minor, patch);
let bumped = v.bump_minor();
prop_assert_eq!(bumped.major, major);
prop_assert_eq!(bumped.minor, minor + 1);
prop_assert_eq!(bumped.patch, 0);
}
#[test]
fn prop_ordering_transitive(
a_major in 0u32..10, a_minor in 0u32..10, a_patch in 0u32..10,
b_major in 0u32..10, b_minor in 0u32..10, b_patch in 0u32..10,
c_major in 0u32..10, c_minor in 0u32..10, c_patch in 0u32..10,
) {
let a = ModelVersion::new(a_major, a_minor, a_patch);
let b = ModelVersion::new(b_major, b_minor, b_patch);
let c = ModelVersion::new(c_major, c_minor, c_patch);
if a < b && b < c {
prop_assert!(a < c);
}
}
}
}