m/cup
1
0
mirror of https://github.com/sergi0g/cup.git synced 2025-11-17 09:33:38 -05:00

feat: implement standard version parsing

This commit is contained in:
Sergio
2025-08-02 11:55:16 +03:00
parent 8a94e59dfa
commit 1550ad3456
6 changed files with 342 additions and 55 deletions

55
Cargo.lock generated
View File

@@ -2,11 +2,11 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
[[package]] [[package]]
name = "cup" name = "cup"
version = "4.0.0" version = "4.0.0"
dependencies = [ dependencies = [
"thiserror",
] ]
[[package]] [[package]]
@@ -18,56 +18,3 @@ name = "cup_server"
version = "4.0.0" version = "4.0.0"
[[package]] [[package]]
name = "proc-macro2"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "syn"
version = "2.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"

View File

@@ -6,4 +6,3 @@ edition = "2024"
[lib] [lib]
[dependencies] [dependencies]
thiserror = "2"

View File

@@ -0,0 +1 @@
pub struct DateVersion {}

View File

@@ -0,0 +1 @@
pub struct ExtendedVersion {}

View File

@@ -1,3 +1,119 @@
use crate::version::date::DateVersion;
use crate::version::extended::ExtendedVersion;
use crate::version::standard::StandardVersion;
pub mod date; pub mod date;
pub mod extended; pub mod extended;
pub mod standard; pub mod standard;
/// An enum used to refer to the version of an image independently of its versioning scheme. It strives to unify all common operations in one place so that there is no need to match on the type everywhere.
/// An alternative to this implementation would probably be having a `Version` trait and a lot of `Box` stuff going on which I'd rather avoid. For future readers: if you can think of a better way to handle this, I'm interested.
pub enum Version {
/// Used when the versioning scheme cannot be determined and as the default. Should ideally be avoided, especially in user code. TODO: Check if this enum is actually useful in user code, maybe none of it should be used.
Unknown,
/// There isn't any official "standard" versioning scheme. It's just a sane default which basically describes the versioning scheme previous versions of Cup would handle. Refer to `StandardVersion` for more info.
Standard(StandardVersion),
/// Likewise, "extended" is my own creation for describing a more customizable versioning scheme. Refer to `ExtendedVersion` for more info.
Extended(ExtendedVersion),
/// The "Date" versioning schema is for images which are versioned based on date and or time and come with their own quirky rules to compare them. Refer to `DateVersion` for more info.
Date(DateVersion),
}
mod version_component {
use std::{fmt::Display, num::ParseIntError, str::FromStr};
/// A struct describing a version component. The objective is to store the _string length_ of it alongside the numeric value so it can be padded with the required amount of zeroes when converted back to string representation.
#[derive(PartialEq, Debug)]
pub struct VersionComponent {
pub value: u32,
pub length: u8, // An OCI image tag can only be up to 127 characters long so it's impossible for length to exceed a u8. See https://github.com/distribution/reference/blob/727f80d42224f6696b8e1ad16b06aadf2c6b833b/regexp.go#L68.
}
impl FromStr for VersionComponent {
type Err = ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self {
value: s.parse()?,
length: s.len() as u8, // Cast is safe because `value.len()` is guaranteed to be much smaller than 255 characters. Refer to the comment on the `length` field of `VersionComponent`
})
}
}
impl Display for VersionComponent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:0>zeroes$}", self.value, zeroes = self.length as usize)
}
}
impl PartialOrd for VersionComponent {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
if self.length == other.length {
// We can't compare `2` with `01`. If you choose to zero-pad your numbers you'll have to do that everywhere, sorry.
self.value.partial_cmp(&other.value)
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::VersionComponent;
#[test]
fn parse_from_string_slice() {
let version_component = "0021";
assert_eq!(
VersionComponent::from_str(version_component).unwrap(),
VersionComponent {
value: 21,
length: 4
}
);
}
#[test]
fn stringify() {
let version_component = VersionComponent {
value: 21,
length: 4,
};
assert_eq!(version_component.to_string(), "0021");
}
#[test]
fn sort_components_with_equal_length() {
let component_a = VersionComponent {
value: 5,
length: 2,
};
let component_b = VersionComponent {
value: 7,
length: 2,
};
assert_eq!(
component_a.partial_cmp(&component_b),
Some(std::cmp::Ordering::Less)
)
}
#[test]
fn sort_components_with_different_length() {
let component_a = VersionComponent {
value: 5,
length: 2,
};
let component_b = VersionComponent {
value: 7,
length: 1,
};
assert_eq!(component_a.partial_cmp(&component_b), None)
}
}
}

View File

@@ -0,0 +1,223 @@
use std::{error::Error, fmt::Display, num::ParseIntError, str::FromStr};
use super::version_component::VersionComponent;
/// A versioning scheme I'd call SemVer-inspired. The main difference from [SemVer](https://semver.org) is that the minor and patch versions are optional.
/// It describes a version that is made up of one to three numeric components named `major`, `minor` and `patch`, separated by dots (`.`). Numbers can be prefixed by any number of zeroes.
/// In practice, this versioning scheme works well for most versioned images available and is a good out-of-the-box default.
#[cfg_attr(test, derive(Debug, PartialEq))]
pub struct StandardVersion {
major: VersionComponent,
minor: Option<VersionComponent>,
patch: Option<VersionComponent>,
}
impl FromStr for StandardVersion {
type Err = VersionParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let splits = s.split('.');
if splits.clone().any(|split| split.is_empty()) {
return Err(VersionParseError {
version_string: s.to_string(),
kind: VersionParseErrorKind::IncorrectFormat,
});
}
let mut component_iter = splits.map(|component| {
VersionComponent::from_str(component).map_err(|e| VersionParseError {
version_string: s.to_string(),
kind: VersionParseErrorKind::ParseComponent(e),
})
});
let major = component_iter.next().transpose()?.unwrap();
let minor = component_iter.next().transpose()?;
let patch = component_iter.next().transpose()?;
if component_iter.next().is_some() {
return Err(VersionParseError {
version_string: s.to_string(),
kind: VersionParseErrorKind::TooManyComponents(4 + component_iter.count()),
});
}
Ok(Self {
major,
minor,
patch,
})
}
}
#[derive(Debug)]
#[cfg_attr(test, derive(PartialEq))]
#[non_exhaustive]
pub struct VersionParseError {
pub version_string: String,
pub kind: VersionParseErrorKind,
}
impl Display for VersionParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"failed to parse `{}` as standard version",
self.version_string
)
}
}
impl Error for VersionParseError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.kind)
}
}
#[derive(Debug)]
#[cfg_attr(test, derive(PartialEq))]
pub enum VersionParseErrorKind {
/// A version component could not be parsed
ParseComponent(ParseIntError),
/// The version string is not in the format expected by `StandardVersion`
IncorrectFormat,
/// The version string includes more than 3 components
TooManyComponents(usize),
}
impl Display for VersionParseErrorKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self {
Self::IncorrectFormat => write!(f, "version string is incorrectly formatted"),
Self::ParseComponent(_) => write!(f, "version component is not a valid integer"),
Self::TooManyComponents(num_components) => write!(
f,
"expected up to three version components, received {}",
num_components
),
}
}
}
impl Error for VersionParseErrorKind {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match &self {
Self::ParseComponent(e) => Some(e),
Self::IncorrectFormat => None,
Self::TooManyComponents(_) => None,
}
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::{StandardVersion, VersionParseError, VersionParseErrorKind};
use super::super::version_component::VersionComponent;
#[test]
fn parse_single_number() {
assert_eq!(
StandardVersion::from_str("3"),
Ok(StandardVersion {
major: VersionComponent {
value: 3,
length: 1
},
minor: None,
patch: None
})
)
}
#[test]
fn parse_two_components() {
assert_eq!(
StandardVersion::from_str("3.14"),
Ok(StandardVersion {
major: VersionComponent {
value: 3,
length: 1
},
minor: Some(VersionComponent {
value: 14,
length: 2
}),
patch: None
})
)
}
#[test]
fn parse_three_components() {
assert_eq!(
StandardVersion::from_str("3.1.4"),
Ok(StandardVersion {
major: VersionComponent {
value: 3,
length: 1
},
minor: Some(VersionComponent {
value: 1,
length: 1
}),
patch: Some(VersionComponent {
value: 4,
length: 1
})
})
)
}
#[test]
fn parse_zero_prefixed() {
assert_eq!(
StandardVersion::from_str("01.28.04"),
Ok(StandardVersion {
major: VersionComponent {
value: 1,
length: 2
},
minor: Some(VersionComponent {
value: 28,
length: 2
}),
patch: Some(VersionComponent {
value: 4,
length: 2
})
})
)
}
#[test]
fn parse_invalid_string() {
assert_eq!(
StandardVersion::from_str(".1.0"),
Err(VersionParseError {
version_string: String::from(".1.0"),
kind: VersionParseErrorKind::IncorrectFormat
})
)
}
#[test]
fn parse_invalid_component() {
assert_eq!(
StandardVersion::from_str("0.1.O"),
Err(VersionParseError {
version_string: String::from("0.1.O"),
kind: VersionParseErrorKind::ParseComponent(
"O".parse::<u32>().unwrap_err()
)
})
)
}
#[test]
fn parse_four_components() {
assert_eq!(
StandardVersion::from_str("1.2.4.0"),
Err(VersionParseError {
version_string: String::from("1.2.4.0"),
kind: VersionParseErrorKind::TooManyComponents(4)
})
)
}
}