diff --git a/Cargo.lock b/Cargo.lock index 3843cd7..811927e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/cup/Cargo.toml b/cup/Cargo.toml index 26a379c..e031966 100644 --- a/cup/Cargo.toml +++ b/cup/Cargo.toml @@ -6,3 +6,5 @@ edition = "2024" [lib] [dependencies] +regex = "1.11.1" +rustc-hash = "2.1.1" diff --git a/cup/src/version/extended.rs b/cup/src/version/extended.rs index 95cdec8..3c6da17 100644 --- a/cup/src/version/extended.rs +++ b/cup/src/version/extended.rs @@ -1 +1,340 @@ -pub struct ExtendedVersion {} \ No newline at end of file +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, +} + +impl ExtendedVersion { + pub fn parse(version_string: &str, regex: &str) -> Result { + 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::, 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::, 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(?\d+)\.(?\d+)\.(?\d+)-ls(?\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::().unwrap_err()) + }) + ) + } + + #[test] + fn non_uniform_groups() { + assert_eq!( + ExtendedVersion::parse( + "4.1.2.5", + r"(?\d+)\.(?\d+)\.(?\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"(?\d+)\.(?\d+)\.(?\d+)|(?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 +}