use crate::core::python::PythonUpdateType;
use std::cmp::Ordering;
#[derive(Debug, Clone)]
pub struct Version {
epoch: u64,
release: Vec<u64>,
pre: Option<(PreLabel, u64)>,
post: Option<u64>,
dev: Option<u64>,
local: Option<Vec<LocalSegment>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum PreLabel {
A,
B,
Rc,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum LocalSegment {
Alphanumeric(String),
Numeric(u64),
}
type SortKey = (
u64,
Vec<u64>,
Sentinel<(PreLabel, u64)>,
Sentinel<u64>,
Sentinel<u64>,
Sentinel<Vec<LocalSegment>>,
);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum Sentinel<T> {
Below,
Exact(T),
Above,
}
const PRE_LABELS: [(&str, PreLabel); 8] = [
("preview", PreLabel::Rc),
("alpha", PreLabel::A),
("beta", PreLabel::B),
("pre", PreLabel::Rc),
("rc", PreLabel::Rc),
("a", PreLabel::A),
("b", PreLabel::B),
("c", PreLabel::Rc),
];
const POST_LABELS: [&str; 3] = ["post", "rev", "r"];
impl Version {
pub fn parse(text: &str) -> Option<Self> {
let lowered = text.trim().to_ascii_lowercase();
let mut rest = lowered.as_str();
rest = rest.strip_prefix('v').unwrap_or(rest);
let epoch = match rest.split_once('!') {
Some((epoch, remainder)) => {
rest = remainder;
parse_number(epoch)?
}
None => 0,
};
let (release, remainder) = parse_release(rest)?;
rest = remainder;
let (pre, remainder) = parse_pre(rest);
rest = remainder;
let (post, remainder) = parse_post(rest);
rest = remainder;
let (dev, remainder) = parse_dev(rest);
rest = remainder;
let local = match rest.strip_prefix('+') {
Some(label) => {
rest = "";
Some(parse_local(label)?)
}
None => None,
};
if !rest.is_empty() {
return None;
}
Some(Version {
epoch,
release,
pre,
post,
dev,
local,
})
}
fn sort_key(&self) -> SortKey {
let mut release = self.release.clone();
while release.len() > 1 && release.last() == Some(&0) {
release.pop();
}
let pre = match (self.pre, self.post, self.dev) {
(Some(pre), _, _) => Sentinel::Exact(pre),
(None, None, Some(_)) => Sentinel::Below,
(None, _, _) => Sentinel::Above,
};
(
self.epoch,
release,
pre,
self.post.map_or(Sentinel::Below, Sentinel::Exact),
self.dev.map_or(Sentinel::Above, Sentinel::Exact),
self.local.clone().map_or(Sentinel::Below, Sentinel::Exact),
)
}
}
impl PartialEq for Version {
fn eq(&self, other: &Self) -> bool {
self.sort_key() == other.sort_key()
}
}
impl Eq for Version {}
impl Ord for Version {
fn cmp(&self, other: &Self) -> Ordering {
self.sort_key().cmp(&other.sort_key())
}
}
impl PartialOrd for Version {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
pub fn is_same_version(left: &str, right: &str) -> bool {
match (Version::parse(left), Version::parse(right)) {
(Some(left), Some(right)) => left == right,
_ => false,
}
}
pub fn classify(current: &str, latest: &str) -> PythonUpdateType {
let (Some(current), Some(latest)) = (Version::parse(current), Version::parse(latest)) else {
return PythonUpdateType::Unclassified;
};
if latest < current {
return PythonUpdateType::Unclassified;
}
if current.epoch != latest.epoch {
return PythonUpdateType::Epoch;
}
let width = current.release.len().max(latest.release.len());
for index in 0..width {
if release_component(¤t, index) != release_component(&latest, index) {
return match index {
0 => PythonUpdateType::Major,
1 => PythonUpdateType::Minor,
_ => PythonUpdateType::Patch,
};
}
}
if current == latest {
PythonUpdateType::Unclassified
} else {
PythonUpdateType::Qualifier
}
}
fn release_component(version: &Version, index: usize) -> u64 {
version.release.get(index).copied().unwrap_or(0)
}
fn parse_release(text: &str) -> Option<(Vec<u64>, &str)> {
let mut release = Vec::new();
let mut rest = text;
loop {
let end = rest
.find(|character: char| !character.is_ascii_digit())
.unwrap_or(rest.len());
release.push(parse_number(&rest[..end])?);
rest = &rest[end..];
let follows_a_digit = rest
.strip_prefix('.')
.is_some_and(|next| next.starts_with(|character: char| character.is_ascii_digit()));
if !follows_a_digit {
return Some((release, rest));
}
rest = &rest[1..];
}
}
fn parse_pre(text: &str) -> (Option<(PreLabel, u64)>, &str) {
let after_separator = strip_separator(text);
for (spelling, label) in PRE_LABELS {
if let Some(rest) = after_separator.strip_prefix(spelling) {
let (number, rest) = parse_optional_number(rest);
return (Some((label, number)), rest);
}
}
(None, text)
}
fn parse_post(text: &str) -> (Option<u64>, &str) {
if let Some(rest) = text.strip_prefix('-') {
let digits = rest
.find(|character: char| !character.is_ascii_digit())
.unwrap_or(rest.len());
if digits > 0 {
if let Some(number) = parse_number(&rest[..digits]) {
return (Some(number), &rest[digits..]);
}
}
}
let after_separator = strip_separator(text);
for spelling in POST_LABELS {
if let Some(rest) = after_separator.strip_prefix(spelling) {
let (number, rest) = parse_optional_number(rest);
return (Some(number), rest);
}
}
(None, text)
}
fn parse_dev(text: &str) -> (Option<u64>, &str) {
let after_separator = strip_separator(text);
match after_separator.strip_prefix("dev") {
Some(rest) => {
let (number, rest) = parse_optional_number(rest);
(Some(number), rest)
}
None => (None, text),
}
}
fn parse_local(text: &str) -> Option<Vec<LocalSegment>> {
if text.is_empty() {
return None;
}
let mut segments = Vec::new();
for segment in text.split(['-', '_', '.']) {
if segment.is_empty() || !segment.chars().all(|c| c.is_ascii_alphanumeric()) {
return None;
}
segments.push(match segment.parse::<u64>() {
Ok(number) => LocalSegment::Numeric(number),
Err(_) => LocalSegment::Alphanumeric(segment.to_string()),
});
}
Some(segments)
}
fn strip_separator(text: &str) -> &str {
text.strip_prefix(['-', '_', '.']).unwrap_or(text)
}
fn parse_optional_number(text: &str) -> (u64, &str) {
let after_separator = strip_separator(text);
let end = after_separator
.find(|character: char| !character.is_ascii_digit())
.unwrap_or(after_separator.len());
match parse_number(&after_separator[..end]) {
Some(number) => (number, &after_separator[end..]),
None => (0, after_separator),
}
}
fn parse_number(text: &str) -> Option<u64> {
if text.is_empty() || !text.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
text.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn normalized(version: &Version) -> String {
let mut rendered = String::new();
if version.epoch != 0 {
rendered.push_str(&format!("{}!", version.epoch));
}
rendered.push_str(
&version
.release
.iter()
.map(|component| component.to_string())
.collect::<Vec<_>>()
.join("."),
);
if let Some((label, number)) = version.pre {
let label = match label {
PreLabel::A => "a",
PreLabel::B => "b",
PreLabel::Rc => "rc",
};
rendered.push_str(&format!("{label}{number}"));
}
if let Some(post) = version.post {
rendered.push_str(&format!(".post{post}"));
}
if let Some(dev) = version.dev {
rendered.push_str(&format!(".dev{dev}"));
}
if let Some(local) = &version.local {
let rendered_local = local
.iter()
.map(|segment| match segment {
LocalSegment::Numeric(number) => number.to_string(),
LocalSegment::Alphanumeric(text) => text.clone(),
})
.collect::<Vec<_>>()
.join(".");
rendered.push_str(&format!("+{rendered_local}"));
}
rendered
}
#[test]
fn accepted_spellings_normalize() {
for (input, expected) in [
("v1.0", "1.0"),
("1.0-1", "1.0.post1"),
("1.0beta2", "1.0b2"),
("1.0.alpha1", "1.0a1"),
("1.0-rc1", "1.0rc1"),
("1.0rev1", "1.0.post1"),
("1.0-dev", "1.0.dev0"),
("1.0preview1", "1.0rc1"),
("01.0", "1.0"),
("1.0a.", "1.0a0"),
("1.0a-", "1.0a0"),
("1.0a_", "1.0a0"),
("1.0.post-", "1.0.post0"),
("1.0.dev.", "1.0.dev0"),
("1.0rev.", "1.0.post0"),
("1.0-rc-", "1.0rc0"),
("1.0a-.dev1", "1.0a0.dev1"),
("1.0a..dev1", "1.0a0.dev1"),
("1.0a.+local", "1.0a0+local"),
("1.0c1", "1.0rc1"),
("1.0r1", "1.0.post1"),
("1.0.post1", "1.0.post1"),
("1.0_post_1", "1.0.post1"),
("1.0-post-1", "1.0.post1"),
("1.0.post", "1.0.post0"),
("1.0a", "1.0a0"),
("1.0alpha", "1.0a0"),
("1.0.dev", "1.0.dev0"),
("1.0_dev_2", "1.0.dev2"),
("1!2.0", "1!2.0"),
(" 1.0 ", "1.0"),
("V1.0", "1.0"),
("1.0A1", "1.0a1"),
("1.0.0.0.1", "1.0.0.0.1"),
("0001.0002", "1.2"),
("1.0+ubuntu.1", "1.0+ubuntu.1"),
("1.0+UBUNTU_1", "1.0+ubuntu.1"),
("1.0a1.post2.dev3+local.7", "1.0a1.post2.dev3+local.7"),
("2026.4", "2026.4"),
] {
let parsed = Version::parse(input)
.unwrap_or_else(|| panic!("{input} is a valid PEP 440 version but did not parse"));
assert_eq!(
normalized(&parsed),
expected,
"{input} normalized to the wrong version"
);
}
}
#[test]
fn rejected_spellings_do_not_parse() {
for input in [
"",
"not-a-version",
"1.0.",
".1.0",
"1..0",
"1.0-",
"1.0betaX",
"1.0.dev1extra",
"1.0+",
"1.0+local..1",
"1.0+local-",
"1.0++1",
"a1.0",
"1.0!2.0",
"!1.0",
"1.0 rc1",
"1.0+bad$char",
] {
assert!(
Version::parse(input).is_none(),
"{input} is not a PEP 440 version but parsed anyway"
);
}
}
#[test]
fn ordering_follows_pep_440() {
for (lower, higher) in [
("1.0.dev1", "1.0a1"),
("1.0a1", "1.0a2"),
("1.0a2", "1.0b1"),
("1.0b1", "1.0rc1"),
("1.0rc1", "1.0"),
("1.0", "1.0.post1"),
("1.0.post1", "1.0.post2"),
("1.0.post1", "1.1"),
("1.0", "1.0+local"),
("1.0+1", "1.0+2"),
("1.0+abc", "1.0+1"),
("1.0", "1!0.1"),
("1.9", "1.10"),
("1.0a1.dev1", "1.0a1"),
("1.0.post1.dev1", "1.0.post1"),
] {
let lower_version = Version::parse(lower).expect(lower);
let higher_version = Version::parse(higher).expect(higher);
assert!(
lower_version < higher_version,
"{lower} should sort below {higher}"
);
}
for (left, right) in [("1.0", "1.0.0"), ("1.0", "1.0.0.0"), ("1.0.0", "v1.0")] {
let left_version = Version::parse(left).expect(left);
let right_version = Version::parse(right).expect(right);
assert!(
left_version == right_version,
"{left} and {right} are the same PEP 440 version"
);
assert_eq!(left_version.cmp(&right_version), Ordering::Equal);
}
}
#[test]
fn documented_classification_examples() {
for (current, latest, expected) in [
("1.4.2", "2.0.0", PythonUpdateType::Major),
("1.4.2", "1.5.0", PythonUpdateType::Minor),
("1.4.2", "1.4.3", PythonUpdateType::Patch),
("1.4", "1.4.1", PythonUpdateType::Patch),
("0.9.0", "0.9.0.post1", PythonUpdateType::Qualifier),
("2.0.0rc1", "2.0.0", PythonUpdateType::Qualifier),
("1.0", "1!1.0", PythonUpdateType::Epoch),
("2026.4", "2026.9", PythonUpdateType::Minor),
("1.0", "not-a-version", PythonUpdateType::Unclassified),
("v1.0", "1.0-1", PythonUpdateType::Qualifier),
("1.0", "1.0.0", PythonUpdateType::Unclassified),
("not-a-version", "1.0", PythonUpdateType::Unclassified),
("2.0.0", "1.9.9", PythonUpdateType::Unclassified),
("1.0.0rc1", "0.9.0", PythonUpdateType::Unclassified),
("1!1.0", "1.0", PythonUpdateType::Unclassified),
("1.4.2.1", "1.4.2.2", PythonUpdateType::Patch),
("1.4.0", "1.4", PythonUpdateType::Unclassified),
("1", "2", PythonUpdateType::Major),
("1", "1.1", PythonUpdateType::Minor),
("1.0", "1.0+build.1", PythonUpdateType::Qualifier),
("1.0.dev1", "1.0", PythonUpdateType::Qualifier),
("1.4.2", "1!0.1", PythonUpdateType::Epoch),
] {
assert_eq!(
classify(current, latest),
expected,
"{current} -> {latest} classified wrongly"
);
}
}
#[test]
fn calendar_versions_are_classified_on_position_alone() {
assert_eq!(classify("2025.12", "2026.1"), PythonUpdateType::Major);
assert_eq!(classify("2026.4", "2026.9"), PythonUpdateType::Minor);
}
}