Skip to main content

pulith_version/
version.rs

1//! Version types and operations.
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5use semver::Version as SemVer;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9static CALVER_REGEX: Lazy<Regex> = Lazy::new(|| {
10    Regex::new(r"^(?<year>[0-9]{4})[-.](?<month>(0?[1-9]|10|11|12))(?:\.(?<day>(0?[1-9]|[1-3][0-9])))?(?:\+(?<micro>[0-9]+))?(?:-(?<pre>[a-zA-Z][-0-9a-zA-Z.]+))?$").unwrap()
11});
12
13static PARTIAL_REGEX: Lazy<Regex> = Lazy::new(|| {
14    Regex::new(r"^(?:(?<major>[0-9]+))?(?:\.(?<minor>[0-9]+))?(?:\.(?<patch>[0-9]+))?(?:-(?<pre>[a-zA-Z][-0-9a-zA-Z.]*))?(?:\+(?<build>[-0-9a-zA-Z.]+))?(?:lts)?$").unwrap()
15});
16
17#[derive(Debug, Error)]
18pub enum VersionError {
19    #[error("invalid semver")]
20    SemVer(#[from] semver::Error),
21    #[error(transparent)]
22    CalVer(#[from] CalVerError),
23    #[error(transparent)]
24    Partial(#[from] PartialError),
25    #[error("unknown version scheme")]
26    Unknown,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30pub enum VersionKindType {
31    SemVer,
32    CalVer,
33    Partial,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
37pub enum VersionKind {
38    SemVer(SemVer),
39    CalVer(CalVer),
40    Partial(Partial),
41}
42
43#[derive(Debug, Error)]
44#[error("invalid CalVer format: {0}")]
45pub struct CalVerError(pub String);
46
47#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
48pub struct CalVer(SemVer);
49
50impl CalVer {
51    pub fn parse(s: &str) -> Result<Self, CalVerError> {
52        let caps = CALVER_REGEX
53            .captures(s)
54            .ok_or_else(|| CalVerError(s.to_string()))?;
55
56        let year = caps
57            .name("year")
58            .map(|c| c.as_str().trim_start_matches('0'))
59            .unwrap_or("0");
60        let year = if year.len() < 4 {
61            format!("20{}", year)
62        } else {
63            year.to_string()
64        };
65
66        let month = caps
67            .name("month")
68            .map(|c| c.as_str().trim_start_matches('0'))
69            .unwrap_or("0");
70        let day = caps
71            .name("day")
72            .map(|c| c.as_str().trim_start_matches('0'))
73            .unwrap_or("0");
74
75        let mut version = format!("{}.{}.{}", year, month, day);
76
77        if let Some(pre) = caps.name("pre") {
78            version.push('-');
79            version.push_str(pre.as_str());
80        }
81
82        if let Some(micro) = caps.name("micro") {
83            version.push('+');
84            version.push_str(micro.as_str());
85        }
86
87        Ok(Self(
88            SemVer::parse(&version).map_err(|_| CalVerError(s.to_string()))?,
89        ))
90    }
91
92    pub fn from_ymd(year: u64, month: u64, day: u64) -> Result<Self, CalVerError> {
93        if !(1..=12).contains(&month) {
94            return Err(CalVerError(format!("invalid month: {}", month)));
95        }
96        if !(1..=31).contains(&day) {
97            return Err(CalVerError(format!("invalid day: {}", day)));
98        }
99
100        let version = format!("{:04}.{:02}.{:02}", year, month, day);
101        Ok(Self(
102            SemVer::parse(&version).map_err(|_| CalVerError(version))?,
103        ))
104    }
105
106    pub fn year(&self) -> u64 {
107        self.0.major
108    }
109    pub fn month(&self) -> u64 {
110        self.0.minor
111    }
112    pub fn day(&self) -> u64 {
113        self.0.patch
114    }
115}
116
117impl std::str::FromStr for CalVer {
118    type Err = CalVerError;
119
120    fn from_str(s: &str) -> Result<Self, Self::Err> {
121        CalVer::parse(s)
122    }
123}
124
125impl std::fmt::Display for CalVer {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        write!(f, "{:04}.{:02}", self.0.major, self.0.minor)?;
128        if self.0.patch > 0 {
129            write!(f, ".{:02}", self.0.patch)?;
130        }
131        if !self.0.pre.is_empty() {
132            write!(f, "-{}", self.0.pre)?;
133        }
134        if !self.0.build.is_empty() {
135            write!(f, "+{}", self.0.build)?;
136        }
137        Ok(())
138    }
139}
140
141impl std::ops::Deref for CalVer {
142    type Target = SemVer;
143
144    fn deref(&self) -> &Self::Target {
145        &self.0
146    }
147}
148
149#[derive(Debug, Error)]
150#[error("invalid partial version: {0}")]
151pub struct PartialError(pub String);
152
153#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
154pub struct Partial {
155    pub major: Option<u64>,
156    pub minor: Option<u64>,
157    pub patch: Option<u64>,
158    pub pre_release: Option<String>,
159    pub build_metadata: Option<String>,
160    pub lts: bool,
161}
162
163impl Partial {
164    pub fn parse(s: &str) -> Result<Self, PartialError> {
165        let trimmed = s.trim();
166        let lts = trimmed.ends_with("lts");
167        let trimmed = trimmed.trim_end_matches("lts");
168
169        let (parts, build) = trimmed
170            .split_once('+')
171            .map(|(c, b)| (c, Some(b)))
172            .unwrap_or((trimmed, None));
173        let (parts, pre) = parts
174            .split_once('-')
175            .map(|(c, p)| (c, Some(p)))
176            .unwrap_or((parts, None));
177
178        let caps = PARTIAL_REGEX
179            .captures(parts)
180            .ok_or_else(|| PartialError(s.to_string()))?;
181
182        let major = caps.name("major").and_then(|m| m.as_str().parse().ok());
183        let minor = caps.name("minor").and_then(|m| m.as_str().parse().ok());
184        let patch = caps.name("patch").and_then(|m| m.as_str().parse().ok());
185
186        if major.is_none() && minor.is_none() && patch.is_none() {
187            return Err(PartialError(s.to_string()));
188        }
189
190        Ok(Partial {
191            major,
192            minor,
193            patch,
194            pre_release: pre.map(|s| s.to_string()),
195            build_metadata: build.map(|s| s.to_string()),
196            lts,
197        })
198    }
199
200    pub fn matches(&self, version: &VersionKind) -> bool {
201        match version {
202            VersionKind::SemVer(v) => {
203                self.major.is_none_or(|m| m == v.major)
204                    && self.minor.is_none_or(|m| m == v.minor)
205                    && self.patch.is_none_or(|m| m == v.patch)
206            }
207            VersionKind::CalVer(v) => {
208                self.major.is_none_or(|m| m == v.year())
209                    && self.minor.is_none_or(|m| m == v.month())
210                    && self.patch.is_none_or(|m| m == v.day())
211            }
212            VersionKind::Partial(other) => {
213                self.major == other.major && self.minor == other.minor && self.patch == other.patch
214            }
215        }
216    }
217}
218
219impl std::str::FromStr for Partial {
220    type Err = PartialError;
221
222    fn from_str(s: &str) -> Result<Self, Self::Err> {
223        Partial::parse(s)
224    }
225}
226
227impl std::fmt::Display for Partial {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        if let Some(major) = self.major {
230            write!(f, "{}", major)?;
231        }
232        if let Some(minor) = self.minor {
233            write!(f, ".{}", minor)?;
234        }
235        if let Some(patch) = self.patch {
236            write!(f, ".{}", patch)?;
237        }
238        if let Some(pre) = &self.pre_release {
239            write!(f, "-{}", pre)?;
240        }
241        if let Some(build) = &self.build_metadata {
242            write!(f, "+{}", build)?;
243        }
244        if self.lts {
245            write!(f, "lts")?;
246        }
247        Ok(())
248    }
249}
250
251impl std::str::FromStr for VersionKind {
252    type Err = VersionError;
253
254    fn from_str(s: &str) -> Result<Self, Self::Err> {
255        if let Ok(v) = s.parse::<SemVer>() {
256            return Ok(VersionKind::SemVer(v));
257        }
258        if let Ok(v) = s.parse::<CalVer>() {
259            return Ok(VersionKind::CalVer(v));
260        }
261        match s.parse::<Partial>() {
262            Ok(p) => Ok(VersionKind::Partial(p)),
263            Err(e) => Err(VersionError::Partial(e)),
264        }
265    }
266}
267
268impl std::fmt::Display for VersionKind {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        match self {
271            VersionKind::SemVer(v) => write!(f, "{}", v),
272            VersionKind::CalVer(v) => write!(f, "{}", v),
273            VersionKind::Partial(v) => write!(f, "{}", v),
274        }
275    }
276}
277
278impl VersionKind {
279    pub fn parse(s: &str) -> Result<Self, VersionError> {
280        s.parse()
281    }
282
283    pub fn as_semver(&self) -> Option<&SemVer> {
284        match self {
285            VersionKind::SemVer(v) => Some(v),
286            _ => None,
287        }
288    }
289
290    pub fn kind(&self) -> VersionKindType {
291        match self {
292            VersionKind::SemVer(_) => VersionKindType::SemVer,
293            VersionKind::CalVer(_) => VersionKindType::CalVer,
294            VersionKind::Partial(_) => VersionKindType::Partial,
295        }
296    }
297}
298
299#[derive(Debug, Error)]
300pub enum VersionPreferenceError {
301    #[error("invalid version requirement: {0}")]
302    InvalidRequirement(String),
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306pub enum VersionRequirement {
307    Any,
308    Exact(VersionKind),
309    Partial(Partial),
310    SemVer(semver::VersionReq),
311}
312
313impl VersionRequirement {
314    pub fn parse(input: &str) -> Result<Self, VersionPreferenceError> {
315        let trimmed = input.trim();
316        if trimmed.is_empty() || trimmed == "*" {
317            return Ok(Self::Any);
318        }
319
320        if let Ok(requirement) = semver::VersionReq::parse(trimmed) {
321            return Ok(Self::SemVer(requirement));
322        }
323
324        if let Ok(partial) = Partial::parse(trimmed) {
325            return Ok(Self::Partial(partial));
326        }
327
328        if let Ok(version) = VersionKind::parse(trimmed) {
329            return Ok(Self::Exact(version));
330        }
331
332        Err(VersionPreferenceError::InvalidRequirement(
333            trimmed.to_string(),
334        ))
335    }
336
337    pub fn matches(&self, version: &VersionKind) -> bool {
338        match self {
339            Self::Any => true,
340            Self::Exact(expected) => expected == version,
341            Self::Partial(partial) => partial.matches(version),
342            Self::SemVer(requirement) => version
343                .as_semver()
344                .is_some_and(|value| requirement.matches(value)),
345        }
346    }
347}
348
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
350pub enum VersionPreference {
351    Latest,
352    Lowest,
353    HighestStable,
354    Lts,
355    Pinned(VersionKind),
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
359pub struct SelectionPolicy {
360    pub requirement: VersionRequirement,
361    pub preference: VersionPreference,
362}
363
364impl Default for SelectionPolicy {
365    fn default() -> Self {
366        Self {
367            requirement: VersionRequirement::Any,
368            preference: VersionPreference::Latest,
369        }
370    }
371}
372
373pub fn select_preferred<'a>(
374    versions: &'a [VersionKind],
375    policy: &SelectionPolicy,
376) -> Option<&'a VersionKind> {
377    let candidates = matching_candidates(versions, &policy.requirement);
378
379    if candidates.is_empty() {
380        return None;
381    }
382
383    match &policy.preference {
384        VersionPreference::Lowest => candidates.into_iter().min(),
385        VersionPreference::Latest => candidates.into_iter().max(),
386        VersionPreference::HighestStable => candidates
387            .into_iter()
388            .filter(|version| version.is_stable())
389            .max()
390            .or_else(|| {
391                matching_candidates(versions, &policy.requirement)
392                    .into_iter()
393                    .max()
394            }),
395        VersionPreference::Lts => candidates
396            .into_iter()
397            .filter(|version| matches!(version, VersionKind::Partial(partial) if partial.lts))
398            .max()
399            .or_else(|| {
400                matching_candidates(versions, &policy.requirement)
401                    .into_iter()
402                    .max()
403            }),
404        VersionPreference::Pinned(version) => {
405            versions.iter().find(|candidate| *candidate == version)
406        }
407    }
408}
409
410fn matching_candidates<'a>(
411    versions: &'a [VersionKind],
412    requirement: &VersionRequirement,
413) -> Vec<&'a VersionKind> {
414    versions
415        .iter()
416        .filter(|version| requirement.matches(version))
417        .collect()
418}
419
420impl VersionKind {
421    pub fn is_stable(&self) -> bool {
422        match self {
423            VersionKind::SemVer(version) => version.pre.is_empty(),
424            VersionKind::CalVer(version) => version.pre.is_empty(),
425            VersionKind::Partial(version) => version.pre_release.is_none(),
426        }
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use proptest::prelude::*;
433
434    use super::{
435        Partial, SelectionPolicy, VersionKind, VersionKindType, VersionPreference,
436        VersionRequirement, select_preferred,
437    };
438
439    #[test]
440    fn test_semver_parse() {
441        let v: VersionKind = "1.2.3".parse().unwrap();
442        assert_eq!(v.kind(), VersionKindType::SemVer);
443    }
444
445    #[test]
446    fn test_calver_parse() {
447        let v: VersionKind = "2024.01".parse().unwrap();
448        assert_eq!(v.kind(), VersionKindType::CalVer);
449    }
450
451    #[test]
452    fn test_partial_major() {
453        let v: VersionKind = "18".parse().unwrap();
454        assert_eq!(v.kind(), VersionKindType::Partial);
455    }
456
457    #[test]
458    fn test_version_comparison() {
459        let v1: VersionKind = "1.0.0".parse().unwrap();
460        let v2: VersionKind = "2.0.0".parse().unwrap();
461        assert!(v1 < v2);
462    }
463
464    #[test]
465    fn test_version_display() {
466        let v: VersionKind = "1.2.3".parse().unwrap();
467        assert_eq!(format!("{}", v), "1.2.3");
468    }
469
470    #[test]
471    fn semver_requirement_matches_semver() {
472        let requirement = VersionRequirement::parse("^1.2").unwrap();
473        assert!(requirement.matches(&VersionKind::parse("1.2.3").unwrap()));
474        assert!(!requirement.matches(&VersionKind::parse("2.0.0").unwrap()));
475    }
476
477    #[test]
478    fn partial_requirement_matches_cross_scheme() {
479        let requirement = VersionRequirement::Partial(Partial::parse("2024.01").unwrap());
480        assert!(requirement.matches(&VersionKind::parse("2024.01.15").unwrap()));
481    }
482
483    #[test]
484    fn select_latest_prefers_highest_match() {
485        let versions = vec![
486            VersionKind::parse("1.2.3").unwrap(),
487            VersionKind::parse("1.3.0").unwrap(),
488            VersionKind::parse("1.2.9").unwrap(),
489        ];
490
491        let selected = select_preferred(
492            &versions,
493            &SelectionPolicy {
494                requirement: VersionRequirement::parse("^1.2").unwrap(),
495                preference: VersionPreference::Latest,
496            },
497        )
498        .unwrap();
499
500        assert_eq!(selected.to_string(), "1.3.0");
501    }
502
503    #[test]
504    fn select_highest_stable_skips_prerelease() {
505        let versions = vec![
506            VersionKind::parse("1.2.3-alpha.1").unwrap(),
507            VersionKind::parse("1.2.2").unwrap(),
508        ];
509
510        let selected = select_preferred(
511            &versions,
512            &SelectionPolicy {
513                requirement: VersionRequirement::Any,
514                preference: VersionPreference::HighestStable,
515            },
516        )
517        .unwrap();
518
519        assert_eq!(selected.to_string(), "1.2.2");
520    }
521
522    #[test]
523    fn pinned_preference_returns_exact_version() {
524        let pinned = VersionKind::parse("20.12.1").unwrap();
525        let versions = vec![pinned.clone(), VersionKind::parse("20.11.0").unwrap()];
526
527        let selected = select_preferred(
528            &versions,
529            &SelectionPolicy {
530                requirement: VersionRequirement::Any,
531                preference: VersionPreference::Pinned(pinned),
532            },
533        )
534        .unwrap();
535
536        assert_eq!(selected.to_string(), "20.12.1");
537    }
538
539    #[test]
540    fn semver_requirement_does_not_match_calver_values() {
541        let requirement = VersionRequirement::parse("^1.2").unwrap();
542        assert!(!requirement.matches(&VersionKind::parse("2024.01.15").unwrap()));
543    }
544
545    #[test]
546    fn highest_stable_falls_back_to_latest_when_only_prerelease_exists() {
547        let versions = vec![
548            VersionKind::parse("1.2.3-alpha.1").unwrap(),
549            VersionKind::parse("1.2.3-alpha.2").unwrap(),
550        ];
551
552        let selected = select_preferred(
553            &versions,
554            &SelectionPolicy {
555                requirement: VersionRequirement::Any,
556                preference: VersionPreference::HighestStable,
557            },
558        )
559        .unwrap();
560
561        assert_eq!(selected.to_string(), "1.2.3-alpha.2");
562    }
563
564    #[test]
565    fn lts_preference_prefers_lts_partial_entries() {
566        let versions = vec![
567            VersionKind::parse("22").unwrap(),
568            VersionKind::parse("20lts").unwrap(),
569            VersionKind::parse("18lts").unwrap(),
570        ];
571
572        let selected = select_preferred(
573            &versions,
574            &SelectionPolicy {
575                requirement: VersionRequirement::Any,
576                preference: VersionPreference::Lts,
577            },
578        )
579        .unwrap();
580
581        assert_eq!(selected.to_string(), "20lts");
582    }
583
584    #[test]
585    fn malformed_version_inputs_are_rejected() {
586        for input in ["", "abc", "1.2.3.4"] {
587            assert!(
588                VersionKind::parse(input).is_err(),
589                "input should fail: {input}"
590            );
591        }
592    }
593
594    proptest! {
595        #[test]
596        fn semver_roundtrip_is_stable(major in 0u64..1000, minor in 0u64..1000, patch in 0u64..1000) {
597            let input = format!("{major}.{minor}.{patch}");
598            let parsed = VersionKind::parse(&input).unwrap();
599            prop_assert_eq!(parsed.to_string(), input);
600        }
601
602        #[test]
603        fn semver_ordering_tracks_numeric_components(
604            a_major in 0u64..100,
605            a_minor in 0u64..100,
606            a_patch in 0u64..100,
607            b_major in 0u64..100,
608            b_minor in 0u64..100,
609            b_patch in 0u64..100,
610        ) {
611            let left = VersionKind::parse(&format!("{a_major}.{a_minor}.{a_patch}")).unwrap();
612            let right = VersionKind::parse(&format!("{b_major}.{b_minor}.{b_patch}")).unwrap();
613
614            let tuple_cmp = (a_major, a_minor, a_patch).cmp(&(b_major, b_minor, b_patch));
615            prop_assert_eq!(left.cmp(&right), tuple_cmp);
616        }
617    }
618}