1use serde::{Deserialize, Serialize};
39
40use crate::error::{AxCode, AxError};
41
42mod number;
43pub use number::Version;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum Maturity {
49 PreAlpha,
51 Alpha,
53}
54
55pub const MATURITY: Maturity = Maturity::Alpha;
61
62impl Maturity {
63 #[must_use]
65 pub const fn word(self) -> &'static str {
66 match self {
67 Maturity::PreAlpha => "pre-alpha",
68 Maturity::Alpha => "alpha",
69 }
70 }
71
72 #[must_use]
74 pub const fn titled(self) -> &'static str {
75 match self {
76 Maturity::PreAlpha => "Pre-alpha",
77 Maturity::Alpha => "Alpha",
78 }
79 }
80
81 fn tag_infix(self) -> String {
83 format!("-{}-", self.titled())
84 }
85}
86
87const NPM_INFIX: &str = "-pre.";
89
90const CENTURY: u32 = 2000;
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
102pub struct Release {
103 version: Version,
104 year: u32,
107 month: u32,
108 day: u32,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
124pub enum ReleaseVerdict {
125 Current,
127 Behind,
129 Ahead,
131}
132
133impl Release {
134 pub fn from_tag(tag: &str, expected_version: &str) -> Result<Release, AxError> {
147 Release::decode_tag(tag, expected_version, MATURITY)
148 }
149
150 fn decode_tag(
152 tag: &str,
153 expected_version: &str,
154 maturity: Maturity,
155 ) -> Result<Release, AxError> {
156 let refused = |msg: &str| {
157 AxError::failure(AxCode::ConfigInvalid, "read a release tag", tag.to_owned())
158 .with_recovery(msg.to_owned())
159 };
160 let infix = maturity.tag_infix();
161 let body = tag.strip_prefix('v').ok_or_else(|| {
162 refused(&format!(
163 "a release tag begins with `v`, as in `v0.0.5{infix}260912`"
164 ))
165 })?;
166 let (version, date) = body.split_once(infix.as_str()).ok_or_else(|| {
167 refused(&format!(
168 "a release tag of this build reads `v<version>{infix}<YYMMDD>`; \
169 a tag of another shape or another maturity is not one of its releases"
170 ))
171 })?;
172 if version != expected_version {
173 return Err(AxError::failure(
174 AxCode::ConfigInvalid,
175 "read a release tag",
176 tag.to_owned(),
177 )
178 .with_recovery(format!(
179 "the tag says {version} and the build says {expected_version}; \
180 one release cannot have two version numbers"
181 )));
182 }
183 Release::assemble(version, date, tag, "read a release tag")
184 }
185
186 pub fn from_npm_version(text: &str) -> Result<Release, AxError> {
191 let (version, date) = text.split_once(NPM_INFIX).ok_or_else(|| {
192 AxError::failure(
193 AxCode::ConfigInvalid,
194 "read an npm version",
195 text.to_owned(),
196 )
197 .with_recovery(
198 "every release of this project is published as \
199 `<version>-pre.<YYMMDD>`; a version of another shape was \
200 published by something other than this workflow",
201 )
202 })?;
203 Release::assemble(version, date, text, "read an npm version")
204 }
205
206 fn assemble(
209 version: &str,
210 date: &str,
211 subject: &str,
212 action: &'static str,
213 ) -> Result<Release, AxError> {
214 let refused = |msg: String| {
215 AxError::failure(AxCode::ConfigInvalid, action, subject.to_owned()).with_recovery(msg)
216 };
217 let version = Version::parse(version, &refused)?;
218 let mut digits = date.chars();
219 let (Some(year), Some(month), Some(day)) =
220 (pair(&mut digits), pair(&mut digits), pair(&mut digits))
221 else {
222 return Err(refused(format!(
223 "the date reads `{date}`, which is not six digits"
224 )));
225 };
226 if digits.next().is_some() {
227 return Err(refused(format!(
228 "the date reads `{date}`, which is longer than six digits"
229 )));
230 }
231 let year = year
232 .checked_add(CENTURY)
233 .ok_or_else(|| refused(format!("the year in `{date}` does not fit")))?;
234 if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
235 return Err(refused(format!(
236 "the date reads `{date}`, and `{month:02}-{day:02}` is not a day of any month"
237 )));
238 }
239 Ok(Release {
240 version,
241 year,
242 month,
243 day,
244 })
245 }
246
247 #[must_use]
250 pub fn version(&self) -> String {
251 self.version.to_string()
252 }
253
254 #[must_use]
256 pub fn tag(&self) -> String {
257 self.tag_at(MATURITY)
258 }
259
260 fn tag_at(&self, maturity: Maturity) -> String {
262 let Release {
263 year, month, day, ..
264 } = *self;
265 let short = year.checked_sub(CENTURY).unwrap_or(year);
266 format!(
267 "v{}{}{short:02}{month:02}{day:02}",
268 self.version(),
269 maturity.tag_infix()
270 )
271 }
272
273 #[must_use]
275 pub fn npm_version(&self) -> String {
276 let Release {
277 year, month, day, ..
278 } = *self;
279 let short = year.checked_sub(CENTURY).unwrap_or(year);
280 format!("{}{NPM_INFIX}{short:02}{month:02}{day:02}", self.version())
281 }
282
283 #[must_use]
290 pub fn released(&self) -> String {
291 let Release {
292 year, month, day, ..
293 } = *self;
294 format!("{year:04}-{month:02}-{day:02}")
295 }
296}
297
298#[must_use]
300pub fn stands(mine: &Release, newest: &Release) -> ReleaseVerdict {
301 verdict_of(mine.cmp(newest))
302}
303
304#[must_use]
310pub fn stands_on_crates(mine: &Release, newest: &Version) -> ReleaseVerdict {
311 verdict_of(mine.version.cmp(newest))
312}
313
314fn verdict_of(order: std::cmp::Ordering) -> ReleaseVerdict {
317 match order {
318 std::cmp::Ordering::Less => ReleaseVerdict::Behind,
319 std::cmp::Ordering::Equal => ReleaseVerdict::Current,
320 std::cmp::Ordering::Greater => ReleaseVerdict::Ahead,
321 }
322}
323
324fn pair(digits: &mut impl Iterator<Item = char>) -> Option<u32> {
326 let tens = digits.next()?.to_digit(10)?;
327 let ones = digits.next()?.to_digit(10)?;
328 tens.checked_mul(10)?.checked_add(ones)
329}
330
331#[cfg(test)]
332#[allow(
333 clippy::unwrap_used,
334 clippy::expect_used,
335 clippy::panic,
336 reason = "test code"
337)]
338mod tests {
339 use super::{Maturity, Release, ReleaseVerdict, Version, stands, stands_on_crates};
340 use proptest::prelude::*;
341
342 fn any_release() -> impl Strategy<Value = Release> {
343 (
344 0u32..=9,
345 0u32..=9,
346 0u32..=9,
347 0u32..=99,
348 1u32..=12,
349 1u32..=31,
350 )
351 .prop_map(|(major, minor, patch, year, month, day)| {
352 Release::from_npm_version(&format!(
353 "{major}.{minor}.{patch}-pre.{year:02}{month:02}{day:02}"
354 ))
355 .unwrap()
356 })
357 }
358
359 fn semver_key(npm: &str) -> (u32, u32, u32, u32) {
363 let (core, pre) = npm.split_once("-pre.").unwrap();
364 let mut numbers = core.split('.').map(|part| part.parse::<u32>().unwrap());
365 let mut next = || numbers.next().unwrap();
366 (next(), next(), next(), pre.parse().unwrap())
367 }
368
369 proptest! {
370 #[test]
375 fn the_three_spellings_give_one_order(
376 a in any_release(),
377 other in any_release(),
378 same_version in any::<bool>(),
379 ) {
380 let b = if same_version {
383 Release { version: a.version, ..other }
384 } else {
385 other
386 };
387 let crates = Version::from_crates_version(&b.version()).unwrap();
388 if a.version == b.version {
389 prop_assert_eq!(stands_on_crates(&a, &crates), ReleaseVerdict::Current);
390 } else {
391 prop_assert_eq!(stands_on_crates(&a, &crates), stands(&a, &b));
392 }
393 prop_assert_eq!(
394 semver_key(&a.npm_version()).cmp(&semver_key(&b.npm_version())),
395 a.cmp(&b)
396 );
397 }
398 }
399
400 #[test]
403 fn a_crates_answer_of_the_same_release_reads_as_current() {
404 let mine = Release::from_npm_version("0.0.8-pre.261002").unwrap();
405 let newest = Version::from_crates_version("0.0.8").unwrap();
406 assert_eq!(stands_on_crates(&mine, &newest), ReleaseVerdict::Current);
407 assert!(Version::from_crates_version("0.0.8-pre.261002").is_err());
408 }
409
410 #[test]
415 fn an_alpha_build_cuts_and_reads_alpha_tags() {
416 let read = |tag: &str| {
417 Release::decode_tag(tag, "0.1.0", Maturity::Alpha)
418 .map(|cut| cut.tag_at(Maturity::Alpha))
419 .map_err(|err| err.recovery().to_owned())
420 };
421 assert_eq!(
422 (
423 read("v0.1.0-Alpha-270101"),
424 read("v0.1.0-Pre-alpha-270101").is_err()
425 ),
426 (Ok("v0.1.0-Alpha-270101".to_owned()), true)
427 );
428 }
429
430 #[test]
431 fn a_release_tag_becomes_the_semver_npm_accepts() {
432 let cut =
433 Release::decode_tag("v0.0.5-Pre-alpha-260912", "0.0.5", Maturity::PreAlpha).unwrap();
434 assert_eq!(cut.npm_version(), "0.0.5-pre.260912");
435 assert_eq!(cut.version(), "0.0.5");
436 assert_eq!(cut.released(), "2026-09-12");
437 }
438
439 #[test]
443 fn both_spellings_round_trip_through_one_another() {
444 let tag = "v0.0.5-Pre-alpha-260912";
445 let cut = Release::decode_tag(tag, "0.0.5", Maturity::PreAlpha).unwrap();
446 assert_eq!(cut.tag_at(Maturity::PreAlpha), tag);
447 assert_eq!(Release::from_npm_version(&cut.npm_version()).unwrap(), cut);
448 }
449
450 proptest! {
451 #[test]
454 fn a_release_reads_back_from_the_tag_it_cuts(cut in any_release()) {
455 let read = Release::from_tag(&cut.tag(), &cut.version()).unwrap();
456 prop_assert_eq!(read, cut);
457 }
458 }
459
460 #[test]
463 fn a_maturity_is_one_word_in_two_cases() {
464 for maturity in [Maturity::PreAlpha, Maturity::Alpha] {
465 assert_eq!(maturity.word(), maturity.titled().to_lowercase());
466 }
467 }
468
469 #[test]
472 fn a_version_part_of_several_digits_reads_as_its_number() {
473 assert_eq!(
474 Version::from_crates_version("10.12.30")
475 .unwrap()
476 .to_string(),
477 "10.12.30"
478 );
479 assert!(Version::from_crates_version("0.012.0").is_err());
480 }
481
482 #[test]
484 fn a_tag_disagreeing_with_the_build_is_refused() {
485 let err = Release::decode_tag("v0.0.3-Pre-alpha-260911", "0.0.4", Maturity::PreAlpha)
486 .unwrap_err();
487 assert!(err.recovery().contains("two version numbers"), "{err:?}");
488 }
489
490 #[test]
491 fn a_tag_of_another_shape_is_refused_rather_than_guessed_at() {
492 for refused in [
493 "0.0.4",
494 "v0.0.4",
495 "v0.0.4-Pre-alpha-26091",
496 "v0.0.4-Pre-alpha-2609111",
497 "v0.0.4-Pre-alpha-2609xx",
498 "v0.0.4-Pre-alpha-261301",
499 "v0.0.4-Pre-alpha-260900",
500 "v0.0.4.1-Pre-alpha-260911",
501 "v0.00.4-Pre-alpha-260911",
502 ] {
503 assert!(
504 Release::decode_tag(refused, "0.0.4", Maturity::PreAlpha).is_err(),
505 "{refused} was accepted"
506 );
507 }
508 }
509
510 #[test]
515 fn the_date_decides_between_two_releases_of_one_version() {
516 let older = Release::from_npm_version("0.0.5-pre.260911").unwrap();
517 let newer = Release::from_npm_version("0.0.5-pre.260912").unwrap();
518 assert_eq!(stands(&older, &newer), ReleaseVerdict::Behind);
519 assert_eq!(stands(&newer, &older), ReleaseVerdict::Ahead);
520 assert_eq!(stands(&newer, &newer), ReleaseVerdict::Current);
521 }
522
523 #[test]
526 fn the_version_outranks_the_date() {
527 let old_version_late = Release::from_npm_version("0.0.5-pre.261231").unwrap();
528 let new_version_early = Release::from_npm_version("0.0.6-pre.260101").unwrap();
529 assert_eq!(
530 stands(&old_version_late, &new_version_early),
531 ReleaseVerdict::Behind
532 );
533 }
534
535 #[test]
538 fn an_npm_version_this_project_does_not_publish_is_refused() {
539 for refused in ["0.0.5", "0.0.5-alpha.1", "0.0.5-pre.", "latest"] {
540 assert!(
541 Release::from_npm_version(refused).is_err(),
542 "{refused} was accepted"
543 );
544 }
545 }
546}