1use std::cmp::Ordering;
4use std::fmt;
5use std::str::FromStr;
6
7#[derive(Clone, Debug, Eq, Hash, PartialEq)]
9pub(crate) struct Version(semver::Version);
10
11#[derive(Clone, Debug, Eq, PartialEq)]
14pub(crate) enum Proposed {
15 Bump(Version),
16 First(Version),
17}
18
19impl Proposed {
20 pub(crate) const fn version(&self) -> &Version {
21 match self {
22 Self::Bump(version) | Self::First(version) => version,
23 }
24 }
25
26 pub(crate) const fn at(&self, version: Version) -> Self {
28 match self {
29 Self::Bump(_) => Self::Bump(version),
30 Self::First(_) => Self::First(version),
31 }
32 }
33}
34
35#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
37pub(crate) enum Bump {
38 Patch,
39 Minor,
40 Major,
41}
42
43#[derive(Clone, Debug)]
46pub(crate) struct TagVersion {
47 version: Version,
48 revision: Option<u64>,
49}
50
51impl Version {
52 pub(crate) fn parse(value: &str) -> Result<Self, semver::Error> {
58 semver::Version::parse(value).map(Self)
59 }
60
61 #[must_use]
64 pub(crate) fn next_patch(&self) -> Option<Self> {
65 let mut version = self.0.clone();
66 version.patch = version.patch.checked_add(1)?;
67 version.pre = semver::Prerelease::EMPTY;
68 version.build = semver::BuildMetadata::EMPTY;
69 Some(Self(version))
70 }
71
72 #[must_use]
74 pub(crate) fn next_minor(&self) -> Option<Self> {
75 let mut version = self.0.clone();
76 version.minor = version.minor.checked_add(1)?;
77 version.patch = 0;
78 version.pre = semver::Prerelease::EMPTY;
79 version.build = semver::BuildMetadata::EMPTY;
80 Some(Self(version))
81 }
82
83 #[must_use]
85 pub(crate) fn next_major(&self) -> Option<Self> {
86 let mut version = self.0.clone();
87 version.major = version.major.checked_add(1)?;
88 version.minor = 0;
89 version.patch = 0;
90 version.pre = semver::Prerelease::EMPTY;
91 version.build = semver::BuildMetadata::EMPTY;
92 Some(Self(version))
93 }
94
95 pub(crate) fn is_unversioned(&self) -> bool {
97 self.0 == semver::Version::new(0, 0, 0)
98 }
99
100 #[must_use]
102 pub(crate) const fn is_initial_development(&self) -> bool {
103 self.0.major == 0
104 }
105
106 #[must_use]
108 pub(crate) fn precedence_cmp(&self, other: &Self) -> Ordering {
109 self.0.cmp_precedence(&other.0)
110 }
111
112 pub(crate) fn satisfies(&self, requirement: &str) -> Result<bool, semver::Error> {
117 semver::VersionReq::parse(requirement).map(|requirement| requirement.matches(&self.0))
118 }
119}
120
121impl Bump {
122 #[must_use]
124 pub(crate) fn apply(self, version: &Version) -> Option<Version> {
125 match self {
126 Self::Patch => version.next_patch(),
127 Self::Minor => version.next_minor(),
128 Self::Major => version.next_major(),
129 }
130 }
131}
132
133impl TagVersion {
134 pub(crate) const fn new(version: Version, revision: Option<u64>) -> Self {
136 Self { version, revision }
137 }
138
139 pub(crate) fn parse(tag: &str, prefix: &str) -> Option<Self> {
142 let rest = tag.strip_prefix(prefix)?;
143 if let Some((base, revision)) = rest.rsplit_once('-')
144 && !base.contains(['-', '+'])
145 && !revision.is_empty()
146 && revision.bytes().all(|byte| byte.is_ascii_digit())
147 {
148 return Some(Self {
149 version: Version::parse(base).ok()?,
150 revision: Some(revision.parse().ok()?),
151 });
152 }
153 Some(Self {
154 version: Version::parse(rest).ok()?,
155 revision: None,
156 })
157 }
158}
159
160impl TagVersion {
161 pub(crate) fn latest<'t>(tags: &'t [String], prefix: &str) -> Option<(Self, &'t str)> {
163 tags.iter()
164 .filter_map(|tag| Some((Self::parse(tag, prefix)?, tag.as_str())))
165 .max_by(|(left, _), (right, _)| left.cmp(right))
166 }
167
168 pub(crate) fn names(&self, version: &Version, revision: Option<u64>) -> bool {
171 self.revision == revision && self.version == *version
172 }
173}
174
175impl PartialEq for TagVersion {
176 fn eq(&self, other: &Self) -> bool {
177 self.cmp(other).is_eq()
178 }
179}
180
181impl Eq for TagVersion {}
182
183impl PartialOrd for TagVersion {
184 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
185 Some(self.cmp(other))
186 }
187}
188
189impl Ord for TagVersion {
190 fn cmp(&self, other: &Self) -> Ordering {
191 self.version
192 .precedence_cmp(&other.version)
193 .then_with(|| self.revision.cmp(&other.revision))
194 }
195}
196
197impl fmt::Display for Bump {
198 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199 f.write_str(match self {
200 Self::Patch => "patch",
201 Self::Minor => "minor",
202 Self::Major => "major",
203 })
204 }
205}
206
207impl FromStr for Version {
208 type Err = semver::Error;
209
210 fn from_str(value: &str) -> Result<Self, Self::Err> {
211 Self::parse(value)
212 }
213}
214
215impl fmt::Display for Version {
216 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217 self.0.fmt(f)
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use std::cmp::Ordering;
224
225 use super::{Bump, TagVersion, Version};
226
227 #[test]
228 fn comparison_uses_semantic_precedence_and_ignores_build_metadata() {
229 let stable = Version::parse("1.2.3").unwrap();
230 let built = Version::parse("1.2.3+build.7").unwrap();
231 let prerelease = Version::parse("1.2.3-rc.1").unwrap();
232
233 assert_eq!(stable.precedence_cmp(&built), Ordering::Equal);
234 assert_eq!(stable.precedence_cmp(&prerelease), Ordering::Greater);
235 }
236
237 #[test]
238 fn patch_bump_makes_prerelease_stable_and_clears_build_metadata() {
239 let prerelease = Version::parse("1.2.3-rc.1+build.7").unwrap();
240 assert_eq!(prerelease.next_patch().unwrap().to_string(), "1.2.4");
241 }
242
243 #[test]
244 fn minor_bump_resets_patch_and_clears_suffixes() {
245 let version = Version::parse("1.2.3-rc.1+build.7").unwrap();
246 assert_eq!(version.next_minor().unwrap().to_string(), "1.3.0");
247 }
248
249 #[test]
250 fn major_bump_resets_minor_and_patch_and_clears_suffixes() {
251 let version = Version::parse("1.2.3-rc.1+build.7").unwrap();
252 assert_eq!(version.next_major().unwrap().to_string(), "2.0.0");
253 }
254
255 #[test]
256 fn bump_apply_selects_the_component() {
257 let version = Version::parse("1.2.3").unwrap();
258 assert_eq!(Bump::Patch.apply(&version).unwrap().to_string(), "1.2.4");
259 assert_eq!(Bump::Minor.apply(&version).unwrap().to_string(), "1.3.0");
260 assert_eq!(Bump::Major.apply(&version).unwrap().to_string(), "2.0.0");
261 }
262
263 #[test]
264 fn initial_development_is_below_one() {
265 assert!(Version::parse("0.9.9").unwrap().is_initial_development());
266 assert!(!Version::parse("1.0.0").unwrap().is_initial_development());
267 }
268
269 #[test]
270 fn minor_and_major_bumps_report_overflow() {
271 let minor = Version::parse("1.18446744073709551615.0").unwrap();
272 assert!(minor.next_minor().is_none());
273 let major = Version::parse("18446744073709551615.0.0").unwrap();
274 assert!(major.next_major().is_none());
275 }
276
277 #[test]
278 fn malformed_requirement_is_an_error() {
279 let version = Version::parse("1.2.3").unwrap();
280 assert!(version.satisfies("^1.2").unwrap());
281 assert!(!version.satisfies("^2").unwrap());
282 assert!(version.satisfies("not a requirement").is_err());
283 }
284
285 #[test]
286 fn patch_bump_reports_overflow() {
287 let maximum = Version::parse("1.2.18446744073709551615").unwrap();
288 assert!(maximum.next_patch().is_none());
289 }
290
291 #[test]
292 fn strict_semver_rejects_leading_zeroes() {
293 assert!(Version::parse("1.02.3").is_err());
294 }
295
296 #[test]
297 fn numeric_suffix_is_a_revision_not_a_prerelease() {
298 let parse = |tag| {
299 TagVersion::parse(tag, "v").map(|parsed| (parsed.version.to_string(), parsed.revision))
300 };
301 assert_eq!(parse("v3.1.0-3"), Some(("3.1.0".to_owned(), Some(3))));
302 assert_eq!(parse("v3.1.0"), Some(("3.1.0".to_owned(), None)));
303 assert_eq!(parse("v3.1.0-rc.1"), Some(("3.1.0-rc.1".to_owned(), None)));
304 assert_eq!(parse("v3.1.0-rc-1"), Some(("3.1.0-rc-1".to_owned(), None)));
305 assert_eq!(parse("v3.1-3"), None);
306 assert_eq!(parse("w3.1.0"), None);
307 assert!(TagVersion::parse("v3.1.0-3", "v") > TagVersion::parse("v3.1.0+build", "v"));
308 }
309
310 #[test]
311 fn latest_ignores_tags_without_a_version_after_the_prefix() {
312 let tags = ["app-v1.0.0", "app-v1.2.0", "app-vnext", "app-v1.10.0"].map(String::from);
313 assert_eq!(
314 TagVersion::latest(&tags, "app-v").map(|(_, tag)| tag),
315 Some("app-v1.10.0")
316 );
317 assert!(TagVersion::latest(&tags, "lib-v").is_none());
318 }
319
320 #[test]
321 fn latest_orders_revisions_after_their_version() {
322 fn latest(tags: &[String]) -> Option<&str> {
323 TagVersion::latest(tags, "v").map(|(_, tag)| tag)
324 }
325 let tags = ["v3.1.0", "v3.1.0-2", "v3.1.1", "v3.1.0-3", "v3.1.0-10"].map(String::from);
326 assert_eq!(latest(&tags), Some("v3.1.1"));
327 assert_eq!(latest(&tags[..2]), Some("v3.1.0-2"));
328 assert_eq!(
329 latest(&[&tags[..2], &tags[3..]].concat()),
330 Some("v3.1.0-10")
331 );
332 }
333}