use std::cmp::Ordering;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SemVer {
pub major: u64,
pub minor: u64,
pub patch: u64,
pub prerelease: Option<String>,
}
impl SemVer {
pub fn parse(s: &str) -> Option<Self> {
let clean = s.trim().strip_prefix('v').unwrap_or(s.trim());
let (ver_and_pre, _build) = match clean.split_once('+') {
Some((vp, b)) => (vp, Some(b)),
None => (clean, None),
};
let (ver_part, prerelease) = match ver_and_pre.split_once('-') {
Some((v, pre)) => (v, Some(pre.to_string())),
None => (ver_and_pre, None),
};
let parts: Vec<&str> = ver_part.split('.').collect();
if parts.len() != 3 {
return None;
}
let major = parts[0].parse::<u64>().ok()?;
let minor = parts[1].parse::<u64>().ok()?;
let patch = parts[2].parse::<u64>().ok()?;
Some(Self {
major,
minor,
patch,
prerelease,
})
}
pub fn is_newer_than(&self, other: &Self) -> bool {
match (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch)) {
Ordering::Greater => true,
Ordering::Less => false,
Ordering::Equal => match (&self.prerelease, &other.prerelease) {
(None, Some(_)) => true,
(Some(_), None) => false,
(None, None) => false,
(Some(a), Some(b)) => compare_prerelease(a, b) == Ordering::Greater,
},
}
}
}
fn compare_prerelease(a: &str, b: &str) -> Ordering {
let a_parts: Vec<&str> = a.split('.').collect();
let b_parts: Vec<&str> = b.split('.').collect();
for (pa, pb) in a_parts.iter().zip(b_parts.iter()) {
if pa == pb {
continue;
}
match (pa.parse::<u64>(), pb.parse::<u64>()) {
(Ok(num_a), Ok(num_b)) => {
let cmp = num_a.cmp(&num_b);
if cmp != Ordering::Equal {
return cmp;
}
}
(Ok(_), Err(_)) => return Ordering::Less,
(Err(_), Ok(_)) => return Ordering::Greater,
(Err(_), Err(_)) => {
let cmp = pa.cmp(pb);
if cmp != Ordering::Equal {
return cmp;
}
}
}
}
a_parts.len().cmp(&b_parts.len())
}
pub fn parse_registry_version(json_str: &str, channel: &str) -> Option<String> {
let val: serde_json::Value = serde_json::from_str(json_str).ok()?;
if let Some(dist_tags) = val.get("dist-tags").and_then(|v| v.as_object()) {
let tag_key = if channel == "stable" {
"latest"
} else {
channel
};
if let Some(ver) = dist_tags.get(tag_key).and_then(|v| v.as_str()) {
return Some(ver.to_string());
}
if let Some(ver) = dist_tags.get("latest").and_then(|v| v.as_str()) {
return Some(ver.to_string());
}
}
if let Some(ver) = val.get("version").and_then(|v| v.as_str()) {
return Some(ver.to_string());
}
let is_prerelease = val
.get("prerelease")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if channel != "stable" || !is_prerelease {
if let Some(tag) = val.get("tag_name").and_then(|v| v.as_str()) {
let clean = tag.strip_prefix('v').unwrap_or(tag);
return Some(clean.to_string());
}
if let Some(name) = val.get("name").and_then(|v| v.as_str()) {
if let Some(_semver) = SemVer::parse(name) {
let clean = name.strip_prefix('v').unwrap_or(name);
return Some(clean.to_string());
}
}
}
if let Some(arr) = val.as_array() {
for item in arr {
let is_pre = item
.get("prerelease")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if channel != "stable" || !is_pre {
if let Some(tag) = item.get("tag_name").and_then(|v| v.as_str()) {
let clean = tag.strip_prefix('v').unwrap_or(tag);
return Some(clean.to_string());
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn semver_parsing_and_comparison() {
let v025 = SemVer::parse("0.2.5").unwrap();
let v026 = SemVer::parse("v0.2.6").unwrap();
let v026_build = SemVer::parse("0.2.6+build.42").unwrap();
let v026_alpha = SemVer::parse("0.2.6-alpha.1").unwrap();
let v026_alpha_build = SemVer::parse("0.2.6-alpha.1+20260902").unwrap();
let v026_alpha2 = SemVer::parse("0.2.6-alpha.2").unwrap();
let v026_alpha10 = SemVer::parse("0.2.6-alpha.10").unwrap();
assert!(v026.is_newer_than(&v025));
assert!(!v025.is_newer_than(&v026));
assert!(!v025.is_newer_than(&v025));
assert!(v026.is_newer_than(&v026_alpha));
assert!(v026_alpha2.is_newer_than(&v026_alpha));
assert!(v026_alpha10.is_newer_than(&v026_alpha2));
assert!(v026_alpha.is_newer_than(&v025));
assert_eq!(v026_build.major, 0);
assert_eq!(v026_build.minor, 2);
assert_eq!(v026_build.patch, 6);
assert_eq!(v026_build.prerelease, None);
assert_eq!(v026_alpha_build.prerelease, Some("alpha.1".to_string()));
}
#[test]
fn parse_registry_response_dist_tags() {
let json = r#"{
"name": "@shledery/vetto",
"dist-tags": {
"latest": "0.2.6",
"alpha": "0.2.7-alpha.1"
}
}"#;
assert_eq!(
parse_registry_version(json, "stable").as_deref(),
Some("0.2.6")
);
assert_eq!(
parse_registry_version(json, "alpha").as_deref(),
Some("0.2.7-alpha.1")
);
}
#[test]
fn parse_registry_response_direct_version() {
let json = r#"{"version": "0.2.6"}"#;
assert_eq!(
parse_registry_version(json, "stable").as_deref(),
Some("0.2.6")
);
}
#[test]
fn parse_github_releases_response() {
let gh_single =
r#"{"tag_name": "v0.2.11", "name": "v0.2.11 release", "prerelease": false}"#;
assert_eq!(
parse_registry_version(gh_single, "stable").as_deref(),
Some("0.2.11")
);
let gh_array = r#"[
{"tag_name": "v0.2.12-alpha.1", "prerelease": true},
{"tag_name": "v0.2.11", "prerelease": false}
]"#;
assert_eq!(
parse_registry_version(gh_array, "stable").as_deref(),
Some("0.2.11")
);
assert_eq!(
parse_registry_version(gh_array, "alpha").as_deref(),
Some("0.2.12-alpha.1")
);
}
#[test]
fn parse_registry_response_invalid_json() {
assert_eq!(parse_registry_version("not json", "stable"), None);
}
}