use std::{cmp::Ordering, fmt, str::FromStr};
use crate::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ReleaseTrack {
Strom,
Gas,
Classic,
Short,
Other,
}
impl fmt::Display for ReleaseTrack {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ReleaseTrack::Strom => f.write_str("Strom"),
ReleaseTrack::Gas => f.write_str("Gas"),
ReleaseTrack::Classic => f.write_str("Classic"),
ReleaseTrack::Short => f.write_str("Short"),
ReleaseTrack::Other => f.write_str("Other"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ReleaseKind {
Dotted {
major: u32,
minor: u32,
patch: u32,
letter: char,
},
Short {
major: u32,
minor: u32,
letter: char,
},
Strom {
major: u32,
minor: u32,
letter: Option<char>,
},
Gas {
major: u32,
minor: u32,
letter: Option<char>,
},
Opaque(Box<str>),
}
impl ReleaseKind {
#[must_use]
pub fn track(&self) -> ReleaseTrack {
match self {
ReleaseKind::Strom { .. } => ReleaseTrack::Strom,
ReleaseKind::Gas { .. } => ReleaseTrack::Gas,
ReleaseKind::Dotted { .. } => ReleaseTrack::Classic,
ReleaseKind::Short { .. } => ReleaseTrack::Short,
ReleaseKind::Opaque(_) => ReleaseTrack::Other,
}
}
#[must_use]
pub fn same_track(&self, other: &ReleaseKind) -> bool {
matches!(
(self, other),
(ReleaseKind::Dotted { .. }, ReleaseKind::Dotted { .. })
| (ReleaseKind::Short { .. }, ReleaseKind::Short { .. })
| (ReleaseKind::Strom { .. }, ReleaseKind::Strom { .. })
| (ReleaseKind::Gas { .. }, ReleaseKind::Gas { .. })
| (ReleaseKind::Opaque(_), ReleaseKind::Opaque(_))
)
}
#[must_use]
pub fn parse(s: &str) -> Self {
if let Some(rest) = s.strip_prefix('S')
&& let Some((major, minor, letter)) = split_two_numeric_with_suffix(rest)
{
return ReleaseKind::Strom {
major,
minor,
letter,
};
}
if let Some(rest) = s.strip_prefix('G')
&& let Some((major, minor, letter)) = split_two_numeric_with_suffix(rest)
{
return ReleaseKind::Gas {
major,
minor,
letter,
};
}
if let Some(k) = parse_dotted(s) {
return k;
}
if let Some(k) = parse_short(s) {
return k;
}
ReleaseKind::Opaque(s.into())
}
}
fn split_two_numeric_with_suffix(s: &str) -> Option<(u32, u32, Option<char>)> {
let (a, b) = s.split_once('.')?;
let major: u32 = a.parse().ok()?;
let minor_str = b.trim_end_matches(|c: char| c.is_ascii_alphabetic());
if minor_str.is_empty() {
return None;
}
let minor: u32 = minor_str.parse().ok()?;
let letter = b[minor_str.len()..].chars().next();
if b[minor_str.len()..].chars().count() > 1 {
return None;
}
Some((major, minor, letter))
}
fn parse_dotted(s: &str) -> Option<ReleaseKind> {
let first = s.find('.')?;
let rest = &s[first + 1..];
let second = rest.find('.')?;
let major: u32 = s[..first].parse().ok()?;
let minor: u32 = rest[..second].parse().ok()?;
let tail = &rest[second + 1..]; let letter = tail.chars().last()?;
if !letter.is_ascii_lowercase() {
return None;
}
let patch: u32 = tail[..tail.len() - 1].parse().ok()?;
Some(ReleaseKind::Dotted {
major,
minor,
patch,
letter,
})
}
fn parse_short(s: &str) -> Option<ReleaseKind> {
let dot = s.find('.')?;
if s.rfind('.') != Some(dot) {
return None; }
let major: u32 = s[..dot].parse().ok()?;
let tail = &s[dot + 1..]; let letter = tail.chars().last()?;
if !letter.is_ascii_lowercase() {
return None;
}
let minor: u32 = tail[..tail.len() - 1].parse().ok()?;
Some(ReleaseKind::Short {
major,
minor,
letter,
})
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Release(std::sync::Arc<str>);
impl Release {
#[must_use]
pub fn new(code: &str) -> Self {
Self(code.into())
}
pub fn try_new(code: &str) -> Result<Self, Error> {
if code.is_empty() {
return Err(Error::InvalidRelease("release code must not be empty"));
}
if code.len() > 35 {
return Err(Error::InvalidRelease(
"release code must not exceed 35 characters",
));
}
if !code.is_ascii() {
return Err(Error::InvalidRelease(
"release code must contain only ASCII characters",
));
}
Ok(Self(code.into()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn is_registered(
&self,
message_type: crate::MessageType,
registry: &crate::registry::ReleaseRegistry,
) -> bool {
registry
.profiles_for(message_type)
.any(|p| p.release() == self)
}
#[must_use]
pub fn kind(&self) -> ReleaseKind {
let kind = ReleaseKind::parse(&self.0);
#[cfg(feature = "tracing")]
if matches!(kind, ReleaseKind::Opaque(_)) {
tracing::warn!(
release = %self.0,
"unrecognised BDEW release code pattern — defaulting to Opaque track; \
update ReleaseKind::parse() if this is a new BDEW convention"
);
}
kind
}
#[must_use]
pub fn track(&self) -> ReleaseTrack {
ReleaseKind::parse(&self.0).track()
}
#[must_use]
pub fn same_track_cmp(&self, other: &Release) -> Option<Ordering> {
let a = self.kind();
let b = other.kind();
a.same_track(&b).then(|| compare_kinds(&a, &b))
}
}
impl PartialOrd for Release {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Release {
fn cmp(&self, other: &Self) -> Ordering {
let a = self.kind();
let b = other.kind();
compare_kinds(&a, &b).then_with(|| self.0.cmp(&other.0))
}
}
fn compare_kinds(a: &ReleaseKind, b: &ReleaseKind) -> Ordering {
match (a, b) {
(
ReleaseKind::Dotted {
major: ma,
minor: mi,
patch: pa,
letter: la,
},
ReleaseKind::Dotted {
major: mb,
minor: mib,
patch: pb,
letter: lb,
},
) => ma
.cmp(mb)
.then(mi.cmp(mib))
.then(pa.cmp(pb))
.then(la.cmp(lb)),
(
ReleaseKind::Short {
major: ma,
minor: mi,
letter: la,
},
ReleaseKind::Short {
major: mb,
minor: mib,
letter: lb,
},
) => ma.cmp(mb).then(mi.cmp(mib)).then(la.cmp(lb)),
(
ReleaseKind::Strom {
major: ma,
minor: mi,
letter: la,
},
ReleaseKind::Strom {
major: mb,
minor: mib,
letter: lb,
},
)
| (
ReleaseKind::Gas {
major: ma,
minor: mi,
letter: la,
},
ReleaseKind::Gas {
major: mb,
minor: mib,
letter: lb,
},
) => ma.cmp(mb).then(mi.cmp(mib)).then(la.cmp(lb)),
(ReleaseKind::Opaque(a), ReleaseKind::Opaque(b)) => a.cmp(b),
_ => track_rank(a).cmp(&track_rank(b)),
}
}
fn track_rank(kind: &ReleaseKind) -> u8 {
match kind {
ReleaseKind::Dotted { .. } => 0,
ReleaseKind::Short { .. } => 1,
ReleaseKind::Strom { .. } => 2,
ReleaseKind::Gas { .. } => 3,
ReleaseKind::Opaque(_) => 4,
}
}
impl fmt::Display for Release {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for Release {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl FromStr for Release {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::new(s))
}
}
#[cfg(test)]
mod release_kind_tests {
use super::*;
#[test]
fn parse_dotted_form() {
let k = ReleaseKind::parse("5.5.3a");
assert_eq!(
k,
ReleaseKind::Dotted {
major: 5,
minor: 5,
patch: 3,
letter: 'a'
}
);
}
#[test]
fn parse_short_form() {
let k = ReleaseKind::parse("2.5a");
assert_eq!(
k,
ReleaseKind::Short {
major: 2,
minor: 5,
letter: 'a'
}
);
}
#[test]
fn parse_strom_track() {
let k = ReleaseKind::parse("S2.1");
assert_eq!(
k,
ReleaseKind::Strom {
major: 2,
minor: 1,
letter: None
}
);
}
#[test]
fn parse_gas_track() {
let k = ReleaseKind::parse("G1.1");
assert_eq!(
k,
ReleaseKind::Gas {
major: 1,
minor: 1,
letter: None
}
);
}
#[test]
fn parse_opaque_fallback() {
match ReleaseKind::parse("UNKNOWN") {
ReleaseKind::Opaque(_) => {}
other => panic!("expected Opaque, got {other:?}"),
}
}
#[test]
fn semantic_ordering_dotted() {
let a = Release::new("5.5.3a");
let b = Release::new("5.5.10a"); assert!(a < b, "5.5.3a should be less than 5.5.10a");
}
#[test]
fn semantic_ordering_short() {
let a = Release::new("2.9a");
let b = Release::new("2.10a"); assert!(a < b, "2.9a should be less than 2.10a (numeric, not lex)");
}
#[test]
fn semantic_ordering_strom() {
let a = Release::new("S2.9");
let b = Release::new("S2.10");
assert!(a < b, "S2.9 should be less than S2.10 (numeric)");
}
#[test]
fn cross_track_comparison_is_reported_as_meaningless() {
let a = Release::new("S2.1");
let b = Release::new("5.5.3a");
assert_eq!(
a.same_track_cmp(&b),
None,
"no meaningful cross-track order"
);
assert_eq!(a.cmp(&b), b.cmp(&a).reverse());
}
#[test]
fn ord_and_partial_ord_agree_on_every_pair() {
let codes = [
"S2.1", "S2.2", "S1.1a", "S1.1", "G1.0a", "G1.1", "5.5.3a", "5.5.10a", "2.4c", "2.9a",
"2.10a", "UNKNOWN", "",
"S02.1", "S2.01",
];
for x in codes {
for y in codes {
let (a, b) = (Release::new(x), Release::new(y));
assert_eq!(
a.partial_cmp(&b),
Some(a.cmp(&b)),
"partial_cmp disagrees with cmp for {x:?} vs {y:?}"
);
assert_eq!(a.cmp(&b), b.cmp(&a).reverse(), "{x:?} vs {y:?}");
assert_eq!(
a.cmp(&b) == Ordering::Equal,
a == b,
"Ord and Eq disagree for {x:?} vs {y:?}"
);
}
}
}
#[test]
fn the_corrigendum_letter_is_not_discarded() {
let plain = Release::new("S1.1");
let corrected = Release::new("S1.1a");
assert_ne!(plain, corrected);
assert_eq!(plain.same_track_cmp(&corrected), Some(Ordering::Less));
assert_eq!(plain.track(), ReleaseTrack::Strom);
assert_eq!(corrected.track(), ReleaseTrack::Strom);
}
#[test]
fn same_track_strom() {
assert!(
ReleaseKind::Strom {
major: 2,
minor: 1,
letter: None
}
.same_track(&ReleaseKind::Strom {
major: 2,
minor: 2,
letter: None
})
);
}
}