Skip to main content

bock_pkg/
version.rs

1//! Version parsing utilities for Bock package constraints.
2//!
3//! Converts Bock version requirement strings (e.g., `^1.0`, `~2.3.1`, `=1.0.0`)
4//! into `semver::VersionReq` and provides conversion to pubgrub `Ranges`.
5
6use pubgrub::Ranges;
7use semver::{Version, VersionReq};
8
9use crate::error::PkgError;
10
11/// Parse a Bock version requirement string into a `semver::VersionReq`.
12///
13/// Supports `^`, `~`, `=`, `>=`, `<=`, `>`, `<` prefixes and bare versions.
14/// A bare version like `"1.0.0"` is treated as `"^1.0.0"`.
15pub fn parse_version_req(s: &str) -> Result<VersionReq, PkgError> {
16    let s = s.trim();
17    // Bare version number without operator → treat as caret
18    let req_str = if s.starts_with(|c: char| c.is_ascii_digit()) {
19        format!("^{s}")
20    } else {
21        s.to_string()
22    };
23    VersionReq::parse(&req_str).map_err(|e| PkgError::InvalidVersion(format!("{s}: {e}")))
24}
25
26/// Parse a version string into a `semver::Version`.
27pub fn parse_version(s: &str) -> Result<Version, PkgError> {
28    // Allow two-component versions like "1.0" by appending ".0"
29    let normalized = if s.matches('.').count() < 2 {
30        format!("{s}.0")
31    } else {
32        s.to_string()
33    };
34    Version::parse(&normalized).map_err(|e| PkgError::InvalidVersion(format!("{s}: {e}")))
35}
36
37/// Convert a `semver::VersionReq` into a pubgrub `Ranges<Version>`.
38///
39/// This maps semver comparators to pubgrub range operations.
40#[must_use]
41pub fn req_to_pubgrub_range(req: &VersionReq) -> Ranges<Version> {
42    // Build ranges from each comparator and intersect them
43    let mut result = Ranges::full();
44
45    for comp in &req.comparators {
46        let range = comparator_to_range(comp);
47        result = result.intersection(&range);
48    }
49
50    result
51}
52
53fn comparator_to_range(comp: &semver::Comparator) -> Ranges<Version> {
54    let major = comp.major;
55    let minor = comp.minor.unwrap_or(0);
56    let patch = comp.patch.unwrap_or(0);
57    let version = Version::new(major, minor, patch);
58
59    match comp.op {
60        semver::Op::Exact => Ranges::singleton(version),
61        semver::Op::Greater => Ranges::strictly_higher_than(version),
62        semver::Op::GreaterEq => Ranges::higher_than(version),
63        semver::Op::Less => Ranges::strictly_lower_than(version),
64        semver::Op::LessEq => {
65            // <= v means < next version
66            let next = Version::new(major, minor, patch + 1);
67            Ranges::strictly_lower_than(next)
68        }
69        semver::Op::Tilde => {
70            // ~X.Y.Z: >=X.Y.Z, <X.(Y+1).0
71            let upper = Version::new(major, minor + 1, 0);
72            Ranges::between(version, upper)
73        }
74        semver::Op::Caret => {
75            // ^X.Y.Z: >=X.Y.Z, <next breaking
76            let upper = if major > 0 {
77                Version::new(major + 1, 0, 0)
78            } else if minor > 0 {
79                Version::new(0, minor + 1, 0)
80            } else {
81                Version::new(0, 0, patch + 1)
82            };
83            Ranges::between(version, upper)
84        }
85        semver::Op::Wildcard => {
86            // X.Y.* or X.*
87            if comp.minor.is_some() {
88                // X.Y.*: >=X.Y.0, <X.(Y+1).0
89                let lower = Version::new(major, minor, 0);
90                let upper = Version::new(major, minor + 1, 0);
91                Ranges::between(lower, upper)
92            } else {
93                // X.*: >=X.0.0, <(X+1).0.0
94                let lower = Version::new(major, 0, 0);
95                let upper = Version::new(major + 1, 0, 0);
96                Ranges::between(lower, upper)
97            }
98        }
99        _ => Ranges::full(),
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn parse_caret_requirement() {
109        let req = parse_version_req("^1.0").unwrap();
110        assert!(req.matches(&Version::new(1, 5, 0)));
111        assert!(!req.matches(&Version::new(2, 0, 0)));
112    }
113
114    #[test]
115    fn parse_tilde_requirement() {
116        let req = parse_version_req("~1.2.3").unwrap();
117        assert!(req.matches(&Version::new(1, 2, 5)));
118        assert!(!req.matches(&Version::new(1, 3, 0)));
119    }
120
121    #[test]
122    fn parse_bare_version_as_caret() {
123        let req = parse_version_req("1.0.0").unwrap();
124        assert!(req.matches(&Version::new(1, 5, 0)));
125        assert!(!req.matches(&Version::new(2, 0, 0)));
126    }
127
128    #[test]
129    fn parse_exact_requirement() {
130        let req = parse_version_req("=1.2.3").unwrap();
131        assert!(req.matches(&Version::new(1, 2, 3)));
132        assert!(!req.matches(&Version::new(1, 2, 4)));
133    }
134
135    #[test]
136    fn caret_to_pubgrub_range() {
137        let req = parse_version_req("^1.0").unwrap();
138        let range = req_to_pubgrub_range(&req);
139        assert!(range.contains(&Version::new(1, 0, 0)));
140        assert!(range.contains(&Version::new(1, 9, 9)));
141        assert!(!range.contains(&Version::new(2, 0, 0)));
142        assert!(!range.contains(&Version::new(0, 9, 9)));
143    }
144
145    #[test]
146    fn tilde_to_pubgrub_range() {
147        let req = parse_version_req("~1.2.0").unwrap();
148        let range = req_to_pubgrub_range(&req);
149        assert!(range.contains(&Version::new(1, 2, 0)));
150        assert!(range.contains(&Version::new(1, 2, 9)));
151        assert!(!range.contains(&Version::new(1, 3, 0)));
152    }
153
154    #[test]
155    fn parse_two_component_version() {
156        let v = parse_version("1.0").unwrap();
157        assert_eq!(v, Version::new(1, 0, 0));
158    }
159}