versa 1.9.0

Versa types and utilities for developing Versa client applications in Rust
Documentation
//! Semver-style comparison for schema version strings (e.g. "2.3.0", "2.3.0-rc1").

use std::cmp::Ordering;
use std::fmt;
use std::str::FromStr;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Version {
  pub major: u64,
  pub minor: u64,
  pub patch: u64,
  pub pre_release: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseVersionError(String);

impl fmt::Display for ParseVersionError {
  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    write!(f, "invalid schema version: {}", self.0)
  }
}

impl std::error::Error for ParseVersionError {}

impl FromStr for Version {
  type Err = ParseVersionError;

  fn from_str(s: &str) -> Result<Self, Self::Err> {
    let core_and_pre = s.split_once('+').map(|(a, _)| a).unwrap_or(s);
    let (core, pre_release) = match core_and_pre.split_once('-') {
      Some((c, p)) if !p.is_empty() => (c, Some(p.to_string())),
      Some(_) => return Err(ParseVersionError(s.to_string())),
      None => (core_and_pre, None),
    };

    let mut parts = core.split('.');
    let major = parse_numeric(parts.next(), s)?;
    let minor = parse_numeric(parts.next(), s)?;
    let patch = parse_numeric(parts.next(), s)?;
    if parts.next().is_some() {
      return Err(ParseVersionError(s.to_string()));
    }

    Ok(Version {
      major,
      minor,
      patch,
      pre_release,
    })
  }
}

fn parse_numeric(part: Option<&str>, full: &str) -> Result<u64, ParseVersionError> {
  part
    .filter(|p| !p.is_empty())
    .and_then(|p| p.parse::<u64>().ok())
    .ok_or_else(|| ParseVersionError(full.to_string()))
}

impl Ord for Version {
  fn cmp(&self, other: &Self) -> Ordering {
    self
      .major
      .cmp(&other.major)
      .then_with(|| self.minor.cmp(&other.minor))
      .then_with(|| self.patch.cmp(&other.patch))
      .then_with(|| compare_pre_release(self.pre_release.as_deref(), other.pre_release.as_deref()))
  }
}

impl PartialOrd for Version {
  fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
    Some(self.cmp(other))
  }
}

impl fmt::Display for Version {
  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
    if let Some(pre) = &self.pre_release {
      write!(f, "-{}", pre)?;
    }
    Ok(())
  }
}

// Per semver 2.0: a version without pre-release outranks one with a pre-release.
// Pre-release strings compare dot-separated identifier by identifier; numeric
// identifiers compare numerically and rank below alphanumeric ones.
fn compare_pre_release(a: Option<&str>, b: Option<&str>) -> Ordering {
  match (a, b) {
    (None, None) => Ordering::Equal,
    (None, Some(_)) => Ordering::Greater,
    (Some(_), None) => Ordering::Less,
    (Some(a), Some(b)) => {
      let mut a_parts = a.split('.');
      let mut b_parts = b.split('.');
      loop {
        match (a_parts.next(), b_parts.next()) {
          (None, None) => return Ordering::Equal,
          (None, Some(_)) => return Ordering::Less,
          (Some(_), None) => return Ordering::Greater,
          (Some(ai), Some(bi)) => {
            let ord = match (ai.parse::<u64>(), bi.parse::<u64>()) {
              (Ok(an), Ok(bn)) => an.cmp(&bn),
              (Ok(_), Err(_)) => Ordering::Less,
              (Err(_), Ok(_)) => Ordering::Greater,
              (Err(_), Err(_)) => ai.cmp(bi),
            };
            if ord != Ordering::Equal {
              return ord;
            }
          }
        }
      }
    }
  }
}

/// Compare two schema version strings. Returns `Err` if either fails to parse.
pub fn compare(a: &str, b: &str) -> Result<Ordering, ParseVersionError> {
  Ok(Version::from_str(a)?.cmp(&Version::from_str(b)?))
}

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

  #[test]
  fn parses_release() {
    let v = Version::from_str("2.3.0").unwrap();
    assert_eq!(v.major, 2);
    assert_eq!(v.minor, 3);
    assert_eq!(v.patch, 0);
    assert_eq!(v.pre_release, None);
  }

  #[test]
  fn parses_pre_release() {
    let v = Version::from_str("2.3.0-rc1").unwrap();
    assert_eq!(v.pre_release.as_deref(), Some("rc1"));
  }

  #[test]
  fn parses_double_digit_minor() {
    let v = Version::from_str("1.11.0").unwrap();
    assert_eq!((v.major, v.minor, v.patch), (1, 11, 0));
  }

  #[test]
  fn strips_build_metadata() {
    let v = Version::from_str("2.3.0+sha.abc").unwrap();
    assert_eq!(v.pre_release, None);
  }

  #[test]
  fn rejects_garbage() {
    assert!(Version::from_str("not-a-version").is_err());
    assert!(Version::from_str("1.0").is_err());
    assert!(Version::from_str("1.0.0.0").is_err());
    assert!(Version::from_str("1.0.0-").is_err());
  }

  #[test]
  fn compare_release_numeric() {
    assert_eq!(compare("1.11.0", "2.0.0").unwrap(), Ordering::Less);
    assert_eq!(compare("2.1.1", "2.1.0").unwrap(), Ordering::Greater);
    assert_eq!(compare("2.2.0", "2.2.0").unwrap(), Ordering::Equal);
    // Double-digit minor must not sort lexically before single-digit.
    assert_eq!(compare("1.11.0", "1.2.0").unwrap(), Ordering::Greater);
  }

  #[test]
  fn compare_pre_release_vs_release() {
    assert_eq!(compare("2.3.0-rc1", "2.3.0").unwrap(), Ordering::Less);
    assert_eq!(compare("2.3.0", "2.3.0-rc1").unwrap(), Ordering::Greater);
  }

  #[test]
  fn compare_pre_release_identifiers() {
    assert_eq!(
      compare("2.3.0-alpha", "2.3.0-beta").unwrap(),
      Ordering::Less
    );
    assert_eq!(compare("2.3.0-rc.1", "2.3.0-rc.2").unwrap(), Ordering::Less);
    // Numeric identifiers rank below alphanumeric.
    assert_eq!(
      compare("2.3.0-rc.1", "2.3.0-rc.alpha").unwrap(),
      Ordering::Less
    );
    // Longer pre-release chain outranks its prefix.
    assert_eq!(compare("2.3.0-rc", "2.3.0-rc.1").unwrap(), Ordering::Less);
  }

  #[test]
  fn ord_impl_sorts_correctly() {
    let mut versions = vec![
      Version::from_str("2.3.0").unwrap(),
      Version::from_str("1.11.0").unwrap(),
      Version::from_str("2.3.0-rc1").unwrap(),
      Version::from_str("2.0.0").unwrap(),
    ];
    versions.sort();
    let rendered: Vec<String> = versions.iter().map(|v| v.to_string()).collect();
    assert_eq!(rendered, vec!["1.11.0", "2.0.0", "2.3.0-rc1", "2.3.0"]);
  }
}