use std::{cmp::Ordering, fmt, str::FromStr};
use crate::Error;
#[derive(Debug, Clone, 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,
},
Gas {
major: u32,
minor: u32,
},
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') {
if let Some((maj, min)) = split_two_numeric_allow_suffix(rest) {
return ReleaseKind::Strom {
major: maj,
minor: min,
};
}
}
if let Some(rest) = s.strip_prefix('G') {
if let Some((maj, min)) = split_two_numeric_allow_suffix(rest) {
return ReleaseKind::Gas {
major: maj,
minor: min,
};
}
}
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_allow_suffix(s: &str) -> Option<(u32, u32)> {
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());
let minor: u32 = minor_str.parse().ok()?;
if minor_str.is_empty() {
return None;
}
Some((major, minor))
}
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> {
self.partial_cmp(other)
}
}
#[expect(clippy::non_canonical_partial_ord_impl)]
impl PartialOrd for Release {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
let a = self.kind();
let b = other.kind();
if !a.same_track(&b) {
return None;
}
Some(compare_kinds(&a, &b))
}
}
impl Ord for Release {
fn cmp(&self, other: &Self) -> Ordering {
self.partial_cmp(other)
.unwrap_or_else(|| 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,
},
ReleaseKind::Strom {
major: mb,
minor: mib,
},
)
| (
ReleaseKind::Gas {
major: ma,
minor: mi,
},
ReleaseKind::Gas {
major: mb,
minor: mib,
},
) => ma.cmp(mb).then(mi.cmp(mib)),
(ReleaseKind::Opaque(a), ReleaseKind::Opaque(b)) => a.cmp(b),
_ => unreachable!("compare_kinds called with cross-track release kinds: {a:?} vs {b:?}"),
}
}
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 });
}
#[test]
fn parse_gas_track() {
let k = ReleaseKind::parse("G1.1");
assert_eq!(k, ReleaseKind::Gas { major: 1, minor: 1 });
}
#[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_partial_ord_is_none() {
let a = Release::new("S2.1");
let b = Release::new("5.5.3a");
assert_eq!(
a.partial_cmp(&b),
None,
"cross-track comparison is incomparable"
);
}
#[test]
fn same_track_strom() {
assert!(
ReleaseKind::Strom { major: 2, minor: 1 }
.same_track(&ReleaseKind::Strom { major: 2, minor: 2 })
);
}
}