Skip to main content

mars_schema/
version.rs

1//! The two version grammars Mars reads and how a version is chosen among a repository's tags.
2//!
3//! A dependency entry's `version` (see [`parse_constraint`]) and a package's `requires-mars` /
4//! `requires-meridian` ([`satisfies_requirement`]) are different grammars: a bare `0.20` is the
5//! caret range `^0.20` in the first and `>=0.20.0` in the second. Everything here is pure; the
6//! caller supplies the tag list and acts on the choice. [`choose`] is the whole decision: the
7//! binary runs its stages ([`settle`], the lock check [`keeps_lock`], then
8//! [`select`]) itself, because it replays the lock without listing tags, and `selectVersion` of
9//! the WASM package runs them all at once. A lock that holds is kept without asking whether its
10//! tag is still in the repository: only `mars upgrade` moves it.
11
12use std::collections::HashSet;
13use std::fmt;
14
15use semver::{Version, VersionReq};
16
17/// A dependency entry's `version`, read.
18#[derive(Debug, Clone)]
19pub enum Constraint {
20    /// Semver requirement (`^1.0`, `>=0.5.0`, `~2.1`, an exact version).
21    Semver(VersionReq),
22    /// Any version, prefer newest.
23    Latest,
24    /// Branch or commit pin: no semver resolution.
25    RefPin(String),
26}
27
28impl fmt::Display for Constraint {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            Constraint::Semver(req) => write!(f, "{req}"),
32            Constraint::Latest => write!(f, "latest"),
33            Constraint::RefPin(reference) => write!(f, "ref:{reference}"),
34        }
35    }
36}
37
38/// Parse a dependency entry's `version`.
39///
40/// - `None`, `""` or `"latest"` (any case): [`Constraint::Latest`]
41/// - `"v1.2.3"`: exactly that version
42/// - `"v2"`: `>=2.0.0, <3.0.0`; `"v2.1"`: `>=2.1.0, <2.2.0`
43/// - `">=0.5.0"`, `"^2.0"`, `"~1.2"`, `"0.20"` (a caret range): a semver requirement
44/// - anything else: a branch or commit [`Constraint::RefPin`]
45pub fn parse_constraint(version: Option<&str>) -> Constraint {
46    let version = match version {
47        None => return Constraint::Latest,
48        Some(v) => v.trim(),
49    };
50
51    if version.is_empty() || version.eq_ignore_ascii_case("latest") {
52        return Constraint::Latest;
53    }
54
55    if let Some(stripped) = version.strip_prefix('v') {
56        if let Ok(ver) = Version::parse(stripped) {
57            let req = VersionReq::parse(&format!("={ver}")).expect("valid exact req");
58            return Constraint::Semver(req);
59        }
60
61        // A bound past `u64::MAX` is no range; it falls through to a ref pin.
62        if let Ok(major) = stripped.parse::<u64>()
63            && let Some(next) = major.checked_add(1)
64        {
65            let req = VersionReq::parse(&format!(">={major}.0.0, <{next}.0.0"))
66                .expect("valid major range req");
67            return Constraint::Semver(req);
68        }
69
70        let parts: Vec<&str> = stripped.split('.').collect();
71        if parts.len() == 2
72            && let (Ok(major), Ok(minor)) = (parts[0].parse::<u64>(), parts[1].parse::<u64>())
73            && let Some(next) = minor.checked_add(1)
74        {
75            let req = VersionReq::parse(&format!(">={major}.{minor}.0, <{major}.{next}.0"))
76                .expect("valid minor range req");
77            return Constraint::Semver(req);
78        }
79    }
80
81    if let Ok(req) = VersionReq::parse(version) {
82        return Constraint::Semver(req);
83    }
84
85    Constraint::RefPin(version.to_string())
86}
87
88/// Parse a tag name as a semver version tag: one leading `v` is dropped, then the rest must be
89/// strict semver. `v1.0.0` and `1.0.0` are versions; `latest` and `nightly-2024` are not.
90pub fn parse_tag(tag: &str) -> Option<Version> {
91    Version::parse(tag.strip_prefix('v').unwrap_or(tag)).ok()
92}
93
94/// Whether `version` meets a dependency `version`. A [`Constraint::RefPin`] names no version, so
95/// nothing meets it.
96pub fn satisfies(constraint: &Constraint, version: &Version) -> bool {
97    match constraint {
98        Constraint::Semver(req) => req.matches(version),
99        Constraint::Latest => true,
100        Constraint::RefPin(_) => false,
101    }
102}
103
104/// Whether `version` meets a package's `requires-mars` / `requires-meridian` (a bare `0.20` means
105/// `>=0.20.0`). An error is a requirement that is not one.
106pub fn satisfies_requirement(requirement: &str, version: &Version) -> Result<bool, ParseError> {
107    let requirement = crate::package::parse_requirement(requirement)
108        .map_err(|error| ParseError::Requirement(requirement.to_owned(), error.to_string()))?;
109    Ok(requirement.matches(version))
110}
111
112/// Version text that is not the kind of version the caller said it was.
113#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
114pub enum ParseError {
115    #[error("`{0}` is not a semver version: {1}")]
116    Version(String, String),
117    #[error("`{0}` is not a version requirement: {1}")]
118    Requirement(String, String),
119    #[error("`{0}` is not a version tag")]
120    Tag(String),
121}
122
123impl ParseError {
124    /// A stable identifier for callers that map failures to their own codes.
125    pub fn code(&self) -> &'static str {
126        match self {
127            Self::Version(..) => "version.invalid-version",
128            Self::Requirement(..) => "version.invalid-requirement",
129            Self::Tag(_) => "version.invalid-tag",
130        }
131    }
132}
133
134/// Parse strict semver (no leading `v`).
135pub fn parse_version(text: &str) -> Result<Version, ParseError> {
136    Version::parse(text).map_err(|error| ParseError::Version(text.to_owned(), error.to_string()))
137}
138
139/// [`parse_tag`], where a tag that is not a version is the caller's mistake.
140pub fn require_tag(tag: &str) -> Result<Version, ParseError> {
141    parse_tag(tag).ok_or_else(|| ParseError::Tag(tag.to_owned()))
142}
143
144/// How a chosen version relates to the lock.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum SelectionPolicy {
147    /// Use the locked version when it is allowed; otherwise the newest allowed.
148    PreferLockThenLatest,
149    /// The newest allowed version, ignoring the lock.
150    LatestOnly,
151}
152
153/// Why no version could be chosen.
154#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
155pub enum SelectError {
156    /// No tag meets every constraint (`requester`, requirement); `available` is ascending.
157    #[error("{}", no_satisfying_message(.constraints, .available))]
158    NoSatisfying {
159        constraints: Vec<(String, VersionReq)>,
160        available: Vec<Version>,
161    },
162}
163
164impl SelectError {
165    /// A stable identifier for callers that map failures to their own codes.
166    pub fn code(&self) -> &'static str {
167        match self {
168            Self::NoSatisfying { .. } => "version.no-satisfying",
169        }
170    }
171}
172
173fn no_satisfying_message(constraints: &[(String, VersionReq)], available: &[Version]) -> String {
174    let wanted: Vec<String> = constraints
175        .iter()
176        .map(|(requester, req)| format!("  `{requester}` requires {req}"))
177        .collect();
178    let have: Vec<String> = available.iter().map(Version::to_string).collect();
179    format!(
180        "no version satisfies all constraints:\n{}\navailable versions: [{}]",
181        wanted.join("\n"),
182        have.join(", ")
183    )
184}
185
186/// Constraints that cannot both hold on one ref.
187#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
188pub enum SettleError {
189    #[error("`{first}` requires ref:{first_ref}, but `{second}` requires ref:{second_ref}")]
190    PinsDiffer {
191        first: String,
192        first_ref: String,
193        second: String,
194        second_ref: String,
195    },
196    /// A pin names no version, so no range holds at it.
197    #[error("`{pinned_by}` requires ref:{reference}, but `{ranged_by}` requires {range}")]
198    PinBesideRange {
199        pinned_by: String,
200        reference: String,
201        ranged_by: String,
202        range: VersionReq,
203    },
204}
205
206impl SettleError {
207    /// A stable identifier for callers that map failures to their own codes.
208    pub fn code(&self) -> &'static str {
209        match self {
210            Self::PinsDiffer { .. } => "version.pins-differ",
211            Self::PinBesideRange { .. } => "version.pin-beside-range",
212        }
213    }
214}
215
216/// Settle every `(requester, constraint)` on a package: each must hold on the ref it ends at,
217/// whether or not it selects anything. A [`Constraint::RefPin`] holds only at that ref, a
218/// [`Constraint::Semver`] never at a pin, and [`Constraint::Latest`] anywhere. So the package
219/// ends at the one pin if there is one (returned; `None` means [`select`] chooses among the
220/// tags), and two pins, or a pin beside a range, refuse. A `Latest` beside a pin holds but
221/// drifts: it asked for the newest and gets a fixed ref.
222pub fn settle<'a>(constraints: &[(&str, &'a Constraint)]) -> Result<Option<&'a str>, SettleError> {
223    let mut pin: Option<(&str, &'a str)> = None;
224    for &(requester, constraint) in constraints {
225        if let Constraint::RefPin(reference) = constraint {
226            match pin {
227                Some((first, first_ref)) if first_ref != reference => {
228                    return Err(SettleError::PinsDiffer {
229                        first: first.to_string(),
230                        first_ref: first_ref.to_string(),
231                        second: requester.to_string(),
232                        second_ref: reference.clone(),
233                    });
234                }
235                Some(_) => {}
236                None => pin = Some((requester, reference)),
237            }
238        }
239    }
240    let Some((pinned_by, reference)) = pin else {
241        return Ok(None);
242    };
243    for &(requester, constraint) in constraints {
244        if let Constraint::Semver(range) = constraint {
245            return Err(SettleError::PinBesideRange {
246                pinned_by: pinned_by.to_string(),
247                reference: reference.to_string(),
248                ranged_by: requester.to_string(),
249                range: range.clone(),
250            });
251        }
252    }
253    Ok(Some(reference))
254}
255
256/// Whether `version` meets every semver range in `constraints` and is not in `excluded`.
257/// [`Constraint::Latest`] allows any version; a [`Constraint::RefPin`] must have been
258/// [settled](settle) already, so it is not read here.
259pub fn allows(
260    constraints: &[(&str, &Constraint)],
261    version: &Version,
262    excluded: &HashSet<Version>,
263) -> bool {
264    !excluded.contains(version)
265        && constraints.iter().all(|(_, constraint)| match constraint {
266            Constraint::Semver(req) => req.matches(version),
267            _ => true,
268        })
269}
270
271/// Whether the lock is kept: `policy` prefers it (a [`Constraint::Latest`] asks for the newest, so
272/// beside one `PreferLockThenLatest` prefers nothing) and the locked version is allowed. It does
273/// not ask whether the tag is still in the repository, so the answer needs no tag list and a tag
274/// deleted upstream keeps the lock. `--frozen` is the binary's own rule and asks [`allows`].
275pub fn keeps_lock(
276    constraints: &[(&str, &Constraint)],
277    locked: &Version,
278    policy: SelectionPolicy,
279    excluded: &HashSet<Version>,
280) -> bool {
281    policy == SelectionPolicy::PreferLockThenLatest
282        && !constraints
283            .iter()
284            .any(|(_, constraint)| matches!(constraint, Constraint::Latest))
285        && allows(constraints, locked, excluded)
286}
287
288/// Choose one of `available` that [`allows`] and return its index. The newest allowed wins
289/// whatever the order of `available`; of equal versions the later one does. A `locked` that is
290/// among `available` and that [`keeps_lock`] wins instead.
291/// `Ok(None)` is a repository with no version tags: the caller takes its default branch.
292pub fn select(
293    available: &[Version],
294    constraints: &[(&str, &Constraint)],
295    locked: Option<&Version>,
296    policy: SelectionPolicy,
297    excluded: &HashSet<Version>,
298) -> Result<Option<usize>, SelectError> {
299    if available.is_empty() {
300        return Ok(None);
301    }
302    let allowed: Vec<usize> = (0..available.len())
303        .filter(|&i| allows(constraints, &available[i], excluded))
304        .collect();
305
306    let Some(newest) = allowed.iter().copied().max_by_key(|&i| (&available[i], i)) else {
307        let mut ascending = available.to_vec();
308        ascending.sort();
309        return Err(SelectError::NoSatisfying {
310            constraints: constraints
311                .iter()
312                .filter_map(|&(requester, constraint)| match constraint {
313                    Constraint::Semver(req) => Some((requester.to_string(), req.clone())),
314                    _ => None,
315                })
316                .collect(),
317            available: ascending,
318        });
319    };
320
321    let kept = locked
322        .filter(|locked| keeps_lock(constraints, locked, policy, excluded))
323        .and_then(|locked| allowed.iter().copied().find(|&i| available[i] == *locked));
324    Ok(Some(kept.unwrap_or(newest)))
325}
326
327/// What a package ends at.
328#[derive(Debug, Clone, PartialEq, Eq)]
329pub enum Chosen<'a> {
330    /// A branch or commit every constraint holds at.
331    Pin(&'a str),
332    /// One of the tags offered.
333    Tag(&'a str),
334    /// The locked version, which is kept ([`keeps_lock`]) whether or not the repository still has
335    /// its tag.
336    Locked,
337    /// No version tags: the default branch.
338    Untagged,
339}
340
341/// Why no ref could be chosen.
342#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
343pub enum ChooseError {
344    #[error(transparent)]
345    Settle(#[from] SettleError),
346    #[error(transparent)]
347    Select(#[from] SelectError),
348}
349
350impl ChooseError {
351    /// A stable identifier for callers that map failures to their own codes.
352    pub fn code(&self) -> &'static str {
353        match self {
354            Self::Settle(error) => error.code(),
355            Self::Select(error) => error.code(),
356        }
357    }
358}
359
360/// The whole decision for one package, from every constraint on it and the tags the repository
361/// has: [`settle`] first (a pin ends it), then the lock if it is kept, then
362/// [`select`] among the tags that are versions. A tag that is not one (`nightly`) is ignored, as
363/// the binary ignores it when it lists a repository.
364pub fn choose<'a>(
365    tags: &'a [String],
366    constraints: &[(&str, &'a Constraint)],
367    locked: Option<&Version>,
368    policy: SelectionPolicy,
369    excluded: &HashSet<Version>,
370) -> Result<Chosen<'a>, ChooseError> {
371    if let Some(reference) = settle(constraints)? {
372        return Ok(Chosen::Pin(reference));
373    }
374    let (names, versions): (Vec<&str>, Vec<Version>) = tags
375        .iter()
376        .filter_map(|tag| Some((tag.as_str(), parse_tag(tag)?)))
377        .unzip();
378    if locked.is_some_and(|locked| keeps_lock(constraints, locked, policy, excluded)) {
379        return Ok(Chosen::Locked);
380    }
381    Ok(select(&versions, constraints, None, policy, excluded)?
382        .map_or(Chosen::Untagged, |index| Chosen::Tag(names[index])))
383}