Skip to main content

cabin_core/
version_req.rs

1//! Lenient `SemVer` version-requirement parsing.
2//!
3//! `semver::VersionReq` only accepts comma-separated comparator
4//! lists. Cabin manifests and index entries follow the
5//! npm-flavored form where space and comma are both accepted, so
6//! the two crates that read `SemVer` requirements from disk
7//! (`cabin-manifest` and `cabin-index`) used to carry an
8//! identical normalization routine. They now both consume this
9//! shared helper.
10
11/// Parse `raw` as a `SemVer` requirement, accepting either comma-
12/// or space-separated comparator lists. Bare operators (`>= 1.2`)
13/// are rejoined with their version. Returns the original parse
14/// error when the input cannot be coerced into either form so
15/// callers' diagnostics keep pointing at the user's text.
16///
17/// # Errors
18/// Returns the [`semver::Error`] from parsing `raw` when it parses neither
19/// directly nor after normalization into a comma-separated comparator list.
20pub fn parse_lenient(raw: &str) -> Result<semver::VersionReq, semver::Error> {
21    if let Ok(req) = semver::VersionReq::parse(raw) {
22        return Ok(req);
23    }
24    let normalized = normalize(raw);
25    if normalized != raw
26        && let Ok(req) = semver::VersionReq::parse(&normalized)
27    {
28        return Ok(req);
29    }
30    semver::VersionReq::parse(raw)
31}
32
33/// Convert a space-separated list of `SemVer` comparators into the
34/// comma-separated form `semver::VersionReq::parse` accepts.
35/// Operators detached from their version (`>= 1.2.3`) are
36/// re-attached. Exposed alongside [`parse_lenient`] so callers
37/// that want to display the canonical comma-separated form can
38/// reuse the same normalization.
39pub(crate) fn normalize(input: &str) -> String {
40    let tokens: Vec<&str> = input.split_whitespace().collect();
41    let mut comparators: Vec<String> = Vec::new();
42    let mut i = 0;
43    while i < tokens.len() {
44        let tok = tokens[i].trim_end_matches(',');
45        if tok.is_empty() {
46            i += 1;
47            continue;
48        }
49        let bare_op = matches!(tok, ">=" | ">" | "<=" | "<" | "=" | "^" | "~");
50        if bare_op && i + 1 < tokens.len() {
51            let next = tokens[i + 1].trim_end_matches(',');
52            comparators.push(format!("{tok}{next}"));
53            i += 2;
54            continue;
55        }
56        comparators.push(tok.to_owned());
57        i += 1;
58    }
59    comparators.join(", ")
60}
61
62/// The exclusive upper bound of a caret (`^`) requirement, given a
63/// fully specified `(major, minor, patch)`: bump the leftmost
64/// non-zero segment and zero out everything to its right, per the
65/// Cargo/npm caret rule.
66///
67/// This is the single source of truth shared by the two crates that
68/// turn caret requirements into a concrete bound in different output
69/// forms — the resolver (`PubGrub` `Ranges`) and `cabin-system-deps`
70/// (pkg-config `<` strings) — so the subtle zero-major / zero-minor
71/// cases cannot drift apart. Callers that allow *partial* comparators
72/// (an absent minor or patch, e.g. `^0` or `^0.0`) must apply their
73/// own widening policy before calling this, because those forms are
74/// not expressible as a leftmost-non-zero bump of a single triple.
75#[must_use]
76pub fn caret_upper_bound(major: u64, minor: u64, patch: u64) -> (u64, u64, u64) {
77    if major > 0 {
78        (major.saturating_add(1), 0, 0)
79    } else if minor > 0 {
80        (0, minor.saturating_add(1), 0)
81    } else {
82        (0, 0, patch.saturating_add(1))
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn parse_accepts_comma_separated_form_unchanged() {
92        let req = parse_lenient(">=1.2, <2").unwrap();
93        assert!(req.matches(&semver::Version::new(1, 5, 0)));
94        assert!(!req.matches(&semver::Version::new(2, 0, 0)));
95    }
96
97    #[test]
98    fn parse_normalizes_space_separated_form() {
99        let req = parse_lenient(">=1.2 <2").unwrap();
100        assert!(req.matches(&semver::Version::new(1, 5, 0)));
101        assert!(!req.matches(&semver::Version::new(2, 0, 0)));
102    }
103
104    #[test]
105    fn parse_rejoins_bare_operator_and_version() {
106        let req = parse_lenient(">= 1.2.3").unwrap();
107        assert!(req.matches(&semver::Version::new(1, 2, 3)));
108    }
109
110    #[test]
111    fn parse_propagates_original_error_for_garbage() {
112        // Unparsable input must keep its original error so
113        // wrapper diagnostics quote the user's text faithfully.
114        let err = parse_lenient("not-a-version").unwrap_err();
115        assert!(!err.to_string().is_empty());
116    }
117
118    #[test]
119    fn normalize_collapses_repeated_whitespace() {
120        assert_eq!(normalize(">=1.2   <2"), ">=1.2, <2");
121    }
122
123    #[test]
124    fn normalize_drops_trailing_comma_tokens() {
125        assert_eq!(normalize(">=1.2, <2"), ">=1.2, <2");
126    }
127
128    #[test]
129    fn caret_upper_bound_bumps_leftmost_nonzero_segment() {
130        // major nonzero ⇒ bump major
131        assert_eq!(caret_upper_bound(1, 2, 3), (2, 0, 0));
132        // major zero, minor nonzero ⇒ bump minor
133        assert_eq!(caret_upper_bound(0, 2, 3), (0, 3, 0));
134        // major and minor zero ⇒ bump patch
135        assert_eq!(caret_upper_bound(0, 0, 3), (0, 0, 4));
136        assert_eq!(caret_upper_bound(0, 0, 0), (0, 0, 1));
137    }
138}