use crate::stability::Stability;
use regex::Regex;
use std::cmp::Ordering;
use std::sync::OnceLock;
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct Version {
pub kind: VersionKind,
pub normalized: String,
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum VersionKind {
Numeric {
segments_raw: Vec<String>,
suffix: Suffix,
},
Branch(String),
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum Suffix {
Stable,
Patch(u16),
Prerelease { stability: Stability, num: String },
Dev,
PrereleaseDev { stability: Stability, num: String },
PatchDev(u16),
}
impl Suffix {
pub fn stability(&self) -> Stability {
match self {
Self::Stable | Self::Patch(_) => Stability::Stable,
Self::Prerelease { stability, .. } => *stability,
Self::Dev | Self::PrereleaseDev { .. } | Self::PatchDev(_) => Stability::Dev,
}
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum ParseError {
Invalid(String),
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Invalid(s) => write!(f, "invalid version: {s:?}"),
}
}
}
impl std::error::Error for ParseError {}
fn re_modifier_str() -> &'static str {
r"(?:[._-]?(?:(stable|RC|beta|b|alpha|a|patch|pl|p)((?:[.-]?\d+)*)?)?)?([.-]?dev)?"
}
fn re_classical() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| {
let pat = format!(
r"^(?i)v?(\d{{1,5}})(\.\d+)?(\.\d+)?(\.\d+)?{}$",
re_modifier_str()
);
Regex::new(&pat).unwrap()
})
}
fn re_date() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| {
let pat = format!(
r"^(?i)v?(\d{{4}}(?:[.:\-_]?\d{{2}}){{1,6}}(?:[.:\-_]?\d{{1,3}}){{0,2}}){}$",
re_modifier_str()
);
Regex::new(&pat).unwrap()
})
}
fn re_branch_alias() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| Regex::new(r"^v?(\d+)(\.(?:\d+|[xX*]))*-dev$").unwrap())
}
fn re_normalize_branch() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| Regex::new(r"^(\d+)(\.(?:\d+|[xX*]))*$").unwrap())
}
fn re_parse_stability() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| {
let pat = format!(r"(?i){}(?:\+.*)?$", re_modifier_str());
Regex::new(&pat).unwrap()
})
}
pub fn is_branch_alias(s: &str) -> bool {
re_branch_alias().is_match(s.trim())
}
fn strip_at_stability(s: &str) -> String {
static R: OnceLock<Regex> = OnceLock::new();
let r = R.get_or_init(|| Regex::new(r"@(?i)(stable|RC|beta|alpha|dev)$").unwrap());
r.replace(s, "").into_owned()
}
fn re_dev_prefix() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| Regex::new(r"^(?i)dev-(.+)$").unwrap())
}
impl Version {
pub fn parse(s: &str) -> Result<Self, ParseError> {
let raw = s.trim();
if raw.is_empty() {
return Err(ParseError::Invalid(s.to_owned()));
}
let cleaned = match raw.split_once('#') {
Some((before, _)) => before,
None => raw,
};
let cleaned = cleaned.trim();
if let Some((left, _right)) = split_aliased(cleaned) {
return Self::parse(left);
}
let cleaned = strip_at_stability(cleaned);
let cleaned = match cleaned.as_str() {
"master" | "trunk" | "default" => format!("dev-{cleaned}"),
_ => cleaned,
};
if let Some(v) = parse_branch_alias(&cleaned) {
return Ok(v);
}
let numeric_candidate = if cleaned.to_ascii_lowercase().starts_with("dev-") {
cleaned.clone()
} else if let Some(idx) = cleaned.find('+') {
let tail = &cleaned[idx + 1..];
if tail.is_empty() || tail.chars().any(char::is_whitespace) {
cleaned.clone()
} else {
cleaned[..idx].to_owned()
}
} else {
cleaned.clone()
};
if let Some(caps) = re_classical().captures(&numeric_candidate) {
let segs = collect_classical_segments(&caps);
let suffix = parse_modifier(
caps.get(5).map_or("", |m| m.as_str()),
caps.get(6).map_or("", |m| m.as_str()),
caps.get(7).map_or("", |m| m.as_str()),
)?;
let normalized = render_numeric(&segs, &suffix);
return Ok(Version {
kind: VersionKind::Numeric {
segments_raw: segs,
suffix,
},
normalized,
});
}
if let Some(caps) = re_date().captures(&numeric_candidate) {
let body = caps.get(1).unwrap().as_str();
let segs = split_date_segments(body);
let suffix = parse_modifier(
caps.get(2).map_or("", |m| m.as_str()),
caps.get(3).map_or("", |m| m.as_str()),
caps.get(4).map_or("", |m| m.as_str()),
)?;
let normalized = render_numeric(&segs, &suffix);
return Ok(Version {
kind: VersionKind::Numeric {
segments_raw: segs,
suffix,
},
normalized,
});
}
if let Some(caps) = re_dev_prefix().captures(&cleaned) {
let body = caps.get(1).unwrap().as_str();
return Ok(Version {
kind: VersionKind::Branch(body.to_owned()),
normalized: format!("dev-{body}"),
});
}
Err(ParseError::Invalid(s.to_owned()))
}
pub fn parse_numeric_alias_prefix(branch: &str) -> Option<String> {
let stripped = strip_v_prefix(branch);
let dev_body = stripped.strip_suffix("-dev")?;
let parts: Vec<&str> = dev_body.split('.').collect();
let mut prefix_parts: Vec<&str> = Vec::with_capacity(parts.len());
let mut saw_wildcard = false;
for p in &parts {
if matches!(*p, "x" | "X" | "*") {
saw_wildcard = true;
break;
}
if p.chars().all(|c| c.is_ascii_digit()) && !p.is_empty() {
prefix_parts.push(p);
} else {
return None;
}
}
let mut out = prefix_parts.join(".");
if !out.is_empty() {
out.push('.');
} else if !saw_wildcard {
return None;
}
Some(out)
}
pub fn normalize_branch(branch: &str) -> String {
let stripped = strip_v_prefix(branch);
if re_normalize_branch().is_match(stripped) {
let mut segs_raw: Vec<String> = Vec::new();
for part in stripped.split('.') {
if matches!(part, "x" | "X" | "*") {
segs_raw.push("9999999".to_owned());
} else {
segs_raw.push(part.to_owned());
}
}
while segs_raw.len() < 4 {
segs_raw.push("9999999".to_owned());
}
return render_numeric(&segs_raw, &Suffix::Dev);
}
format!("dev-{stripped}")
}
pub fn parse_stability(version: &str) -> Stability {
let raw = version.trim();
let cleaned = match raw.split_once('#') {
Some((before, _)) => before,
None => raw,
};
let cleaned = cleaned.trim();
if cleaned.to_ascii_lowercase().starts_with("dev-")
|| cleaned.to_ascii_lowercase().ends_with("-dev")
{
return Stability::Dev;
}
let lower = cleaned.to_ascii_lowercase();
if let Some(caps) = re_parse_stability().captures(&lower) {
if caps.get(3).is_some_and(|m| !m.as_str().is_empty()) {
return Stability::Dev;
}
if let Some(kw) = caps.get(1) {
let s = kw.as_str();
if !s.is_empty() {
return match s {
"beta" | "b" => Stability::Beta,
"alpha" | "a" => Stability::Alpha,
"rc" => Stability::Rc,
_ => Stability::Stable,
};
}
}
}
Stability::Stable
}
}
impl std::fmt::Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.normalized)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CmpOp {
Gt,
Ge,
Lt,
Le,
Eq,
Ne,
}
impl Version {
pub fn stability(&self) -> Stability {
match &self.kind {
VersionKind::Numeric { suffix, .. } => suffix.stability(),
VersionKind::Branch(_) => Stability::Dev,
}
}
pub fn compare(&self, op: CmpOp, other: &Version) -> bool {
if let (VersionKind::Branch(a), VersionKind::Branch(b)) = (&self.kind, &other.kind) {
return match (op, a == b) {
(CmpOp::Eq, same) => same,
(CmpOp::Ne, same) => !same,
(CmpOp::Ge | CmpOp::Le, true) => true,
_ => false,
};
}
let ord = self.cmp(other);
match op {
CmpOp::Gt => ord.is_gt(),
CmpOp::Ge => ord.is_ge(),
CmpOp::Lt => ord.is_lt(),
CmpOp::Le => ord.is_le(),
CmpOp::Eq => ord.is_eq(),
CmpOp::Ne => !ord.is_eq(),
}
}
pub fn normalize_default_branch(normalized: &str) -> String {
match normalized {
"dev-master" | "dev-default" | "dev-trunk" => "9999999-dev".to_owned(),
other => other.to_owned(),
}
}
pub fn with_suffix(&self, suffix: Suffix) -> Option<Version> {
match &self.kind {
VersionKind::Numeric { segments_raw, .. } => {
let normalized = render_numeric(segments_raw, &suffix);
Some(Version {
kind: VersionKind::Numeric {
segments_raw: segments_raw.clone(),
suffix,
},
normalized,
})
}
VersionKind::Branch(_) => None,
}
}
}
impl PartialOrd for Version {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Version {
fn cmp(&self, other: &Self) -> Ordering {
match (&self.kind, &other.kind) {
(
VersionKind::Numeric {
segments_raw: a,
suffix: sa,
},
VersionKind::Numeric {
segments_raw: b,
suffix: sb,
},
) => cmp_segments(a, b).then_with(|| suffix_cmp(sa, sb)),
(VersionKind::Branch(_), VersionKind::Numeric { .. }) => Ordering::Less,
(VersionKind::Numeric { .. }, VersionKind::Branch(_)) => Ordering::Greater,
(VersionKind::Branch(_), VersionKind::Branch(_)) => Ordering::Equal,
}
}
}
fn cmp_segments(a: &[String], b: &[String]) -> Ordering {
let len = a.len().max(b.len());
for i in 0..len {
let va = segment_value(a.get(i));
let vb = segment_value(b.get(i));
match va.cmp(&vb) {
Ordering::Equal => {}
non_eq => return non_eq,
}
}
Ordering::Equal
}
fn segment_value(s: Option<&String>) -> u64 {
match s {
None => 0,
Some(s) => s.parse::<u64>().unwrap_or_else(|_| {
if !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) {
u64::MAX
} else {
0
}
}),
}
}
fn suffix_cmp(a: &Suffix, b: &Suffix) -> Ordering {
suffix_rank(a).cmp(&suffix_rank(b))
}
fn suffix_rank(s: &Suffix) -> (u8, u16) {
let leading_int = |num: &str| -> u16 {
let mut s = String::new();
for ch in num.chars() {
if ch.is_ascii_digit() {
s.push(ch);
} else {
break;
}
}
s.parse().unwrap_or(0)
};
match s {
Suffix::Dev => (0, 0),
Suffix::PrereleaseDev {
stability: Stability::Alpha,
num,
} => (1, leading_int(num)),
Suffix::Prerelease {
stability: Stability::Alpha,
num,
} => (2, leading_int(num)),
Suffix::PrereleaseDev {
stability: Stability::Beta,
num,
} => (3, leading_int(num)),
Suffix::Prerelease {
stability: Stability::Beta,
num,
} => (4, leading_int(num)),
Suffix::PrereleaseDev {
stability: Stability::Rc,
num,
} => (5, leading_int(num)),
Suffix::Prerelease {
stability: Stability::Rc,
num,
} => (6, leading_int(num)),
Suffix::PatchDev(n) => (0, *n),
Suffix::Stable => (7, 0),
Suffix::Patch(n) => (7, *n),
Suffix::Prerelease {
stability: Stability::Dev | Stability::Stable,
num,
} => (0, leading_int(num)),
Suffix::PrereleaseDev {
stability: Stability::Dev | Stability::Stable,
num,
} => (0, leading_int(num)),
}
}
fn strip_v_prefix(s: &str) -> &str {
s.strip_prefix(['v', 'V']).unwrap_or(s)
}
fn split_aliased(s: &str) -> Option<(&str, &str)> {
static R: OnceLock<Regex> = OnceLock::new();
let r = R.get_or_init(|| Regex::new(r"^([^,\s]+)\s+as\s+([^,\s]+)$").unwrap());
let caps = r.captures(s)?;
Some((caps.get(1).unwrap().as_str(), caps.get(2).unwrap().as_str()))
}
fn collect_classical_segments(caps: ®ex::Captures) -> Vec<String> {
let s1 = caps.get(1).unwrap().as_str().to_owned();
let s2 = caps.get(2).map_or_else(
|| "0".to_owned(),
|m| m.as_str().trim_start_matches('.').to_owned(),
);
let s3 = caps.get(3).map_or_else(
|| "0".to_owned(),
|m| m.as_str().trim_start_matches('.').to_owned(),
);
let s4 = caps.get(4).map_or_else(
|| "0".to_owned(),
|m| m.as_str().trim_start_matches('.').to_owned(),
);
vec![s1, s2, s3, s4]
}
fn split_date_segments(body: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut cur = String::new();
for ch in body.chars() {
if ch.is_ascii_digit() {
cur.push(ch);
} else if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
fn render_numeric(segments: &[String], suffix: &Suffix) -> String {
let mut out = segments.join(".");
match suffix {
Suffix::Stable => {}
Suffix::Patch(n) => {
out.push_str("-patch");
out.push_str(&n.to_string());
}
Suffix::Prerelease { stability, num } => {
out.push('-');
out.push_str(stability.as_str());
out.push_str(num);
}
Suffix::Dev => out.push_str("-dev"),
Suffix::PrereleaseDev { stability, num } => {
out.push('-');
out.push_str(stability.as_str());
out.push_str(num);
out.push_str("-dev");
}
Suffix::PatchDev(n) => {
out.push_str("-patch");
out.push_str(&n.to_string());
out.push_str("-dev");
}
}
out
}
fn parse_modifier(keyword: &str, number_tail: &str, dev_tail: &str) -> Result<Suffix, ParseError> {
let kw_present = !keyword.is_empty();
let dev_present = !dev_tail.is_empty();
if !kw_present && !dev_present {
return Ok(Suffix::Stable);
}
if !kw_present && dev_present {
return Ok(Suffix::Dev);
}
let (stab, is_patch) = match keyword.to_ascii_lowercase().as_str() {
"stable" => {
return if dev_present {
Ok(Suffix::Dev)
} else {
Ok(Suffix::Stable)
};
}
"rc" => (Some(Stability::Rc), false),
"beta" | "b" => (Some(Stability::Beta), false),
"alpha" | "a" => (Some(Stability::Alpha), false),
"patch" | "pl" | "p" => (None, true),
_ => return Err(ParseError::Invalid(keyword.to_owned())),
};
let num = canonical_number_tail(number_tail);
if is_patch {
let n: u16 = num.parse().unwrap_or(0);
return Ok(if dev_present {
Suffix::PatchDev(n)
} else {
Suffix::Patch(n)
});
}
let stab = stab.unwrap();
Ok(if dev_present {
Suffix::PrereleaseDev {
stability: stab,
num,
}
} else {
Suffix::Prerelease {
stability: stab,
num,
}
})
}
fn canonical_number_tail(t: &str) -> String {
let mut out = String::new();
let mut leading_stripped = false;
let mut chars = t.chars().peekable();
while let Some(&ch) = chars.peek() {
if !leading_stripped && matches!(ch, '.' | '-' | '_') {
chars.next();
leading_stripped = true;
continue;
}
if ch.is_ascii_digit() {
out.push(ch);
leading_stripped = true; chars.next();
} else {
if !out.is_empty() && matches!(ch, '.' | '-') {
out.push(ch);
chars.next();
} else {
break;
}
}
}
out
}
fn parse_branch_alias(s: &str) -> Option<Version> {
let m = re_branch_alias().captures(s)?;
let whole = m.get(0).unwrap().as_str();
let body = strip_v_prefix(whole).strip_suffix("-dev").unwrap();
let mut segs_raw: Vec<String> = Vec::new();
let mut had_wildcard = false;
for part in body.split('.') {
if matches!(part, "x" | "X" | "*") {
had_wildcard = true;
segs_raw.push("9999999".to_owned());
} else if part.chars().all(|c| c.is_ascii_digit()) && !part.is_empty() {
segs_raw.push(part.to_owned());
} else {
return None;
}
}
if !had_wildcard {
return None;
}
while segs_raw.len() < 4 {
segs_raw.push("9999999".to_owned());
}
let normalized = render_numeric(&segs_raw, &Suffix::Dev);
Some(Version {
kind: VersionKind::Numeric {
segments_raw: segs_raw,
suffix: Suffix::Dev,
},
normalized,
})
}