use std::cmp::Ordering;
use std::fmt;
use std::hash::{Hash, Hasher};
const LONGEST: usize = 16;
const MOST_NUMBERS: usize = 4;
#[derive(Clone, Copy)]
pub struct EngineVersion {
written: [u8; LONGEST],
length: usize,
numbers: [u32; MOST_NUMBERS],
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EngineVersionError {
Empty,
Colon {
accepted: Option<String>,
},
DotBeforeLetters {
accepted: String,
},
NotAVersion,
}
impl fmt::Display for EngineVersionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => f.write_str("there is no version after the `@`"),
Self::Colon { accepted: Some(accepted) } => write!(
f,
"a version never holds a colon, which is how a host and its port are written; \
write `{accepted}`"
),
Self::Colon { accepted: None } => f.write_str(
"a version never holds a colon, which is how a host and its port are written",
),
Self::DotBeforeLetters { accepted } => write!(
f,
"a version has no dot before its letters, which is how a mail address is \
written; write `{accepted}`"
),
Self::NotAVersion => f.write_str(
"a version is digits, with dots where the vendor uses them and letters only after \
the last digit, like `17`, `8.4` or `23ai`",
),
}
}
}
impl std::error::Error for EngineVersionError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Fault {
Empty,
Colon,
DotBeforeLetters,
NotAVersion,
}
impl EngineVersion {
#[must_use]
pub const fn declared(spelling: &str) -> Self {
match Self::read(spelling) {
Ok(version) => version,
Err(_) => panic!("a declared version is digits, dots and trailing letters"),
}
}
pub fn parse(spelling: &str) -> Result<Self, EngineVersionError> {
match Self::read(spelling) {
Ok(version) => Ok(version),
Err(Fault::Empty) => Err(EngineVersionError::Empty),
Err(Fault::Colon) => Err(EngineVersionError::Colon { accepted: None }),
Err(Fault::NotAVersion) => Err(EngineVersionError::NotAVersion),
Err(Fault::DotBeforeLetters) => {
let accepted = without_dots_before_letters(spelling);
if Self::read(&accepted).is_ok() {
Err(EngineVersionError::DotBeforeLetters { accepted })
} else {
Err(EngineVersionError::NotAVersion)
}
}
}
}
const fn read(spelling: &str) -> Result<Self, Fault> {
let bytes = spelling.as_bytes();
if bytes.is_empty() {
return Err(Fault::Empty);
}
let mut at = 0;
while at < bytes.len() {
if bytes[at] == b':' {
return Err(Fault::Colon);
}
at += 1;
}
if bytes.len() > LONGEST {
return Err(Fault::NotAVersion);
}
let mut written = [0u8; LONGEST];
let mut numbers = [0u32; MOST_NUMBERS];
let mut part = 0;
let mut digits = 0;
let mut lettered = false;
let mut at = 0;
while at < bytes.len() {
let byte = bytes[at];
written[at] = byte;
if byte.is_ascii_digit() {
if lettered || numbers[part] > (u32::MAX - 9) / 10 {
return Err(Fault::NotAVersion);
}
numbers[part] = numbers[part] * 10 + (byte - b'0') as u32;
digits += 1;
} else if byte == b'.' {
if digits == 0 || lettered {
return Err(Fault::NotAVersion);
}
if at + 1 < bytes.len() && bytes[at + 1].is_ascii_alphabetic() {
return Err(Fault::DotBeforeLetters);
}
part += 1;
digits = 0;
if part == MOST_NUMBERS {
return Err(Fault::NotAVersion);
}
} else if byte.is_ascii_alphabetic() {
if digits == 0 {
return Err(Fault::NotAVersion);
}
lettered = true;
} else {
return Err(Fault::NotAVersion);
}
at += 1;
}
if digits == 0 {
return Err(Fault::NotAVersion);
}
Ok(Self { written, length: bytes.len(), numbers })
}
#[must_use]
pub fn as_str(&self) -> &str {
std::str::from_utf8(&self.written[..self.length]).unwrap_or_default()
}
#[must_use]
pub const fn numbers(&self) -> [u32; MOST_NUMBERS] {
self.numbers
}
}
impl std::str::FromStr for EngineVersion {
type Err = EngineVersionError;
fn from_str(spelling: &str) -> Result<Self, Self::Err> {
Self::parse(spelling)
}
}
impl PartialEq for EngineVersion {
fn eq(&self, other: &Self) -> bool {
self.numbers == other.numbers
}
}
impl Eq for EngineVersion {}
impl PartialOrd for EngineVersion {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for EngineVersion {
fn cmp(&self, other: &Self) -> Ordering {
self.numbers.cmp(&other.numbers)
}
}
impl Hash for EngineVersion {
fn hash<H: Hasher>(&self, state: &mut H) {
self.numbers.hash(state);
}
}
impl fmt::Display for EngineVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl fmt::Debug for EngineVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "EngineVersion({})", self.as_str())
}
}
impl serde::Serialize for EngineVersion {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
pub fn split_platform(platform: &str) -> Result<(&str, Option<EngineVersion>), EngineVersionError> {
let platform = platform.trim();
if platform.contains(':') {
let accepted = match platform.split_once(':') {
Some((engine, version))
if !engine.contains('@') && EngineVersion::read(version).is_ok() =>
{
Some(format!("{engine}@{version}"))
}
_ => None,
};
return Err(EngineVersionError::Colon { accepted });
}
let Some((engine, version)) = platform.rsplit_once('@') else {
return Ok((platform, None));
};
match EngineVersion::parse(version) {
Ok(version) => Ok((engine, Some(version))),
Err(EngineVersionError::DotBeforeLetters { accepted }) => {
Err(EngineVersionError::DotBeforeLetters { accepted: format!("{engine}@{accepted}") })
}
Err(other) => Err(other),
}
}
fn without_dots_before_letters(spelling: &str) -> String {
let mut out = String::with_capacity(spelling.len());
let mut chars = spelling.chars().peekable();
while let Some(c) = chars.next() {
if c == '.' && chars.peek().is_some_and(char::is_ascii_alphabetic) {
continue;
}
out.push(c);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn read(spelling: &str) -> EngineVersion {
EngineVersion::parse(spelling).unwrap_or_else(|e| panic!("{spelling}: {e}"))
}
#[test]
fn the_four_shapes_a_vendor_writes_are_versions_and_display_as_written() {
for spelling in ["17", "8.4", "2022", "23ai", "2.00.088"] {
assert_eq!(read(spelling).to_string(), spelling);
}
}
#[test]
fn versions_are_ordered_by_their_numbers() {
assert!(read("15") < read("16"));
assert!(read("16") < read("17"));
assert!(read("8") < read("8.4"));
assert!(read("8.4") < read("8.10"));
assert!(read("19") < read("23ai"));
assert_eq!(read("8.4"), read("8.4.0"));
}
#[test]
fn the_letters_are_how_the_vendor_writes_it_and_not_its_order() {
assert_eq!(read("23ai"), read("23"));
assert_eq!(read("23ai").to_string(), "23ai");
}
#[test]
fn a_declared_version_reads_the_same_as_a_written_one() {
const FLOOR: EngineVersion = EngineVersion::declared("15");
assert_eq!(FLOOR, read("15"));
assert_eq!(FLOOR.to_string(), "15");
}
#[test]
fn a_dot_before_letters_is_refused_showing_the_accepted_spelling() {
assert_eq!(
EngineVersion::parse("23.ai"),
Err(EngineVersionError::DotBeforeLetters { accepted: "23ai".to_owned() })
);
let said = EngineVersion::parse("23.ai").unwrap_err().to_string();
assert!(said.contains("`23ai`"), "{said}");
assert!(said.contains("mail address"), "{said}");
}
#[test]
fn a_colon_is_refused() {
assert!(matches!(EngineVersion::parse("17:5432"), Err(EngineVersionError::Colon { .. })));
}
#[test]
fn what_is_not_a_version_is_refused() {
for spelling in ["latest", "v17", "17-beta", "17 ", ".17", "17.", "1..2", "1.2.3.4.5"] {
assert_eq!(
EngineVersion::parse(spelling),
Err(EngineVersionError::NotAVersion),
"{spelling}"
);
}
assert_eq!(EngineVersion::parse(""), Err(EngineVersionError::Empty));
assert_eq!(
EngineVersion::parse("12345678901234567"),
Err(EngineVersionError::NotAVersion),
"longer than any vendor writes one"
);
}
#[test]
fn a_platform_name_splits_on_its_last_at() {
assert_eq!(split_platform("postgres"), Ok(("postgres", None)));
assert_eq!(split_platform("postgres@17"), Ok(("postgres", Some(read("17")))));
assert_eq!(split_platform(" oracle@23ai "), Ok(("oracle", Some(read("23ai")))));
assert_eq!(split_platform("my@db@8.4"), Ok(("my@db", Some(read("8.4")))));
}
#[test]
fn a_platform_name_with_a_colon_is_refused_showing_the_at() {
assert_eq!(
split_platform("postgres:17"),
Err(EngineVersionError::Colon { accepted: Some("postgres@17".to_owned()) })
);
assert!(matches!(split_platform("postgres@17:1"), Err(EngineVersionError::Colon { .. })));
}
#[test]
fn a_platform_name_with_a_dot_before_letters_is_refused_showing_the_accepted_name() {
assert_eq!(
split_platform("oracle@23.ai"),
Err(EngineVersionError::DotBeforeLetters { accepted: "oracle@23ai".to_owned() })
);
}
#[test]
fn a_version_serializes_as_it_was_written() {
let json = serde_json::to_string(&[read("15"), read("23ai")]).unwrap();
assert_eq!(json, r#"["15","23ai"]"#);
}
}