#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TagPin {
Exact,
Prefix(Vec<u64>),
}
pub fn strip_v_prefix(s: &str) -> &str {
s.strip_prefix('v')
.or_else(|| s.strip_prefix('V'))
.unwrap_or(s)
}
pub fn classify_tag_pin(tag: &str) -> Option<TagPin> {
let stripped = strip_v_prefix(tag);
if semver::Version::parse(stripped).is_ok() {
return Some(TagPin::Exact);
}
let parts: Option<Vec<u64>> = stripped.split('.').map(|s| s.parse::<u64>().ok()).collect();
let parts = parts?;
if parts.is_empty() || parts.len() >= 3 {
return None;
}
Some(TagPin::Prefix(parts))
}
pub fn pick_latest_for_pin(tags: &[String], pin: &[u64]) -> Option<String> {
let mut best: Option<(semver::Version, &String)> = None;
for tag in tags {
let stripped = strip_v_prefix(tag);
let Ok(ver) = semver::Version::parse(stripped) else {
continue;
};
if !ver.pre.is_empty() {
continue;
}
let comps = [ver.major, ver.minor, ver.patch];
if pin.len() > comps.len() {
continue;
}
if pin.iter().zip(comps.iter()).any(|(p, c)| p != c) {
continue;
}
match &best {
None => best = Some((ver, tag)),
Some((b, _)) if &ver > b => best = Some((ver, tag)),
_ => {}
}
}
best.map(|(_, t)| t.clone())
}
pub fn pick_latest_overall(tags: &[String]) -> Option<String> {
let mut best: Option<(semver::Version, &String)> = None;
for tag in tags {
let stripped = strip_v_prefix(tag);
let Ok(ver) = semver::Version::parse(stripped) else {
continue;
};
if !ver.pre.is_empty() {
continue;
}
match &best {
None => best = Some((ver, tag)),
Some((b, _)) if &ver > b => best = Some((ver, tag)),
_ => {}
}
}
best.map(|(_, t)| t.clone())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_full_semver_is_exact() {
assert_eq!(classify_tag_pin("v1.2.3"), Some(TagPin::Exact));
assert_eq!(classify_tag_pin("1.2.3"), Some(TagPin::Exact));
assert_eq!(classify_tag_pin("v0.1.0"), Some(TagPin::Exact));
assert_eq!(classify_tag_pin("1.0.0-rc1"), Some(TagPin::Exact));
}
#[test]
fn classify_partial_is_prefix() {
assert_eq!(classify_tag_pin("v1.0"), Some(TagPin::Prefix(vec![1, 0])));
assert_eq!(classify_tag_pin("v1"), Some(TagPin::Prefix(vec![1])));
assert_eq!(classify_tag_pin("2.5"), Some(TagPin::Prefix(vec![2, 5])));
}
#[test]
fn classify_non_semver_is_none() {
assert_eq!(classify_tag_pin("latest"), None);
assert_eq!(classify_tag_pin("release-candidate"), None);
assert_eq!(classify_tag_pin(""), None);
}
#[test]
fn pick_for_pin_takes_prefix_max() {
let tags = vec![
"v1.0.0".to_string(),
"v1.0.1".to_string(),
"v1.0.5".to_string(),
"v1.1.0".to_string(),
"v2.0.0".to_string(),
];
assert_eq!(
pick_latest_for_pin(&tags, &[1, 0]),
Some("v1.0.5".to_string())
);
assert_eq!(pick_latest_for_pin(&tags, &[1]), Some("v1.1.0".to_string()));
assert_eq!(pick_latest_for_pin(&tags, &[3]), None);
}
#[test]
fn pick_for_pin_excludes_prerelease() {
let tags = vec![
"v1.0.0".to_string(),
"v1.0.1-rc1".to_string(),
"v1.0.1".to_string(),
];
assert_eq!(
pick_latest_for_pin(&tags, &[1, 0]),
Some("v1.0.1".to_string())
);
}
#[test]
fn pick_for_pin_ignores_unrelated_tags() {
let tags = vec![
"v1.0.0".to_string(),
"release-2024".to_string(),
"v1.0.1".to_string(),
];
assert_eq!(
pick_latest_for_pin(&tags, &[1, 0]),
Some("v1.0.1".to_string())
);
}
#[test]
fn pick_overall_takes_global_max() {
let tags = vec![
"v0.9.9".to_string(),
"v1.0.5".to_string(),
"v2.0.0".to_string(),
"v1.9.9".to_string(),
];
assert_eq!(pick_latest_overall(&tags), Some("v2.0.0".to_string()));
}
}