1use crate::error::{PachaError, Result};
4use serde::{Deserialize, Serialize};
5use std::cmp::Ordering;
6use std::fmt;
7use std::str::FromStr;
8
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
11pub struct RecipeVersion {
12 pub major: u32,
14 pub minor: u32,
16 pub patch: u32,
18}
19
20impl RecipeVersion {
21 #[must_use]
23 pub fn new(major: u32, minor: u32, patch: u32) -> Self {
24 Self { major, minor, patch }
25 }
26
27 #[must_use]
29 pub fn initial() -> Self {
30 Self::new(1, 0, 0)
31 }
32
33 #[must_use]
35 pub fn bump_major(&self) -> Self {
36 Self::new(self.major + 1, 0, 0)
37 }
38
39 #[must_use]
41 pub fn bump_minor(&self) -> Self {
42 Self::new(self.major, self.minor + 1, 0)
43 }
44
45 #[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}