Skip to main content

mlua_pkg/
version.rs

1//! SemVer pin classification and selection helpers.
2//!
3//! A `[deps.<name>] tag = "..."` value in `mlua-pkg.toml` is interpreted in
4//! one of two ways:
5//!
6//! - **Exact** — a full SemVer string (`v1.2.3`, `1.0.0-rc1`).  The fetcher
7//!   resolves it literally; `mlua-pkg update` leaves it alone unless
8//!   `--force` is passed.
9//! - **Prefix** — a partial version (`v1.0`, `v1`).  Both fetch and update
10//!   resolve it to the SemVer-max matching release tag on the remote, so
11//!   the manifest acts as a "follow latest patch / minor" specifier akin to
12//!   Cargo `^X.Y` ranges.  The manifest is **not** rewritten on update.
13
14/// Pin classification of a manifest `tag` value.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum TagPin {
17    /// Full `MAJOR.MINOR.PATCH` SemVer, optionally with `v` prefix and/or
18    /// pre-release/build metadata (e.g. `v1.2.3-rc1`).
19    Exact,
20    /// Partial SemVer (`v1.0`, `1`, `v2`).  The numeric components are
21    /// returned in left-to-right order; e.g. `"v1.0"` → `[1, 0]`.
22    Prefix(Vec<u64>),
23}
24
25/// Strip a single leading `v` or `V` from `s`.
26pub fn strip_v_prefix(s: &str) -> &str {
27    s.strip_prefix('v')
28        .or_else(|| s.strip_prefix('V'))
29        .unwrap_or(s)
30}
31
32/// Classify a manifest tag string.  Returns `None` for values that are
33/// neither full SemVer nor pure numeric prefixes (e.g. `"latest"`,
34/// `"feature-x"`); such pins are skipped on update and rejected for
35/// auto-resolution on install.
36pub fn classify_tag_pin(tag: &str) -> Option<TagPin> {
37    let stripped = strip_v_prefix(tag);
38    if semver::Version::parse(stripped).is_ok() {
39        return Some(TagPin::Exact);
40    }
41    let parts: Option<Vec<u64>> = stripped.split('.').map(|s| s.parse::<u64>().ok()).collect();
42    let parts = parts?;
43    if parts.is_empty() || parts.len() >= 3 {
44        return None;
45    }
46    Some(TagPin::Prefix(parts))
47}
48
49/// Pick the SemVer-max release tag from `tags` whose components match `pin`'s
50/// prefix.  Pre-release tags are excluded.  The original tag string is
51/// returned verbatim (preserving any `v` prefix) so that callers can use it
52/// directly as a git ref name.
53pub fn pick_latest_for_pin(tags: &[String], pin: &[u64]) -> Option<String> {
54    let mut best: Option<(semver::Version, &String)> = None;
55    for tag in tags {
56        let stripped = strip_v_prefix(tag);
57        let Ok(ver) = semver::Version::parse(stripped) else {
58            continue;
59        };
60        if !ver.pre.is_empty() {
61            continue;
62        }
63        let comps = [ver.major, ver.minor, ver.patch];
64        if pin.len() > comps.len() {
65            continue;
66        }
67        if pin.iter().zip(comps.iter()).any(|(p, c)| p != c) {
68            continue;
69        }
70        match &best {
71            None => best = Some((ver, tag)),
72            Some((b, _)) if &ver > b => best = Some((ver, tag)),
73            _ => {}
74        }
75    }
76    best.map(|(_, t)| t.clone())
77}
78
79/// Pick the SemVer-max release tag overall.  Pre-release tags are excluded.
80pub fn pick_latest_overall(tags: &[String]) -> Option<String> {
81    let mut best: Option<(semver::Version, &String)> = None;
82    for tag in tags {
83        let stripped = strip_v_prefix(tag);
84        let Ok(ver) = semver::Version::parse(stripped) else {
85            continue;
86        };
87        if !ver.pre.is_empty() {
88            continue;
89        }
90        match &best {
91            None => best = Some((ver, tag)),
92            Some((b, _)) if &ver > b => best = Some((ver, tag)),
93            _ => {}
94        }
95    }
96    best.map(|(_, t)| t.clone())
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn classify_full_semver_is_exact() {
105        assert_eq!(classify_tag_pin("v1.2.3"), Some(TagPin::Exact));
106        assert_eq!(classify_tag_pin("1.2.3"), Some(TagPin::Exact));
107        assert_eq!(classify_tag_pin("v0.1.0"), Some(TagPin::Exact));
108        assert_eq!(classify_tag_pin("1.0.0-rc1"), Some(TagPin::Exact));
109    }
110
111    #[test]
112    fn classify_partial_is_prefix() {
113        assert_eq!(classify_tag_pin("v1.0"), Some(TagPin::Prefix(vec![1, 0])));
114        assert_eq!(classify_tag_pin("v1"), Some(TagPin::Prefix(vec![1])));
115        assert_eq!(classify_tag_pin("2.5"), Some(TagPin::Prefix(vec![2, 5])));
116    }
117
118    #[test]
119    fn classify_non_semver_is_none() {
120        assert_eq!(classify_tag_pin("latest"), None);
121        assert_eq!(classify_tag_pin("release-candidate"), None);
122        assert_eq!(classify_tag_pin(""), None);
123    }
124
125    #[test]
126    fn pick_for_pin_takes_prefix_max() {
127        let tags = vec![
128            "v1.0.0".to_string(),
129            "v1.0.1".to_string(),
130            "v1.0.5".to_string(),
131            "v1.1.0".to_string(),
132            "v2.0.0".to_string(),
133        ];
134        assert_eq!(
135            pick_latest_for_pin(&tags, &[1, 0]),
136            Some("v1.0.5".to_string())
137        );
138        assert_eq!(pick_latest_for_pin(&tags, &[1]), Some("v1.1.0".to_string()));
139        assert_eq!(pick_latest_for_pin(&tags, &[3]), None);
140    }
141
142    #[test]
143    fn pick_for_pin_excludes_prerelease() {
144        let tags = vec![
145            "v1.0.0".to_string(),
146            "v1.0.1-rc1".to_string(),
147            "v1.0.1".to_string(),
148        ];
149        assert_eq!(
150            pick_latest_for_pin(&tags, &[1, 0]),
151            Some("v1.0.1".to_string())
152        );
153    }
154
155    #[test]
156    fn pick_for_pin_ignores_unrelated_tags() {
157        let tags = vec![
158            "v1.0.0".to_string(),
159            "release-2024".to_string(),
160            "v1.0.1".to_string(),
161        ];
162        assert_eq!(
163            pick_latest_for_pin(&tags, &[1, 0]),
164            Some("v1.0.1".to_string())
165        );
166    }
167
168    #[test]
169    fn pick_overall_takes_global_max() {
170        let tags = vec![
171            "v0.9.9".to_string(),
172            "v1.0.5".to_string(),
173            "v2.0.0".to_string(),
174            "v1.9.9".to_string(),
175        ];
176        assert_eq!(pick_latest_overall(&tags), Some("v2.0.0".to_string()));
177    }
178}