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

feat: implement extended version parsing

This commit is contained in:
Sergio
2025-08-05 11:15:41 +03:00
parent 1550ad3456
commit 7a4345076f
3 changed files with 391 additions and 1 deletions

49
Cargo.lock generated
View File

@@ -3,10 +3,20 @@
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "cup"
version = "4.0.0"
dependencies = [
"regex",
"rustc-hash",
]
[[package]]
@@ -18,3 +28,42 @@ name = "cup_server"
version = "4.0.0"
[[package]]
name = "memchr"
version = "2.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "regex"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"

View File

@@ -6,3 +6,5 @@ edition = "2024"
[lib]
[dependencies]
regex = "1.11.1"
rustc-hash = "2.1.1"

View File

@@ -1 +1,340 @@
pub struct ExtendedVersion {}
use std::num::ParseIntError;
use std::str::FromStr;
use std::{error::Error, fmt::Display};
use regex::Regex;
use rustc_hash::FxHashMap;
use crate::version::version_component::VersionComponent;
/// This doesn't describe a specific versioning scheme, but instead aims to provide the utilites for parsing a version string not covered by any of the other parsers in the crate.
/// Takes a regex with a capture group for each component (either _all_ named or anonymous)
#[cfg_attr(test, derive(Debug, PartialEq))]
pub struct ExtendedVersion {
components: FxHashMap<String, VersionComponent>,
}
impl ExtendedVersion {
pub fn parse(version_string: &str, regex: &str) -> Result<Self, VersionParseError> {
let regex = Regex::new(regex).map_err(|e| VersionParseError {
version_string: version_string.to_string(),
kind: VersionParseErrorKind::InvalidRegex(e),
})?;
// Check if all capture groups are named or anonymous
let is_named = match regex
.capture_names()
.skip(1) // The first group will always be the implicit anonymous group for the whole regex
.try_fold(None, |prev_state, name| {
match prev_state {
None => Ok(Some(name)), // First iteration
Some(prev_state) => match (prev_state, name) {
(Some(_), None) | (None, Some(_)) => Err(VersionParseError {
version_string: version_string.to_string(),
kind: VersionParseErrorKind::NonUniformCaptureGroups,
}),
_ => Ok(Some(name)),
},
}
}) {
Ok(Some(Some(_))) => true,
Ok(Some(None)) => false,
Ok(None) => false,
Err(e) => return Err(e),
};
// Parse the version string
Ok(match regex.captures(version_string) {
Some(captures) => {
let components = if is_named {
regex
.capture_names()
.flatten()
.map(|name| {
let capture = captures.name(name).ok_or_else(|| VersionParseError {
version_string: version_string.to_string(),
kind: VersionParseErrorKind::GroupDidNotMatch(name.to_string()),
})?;
let component =
VersionComponent::from_str(capture.as_str()).map_err(|_| {
VersionParseError {
version_string: version_string.to_string(),
kind: VersionParseErrorKind::GroupDidNotMatch(
name.to_string(),
),
}
})?;
Ok((name.to_string(), component))
})
.collect::<Result<FxHashMap<String, VersionComponent>, VersionParseError>>(
)?
} else {
captures
.iter()
.enumerate()
.skip(1) // skip group 0 (whole match)
.filter_map(|(i, m)| m.map(|mat| (i.to_string(), mat)))
.map(|(i, m)| {
VersionComponent::from_str(m.as_str())
.map(|comp| (i, comp))
.map_err(|e| VersionParseError {
version_string: version_string.to_string(),
kind: VersionParseErrorKind::ParseComponent(e),
})
})
.collect::<Result<FxHashMap<String, VersionComponent>, VersionParseError>>(
)?
};
Self { components }
}
None => {
return Err(VersionParseError {
version_string: version_string.to_string(),
kind: VersionParseErrorKind::NoMatch,
});
}
})
}
}
#[derive(Debug)]
#[cfg_attr(test, derive(PartialEq))]
#[non_exhaustive]
pub struct VersionParseError {
version_string: String,
kind: VersionParseErrorKind,
}
impl Display for VersionParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"failed to parse `{}` as extended 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 {
/// The regex supplied could not be compiled
InvalidRegex(regex::Error),
/// The regex supplied has both named and anonymous capture groups
NonUniformCaptureGroups,
/// The version string did not match the regex supplied
NoMatch,
/// A named group had no matches
GroupDidNotMatch(String),
/// A version component could not be parsed
ParseComponent(ParseIntError),
}
impl Display for VersionParseErrorKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidRegex(_) => write!(f, "invalid regex"),
Self::NonUniformCaptureGroups => {
write!(f, "regex has both named and anonymous capture groups")
}
Self::NoMatch => write!(f, "version string did not match the regex"),
Self::GroupDidNotMatch(name) => {
write!(f, "named group `{}` did not match", name)
}
Self::ParseComponent(_) => write!(f, "version component is not a valid integer"),
}
}
}
impl Error for VersionParseErrorKind {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::InvalidRegex(e) => Some(e),
Self::ParseComponent(e) => Some(e),
Self::GroupDidNotMatch(_) => None,
Self::NoMatch => None,
Self::NonUniformCaptureGroups => None,
}
}
}
#[cfg(test)]
mod tests {
use regex::Regex;
use rustc_hash::FxHashMap;
use crate::version::version_component::VersionComponent;
use super::ExtendedVersion;
use super::VersionParseError;
use super::VersionParseErrorKind;
#[test]
fn parse_with_anonymous() {
assert_eq!(
ExtendedVersion::parse("24.04.11.2.1", r"(\d+)\.(\d+)\.(\d+)\.(\d+)\.(\d+)"),
Ok(ExtendedVersion {
components: {
let mut map = FxHashMap::default();
map.insert(
"1".to_string(),
VersionComponent {
value: 24,
length: 2,
},
);
map.insert(
"2".to_string(),
VersionComponent {
value: 4,
length: 2,
},
);
map.insert(
"3".to_string(),
VersionComponent {
value: 11,
length: 2,
},
);
map.insert(
"4".to_string(),
VersionComponent {
value: 2,
length: 1,
},
);
map.insert(
"5".to_string(),
VersionComponent {
value: 1,
length: 1,
},
);
map
}
})
)
}
#[test]
fn parse_with_named() {
assert_eq!(
ExtendedVersion::parse(
"v0.7.1-ls84",
r"v(?<Alpha>\d+)\.(?<Beta>\d+)\.(?<Gamma>\d+)-ls(?<Delta>\d+)"
),
Ok(ExtendedVersion {
components: {
let mut map = FxHashMap::default();
map.insert(
"Alpha".to_string(),
VersionComponent {
value: 0,
length: 1,
},
);
map.insert(
"Beta".to_string(),
VersionComponent {
value: 7,
length: 1,
},
);
map.insert(
"Gamma".to_string(),
VersionComponent {
value: 1,
length: 1,
},
);
map.insert(
"Delta".to_string(),
VersionComponent {
value: 84,
length: 2,
},
);
map
}
})
)
}
#[test]
fn invalid_regex() {
assert_eq!(
ExtendedVersion::parse(
"1.0.0-test2",
r"(\d+)\.(\d+))\.(\d+)-test(\d+)" // Whoops, someone left an extra ) somewhere...
),
Err(VersionParseError {
version_string: String::from("1.0.0-test2"),
kind: VersionParseErrorKind::InvalidRegex(
Regex::new(r"(\d+)\.(\d+))\.(\d+)-test(\d+)").unwrap_err()
)
})
)
}
#[test]
fn invalid_component() {
assert_eq!(
ExtendedVersion::parse("50h+2-3_40", r"([a-z0-9]+)\+(\d+)-(\d+)_(\d+)"),
Err(VersionParseError {
version_string: String::from("50h+2-3_40"),
kind: VersionParseErrorKind::ParseComponent("50h".parse::<usize>().unwrap_err())
})
)
}
#[test]
fn non_uniform_groups() {
assert_eq!(
ExtendedVersion::parse(
"4.1.2.5",
r"(?<Major>\d+)\.(?<Minor>\d+)\.(?<Patch>\d+)\.(\d+)"
),
Err(VersionParseError {
version_string: String::from("4.1.2.5"),
kind: VersionParseErrorKind::NonUniformCaptureGroups
})
)
}
#[test]
fn no_match() {
assert_eq!(
ExtendedVersion::parse(
"1-sometool-0.2.5-alpine",
r"(?:\d+)-somet0ol-(\d+)\.(\d+)\.(\d+)-alpine"
),
Err(VersionParseError {
version_string: String::from("1-sometool-0.2.5-alpine"),
kind: VersionParseErrorKind::NoMatch
})
)
}
#[test]
fn no_group_match() {
assert_eq!(
ExtendedVersion::parse(
"2.0.5-alpine",
r"(?<Eenie>\d+)\.(?<Meenie>\d+)\.(?<Miney>\d+)|(?<Moe>moe)-alpine"
),
Err(VersionParseError {
version_string: String::from("2.0.5-alpine"),
kind: VersionParseErrorKind::GroupDidNotMatch(String::from("Moe"))
})
)
}
// TODO: Maybe check that if an anonymous group does not match everything is fine
}