m/cup
1
0
mirror of https://github.com/sergi0g/cup.git synced 2025-11-17 09:33:38 -05:00
Files
cup/src/structs/standard_version.rs
2025-09-10 10:48:09 +03:00

181 lines
11 KiB
Rust

use std::{cmp::Ordering, fmt::Display};
use once_cell::sync::Lazy;
use regex::Regex;
#[derive(Clone, PartialEq, Debug, Default)] // Default is so I can avoid constructing a struct every time I want to use a version number of 0 as a default.
pub struct StandardVersionPart {
value: u32,
length: u8, // If the value is prefixed by zeroes, the total length, otherwise 0
}
impl StandardVersionPart {
fn from_split(split: &str) -> Self {
if split.len() == 1 && split == "0" {
Self::default()
} else {
Self {
value: split.parse().expect("Expected number to be less than 2^32"), // Unwrapping is safe, because we've verified that the string consists of digits and we don't care about supporting big numbers.
length: {
if split.starts_with('0') {
split.len() as u8 // We're casting the zeroes to u8, because no sane person uses more than 255 zeroes as a version prefix. Oh wait, tags can't even be that long
} else {
0
}
},
}
}
}
}
impl PartialOrd for StandardVersionPart {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
if self.length == other.length {
self.value.partial_cmp(&other.value)
} else {
None
}
}
}
impl Display for StandardVersionPart {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!(
"{:0<zeroes$}",
self.value,
zeroes = self.length as usize
))
}
}
/// Represents a semver-like version.
/// While not conforming to the SemVer standard, but was designed to handle common versioning schemes across a wide range of Docker images.
/// Minor and patch versions are considered optional.
/// Matching happens with a regex.
#[derive(Clone, PartialEq, Debug)]
pub struct StandardVersion {
pub major: StandardVersionPart,
pub minor: Option<StandardVersionPart>,
pub patch: Option<StandardVersionPart>,
pub format_str: String, // The tag with {} in the place the version was matched.
}
impl StandardVersion {
/// Tries to extract a semver-like version from a tag.
/// Returns a Result<StandardVersion, ()> indicating whether parsing succeeded
pub fn from_tag(tag: &str) -> Result<Self, ()> {
/// Heavily modified version of the official semver regex based on common tagging schemes for container images. Sometimes it matches more than once, but we'll try to select the best match.
static VERSION_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?P<major>[0-9]+)(?:\.(?P<minor>[0-9]*))?(?:\.(?P<patch>[0-9]*))?")
.unwrap()
});
let mut captures = VERSION_REGEX.captures_iter(tag);
// And now... terrible best match selection for everyone! Actually, it's probably not that terrible. I don't know.
match captures.next() {
Some(mut best_match) => {
let mut max_matches: u8 = 0; // Why does Rust not have `u2`s?
for capture in captures {
let count = capture.iter().filter_map(|c| c).count() as u8;
if count > max_matches {
max_matches = count;
best_match = capture;
}
}
let start_pos;
let mut end_pos;
let major: StandardVersionPart = match best_match.name("major") {
Some(major) => {
start_pos = major.start();
end_pos = major.end();
StandardVersionPart::from_split(major.as_str())
}
None => return Err(()),
};
let minor: Option<StandardVersionPart> = best_match.name("minor").map(|minor| {
end_pos = minor.end();
StandardVersionPart::from_split(minor.as_str())
});
let patch: Option<StandardVersionPart> = best_match.name("patch").map(|patch| {
end_pos = patch.end();
StandardVersionPart::from_split(patch.as_str())
});
let mut format_str = tag.to_string();
format_str.replace_range(start_pos..end_pos, "{}");
Ok(Self {
major,
minor,
patch,
format_str,
})
}
None => Err(()),
}
}
}
impl PartialOrd for StandardVersion {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
if self.format_str != other.format_str {
None
} else {
match self.major.partial_cmp(&other.major) {
Some(ordering) => match ordering {
Ordering::Equal => match self.minor.partial_cmp(&other.minor) {
Some(ordering) => match ordering {
Ordering::Equal => self.patch.partial_cmp(&other.patch),
_ => Some(ordering),
},
None => None,
},
_ => Some(ordering),
},
None => None,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[rustfmt::skip]
fn standard_version() {
assert_eq!(StandardVersion::from_tag("5.3.2"), Ok(StandardVersion { major: StandardVersionPart { value: 5, length: 0 }, minor: Some(StandardVersionPart { value: 3, length: 0 }), patch: Some(StandardVersionPart { value: 2, length: 0 }) , format_str: String::from("{}") }));
assert_eq!(StandardVersion::from_tag("14"), Ok(StandardVersion { major: StandardVersionPart { value: 14, length: 0 }, minor: None, patch: None , format_str: String::from("{}") }));
assert_eq!(StandardVersion::from_tag("v0.107.53"), Ok(StandardVersion { major: StandardVersionPart { value: 0, length: 0 }, minor: Some(StandardVersionPart { value: 107, length: 0 }), patch: Some(StandardVersionPart { value: 53, length: 0 }) , format_str: String::from("v{}") }));
assert_eq!(StandardVersion::from_tag("12-alpine"), Ok(StandardVersion { major: StandardVersionPart { value: 12, length: 0 }, minor: None, patch: None , format_str: String::from("{}-alpine") }));
assert_eq!(StandardVersion::from_tag("0.9.5-nginx"), Ok(StandardVersion { major: StandardVersionPart { value: 0, length: 0 }, minor: Some(StandardVersionPart { value: 9, length: 0 }), patch: Some(StandardVersionPart { value: 5, length: 0 }) , format_str: String::from("{}-nginx") }));
assert_eq!(StandardVersion::from_tag("v27.0"), Ok(StandardVersion { major: StandardVersionPart { value: 27, length: 0 }, minor: Some(StandardVersionPart { value: 0, length: 0 }), patch: None , format_str: String::from("v{}") }));
assert_eq!(StandardVersion::from_tag("16.1"), Ok(StandardVersion { major: StandardVersionPart { value: 16, length: 0 }, minor: Some(StandardVersionPart { value: 1, length: 0 }), patch: None , format_str: String::from("{}") }));
assert_eq!(StandardVersion::from_tag("version-1.5.6"), Ok(StandardVersion { major: StandardVersionPart { value: 1, length: 0 }, minor: Some(StandardVersionPart { value: 5, length: 0 }), patch: Some(StandardVersionPart { value: 6, length: 0 }) , format_str: String::from("version-{}") }));
assert_eq!(StandardVersion::from_tag("15.4-alpine"), Ok(StandardVersion { major: StandardVersionPart { value: 15, length: 0 }, minor: Some(StandardVersionPart { value: 4, length: 0 }), patch: None , format_str: String::from("{}-alpine") }));
assert_eq!(StandardVersion::from_tag("pg14-v0.2.0"), Ok(StandardVersion { major: StandardVersionPart { value: 0, length: 0 }, minor: Some(StandardVersionPart { value: 2, length: 0 }), patch: Some(StandardVersionPart { value: 0, length: 0 }) , format_str: String::from("pg14-v{}") }));
assert_eq!(StandardVersion::from_tag("18-jammy-full.s6-v0.88.0"), Ok(StandardVersion { major: StandardVersionPart { value: 0, length: 0 }, minor: Some(StandardVersionPart { value: 88, length: 0 }), patch: Some(StandardVersionPart { value: 0, length: 0 }) , format_str: String::from("18-jammy-full.s6-v{}") }));
assert_eq!(StandardVersion::from_tag("fpm-2.1.0-prod"), Ok(StandardVersion { major: StandardVersionPart { value: 2, length: 0 }, minor: Some(StandardVersionPart { value: 1, length: 0 }), patch: Some(StandardVersionPart { value: 0, length: 0 }) , format_str: String::from("fpm-{}-prod") }));
assert_eq!(StandardVersion::from_tag("7.3.3.50"), Ok(StandardVersion { major: StandardVersionPart { value: 7, length: 0 }, minor: Some(StandardVersionPart { value: 3, length: 0 }), patch: Some(StandardVersionPart { value: 3, length: 0 }) , format_str: String::from("{}.50") }));
assert_eq!(StandardVersion::from_tag("1.21.11-0"), Ok(StandardVersion { major: StandardVersionPart { value: 1, length: 0 }, minor: Some(StandardVersionPart { value: 21, length: 0 }), patch: Some(StandardVersionPart { value: 11, length: 0 }) , format_str: String::from("{}-0") }));
assert_eq!(StandardVersion::from_tag("4.1.2.1-full"), Ok(StandardVersion { major: StandardVersionPart { value: 4, length: 0 }, minor: Some(StandardVersionPart { value: 1, length: 0 }), patch: Some(StandardVersionPart { value: 2, length: 0 }) , format_str: String::from("{}.1-full") }));
assert_eq!(StandardVersion::from_tag("v4.0.3-ls215"), Ok(StandardVersion { major: StandardVersionPart { value: 4, length: 0 }, minor: Some(StandardVersionPart { value: 0, length: 0 }), patch: Some(StandardVersionPart { value: 3, length: 0 }) , format_str: String::from("v{}-ls215") }));
assert_eq!(StandardVersion::from_tag("24.04.11.2.1"), Ok(StandardVersion { major: StandardVersionPart { value: 24, length: 0 }, minor: Some(StandardVersionPart { value: 4, length: 2 }), patch: Some(StandardVersionPart { value: 11, length: 0 }) , format_str: String::from("{}.2.1") }));
assert_eq!(StandardVersion::from_tag("example15-test"), Ok(StandardVersion { major: StandardVersionPart { value: 15, length: 0 }, minor: None, patch: None , format_str: String::from("example{}-test") }));
assert_eq!(StandardVersion::from_tag("watch-the-dot-5.3.2.careful"), Ok(StandardVersion { major: StandardVersionPart { value: 5, length: 0 }, minor: Some(StandardVersionPart { value: 3, length: 0 }), patch: Some(StandardVersionPart { value: 2, length: 0 }), format_str: String::from("watch-the-dot-{}.careful") }));
}
#[test]
fn version_part() {
assert_eq!(
format!(
"{:?}",
StandardVersionPart {
value: 21,
length: 4
}
),
String::from("0021")
);
}
}