Skip to main content

pacha/recipe/
version.rs

1//! Recipe versioning.
2
3use crate::error::{PachaError, Result};
4use serde::{Deserialize, Serialize};
5use std::cmp::Ordering;
6use std::fmt;
7use std::str::FromStr;
8
9/// Version for a recipe.
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
11pub struct RecipeVersion {
12    /// Major version.
13    pub major: u32,
14    /// Minor version.
15    pub minor: u32,
16    /// Patch version.
17    pub patch: u32,
18}
19
20impl RecipeVersion {
21    /// Create a new version.
22    #[must_use]
23    pub fn new(major: u32, minor: u32, patch: u32) -> Self {
24        Self { major, minor, patch }
25    }
26
27    /// Create version 1.0.0.
28    #[must_use]
29    pub fn initial() -> Self {
30        Self::new(1, 0, 0)
31    }
32
33    /// Increment major version.
34    #[must_use]
35    pub fn bump_major(&self) -> Self {
36        Self::new(self.major + 1, 0, 0)
37    }
38
39    /// Increment minor version.
40    #[must_use]
41    pub fn bump_minor(&self) -> Self {
42        Self::new(self.major, self.minor + 1, 0)
43    }
44
45    /// Increment patch version.
46    #[must_use]
47    pub fn bump_patch(&self) -> Self {
48        Self::new(self.major, self.minor, self.patch + 1)
49    }
50}
51
52impl Default for RecipeVersion {
53    fn default() -> Self {
54        Self::initial()
55    }
56}
57
58impl fmt::Display for RecipeVersion {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
61    }
62}
63
64impl FromStr for RecipeVersion {
65    type Err = PachaError;
66
67    fn from_str(s: &str) -> Result<Self> {
68        let parts: Vec<&str> = s.split('.').collect();
69        if parts.len() != 3 {
70            return Err(PachaError::InvalidVersion(format!(
71                "expected MAJOR.MINOR.PATCH, got '{s}'"
72            )));
73        }
74
75        let major = parts[0]
76            .parse::<u32>()
77            .map_err(|_| PachaError::InvalidVersion(format!("invalid major version in '{s}'")))?;
78        let minor = parts[1]
79            .parse::<u32>()
80            .map_err(|_| PachaError::InvalidVersion(format!("invalid minor version in '{s}'")))?;
81        let patch = parts[2]
82            .parse::<u32>()
83            .map_err(|_| PachaError::InvalidVersion(format!("invalid patch version in '{s}'")))?;
84
85        Ok(Self { major, minor, patch })
86    }
87}
88
89impl PartialOrd for RecipeVersion {
90    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
91        Some(self.cmp(other))
92    }
93}
94
95impl Ord for RecipeVersion {
96    fn cmp(&self, other: &Self) -> Ordering {
97        match self.major.cmp(&other.major) {
98            Ordering::Equal => {}
99            ord => return ord,
100        }
101        match self.minor.cmp(&other.minor) {
102            Ordering::Equal => {}
103            ord => return ord,
104        }
105        self.patch.cmp(&other.patch)
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use proptest::prelude::*;
113
114    #[test]
115    fn test_version_new() {
116        let v = RecipeVersion::new(1, 2, 3);
117        assert_eq!(v.major, 1);
118        assert_eq!(v.minor, 2);
119        assert_eq!(v.patch, 3);
120    }
121
122    #[test]
123    fn test_version_display() {
124        assert_eq!(RecipeVersion::new(1, 2, 3).to_string(), "1.2.3");
125    }
126
127    #[test]
128    fn test_version_parse() {
129        assert_eq!("1.2.3".parse::<RecipeVersion>().unwrap(), RecipeVersion::new(1, 2, 3));
130    }
131
132    #[test]
133    fn test_version_parse_errors() {
134        assert!("1.2".parse::<RecipeVersion>().is_err());
135        assert!("a.b.c".parse::<RecipeVersion>().is_err());
136    }
137
138    #[test]
139    fn test_version_bump() {
140        let v = RecipeVersion::new(1, 2, 3);
141        assert_eq!(v.bump_major(), RecipeVersion::new(2, 0, 0));
142        assert_eq!(v.bump_minor(), RecipeVersion::new(1, 3, 0));
143        assert_eq!(v.bump_patch(), RecipeVersion::new(1, 2, 4));
144    }
145
146    #[test]
147    fn test_version_ordering() {
148        let v100 = RecipeVersion::new(1, 0, 0);
149        let v110 = RecipeVersion::new(1, 1, 0);
150        let v200 = RecipeVersion::new(2, 0, 0);
151
152        assert!(v100 < v110);
153        assert!(v110 < v200);
154    }
155
156    proptest! {
157        #[test]
158        fn prop_version_roundtrip(major: u32, minor: u32, patch: u32) {
159            let v = RecipeVersion::new(major, minor, patch);
160            let s = v.to_string();
161            let parsed: RecipeVersion = s.parse().unwrap();
162            prop_assert_eq!(v, parsed);
163        }
164    }
165}