1use serde::{Deserialize, Serialize};
39
40use crate::error::{AxCode, AxError};
41
42mod number;
43pub use number::Version;
44
45pub const AUR_PACKAGE_NAME: &str = "sprawling-bin";
47
48#[must_use]
50pub fn aur_install_directory() -> String {
51 format!("usr/lib/{AUR_PACKAGE_NAME}")
52}
53
54#[must_use]
56pub fn is_aur_install(exe: &std::path::Path) -> bool {
57 exe.parent()
58 .is_some_and(|directory| directory.ends_with(aur_install_directory()))
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum Maturity {
65 PreAlpha,
67 Alpha,
69}
70
71pub const MATURITY: Maturity = Maturity::Alpha;
77
78impl Maturity {
79 #[must_use]
81 pub const fn word(self) -> &'static str {
82 match self {
83 Maturity::PreAlpha => "pre-alpha",
84 Maturity::Alpha => "alpha",
85 }
86 }
87
88 #[must_use]
90 pub const fn titled(self) -> &'static str {
91 match self {
92 Maturity::PreAlpha => "Pre-alpha",
93 Maturity::Alpha => "Alpha",
94 }
95 }
96
97 fn tag_infix(self) -> String {
99 format!("-{}-", self.titled())
100 }
101}
102
103const NPM_INFIX: &str = "-pre.";
105
106const CENTURY: u32 = 2000;
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
118pub struct Release {
119 version: Version,
120 year: u32,
123 month: u32,
124 day: u32,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "snake_case")]
139#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
140pub enum ReleaseVerdict {
141 Current,
143 Behind,
145 Ahead,
147}
148
149impl Release {
150 pub fn from_tag(tag: &str, expected_version: &str) -> Result<Release, AxError> {
163 Release::decode_tag(tag, expected_version, MATURITY)
164 }
165
166 fn decode_tag(
168 tag: &str,
169 expected_version: &str,
170 maturity: Maturity,
171 ) -> Result<Release, AxError> {
172 let refused = |msg: &str| {
173 AxError::failure(AxCode::ConfigInvalid, "read a release tag", tag.to_owned())
174 .with_recovery(msg.to_owned())
175 };
176 let infix = maturity.tag_infix();
177 let body = tag.strip_prefix('v').ok_or_else(|| {
178 refused(&format!(
179 "a release tag begins with `v`, as in `v0.0.5{infix}260912`"
180 ))
181 })?;
182 let (version, date) = body.split_once(infix.as_str()).ok_or_else(|| {
183 refused(&format!(
184 "a release tag of this build reads `v<version>{infix}<YYMMDD>`; \
185 a tag of another shape or another maturity is not one of its releases"
186 ))
187 })?;
188 if version != expected_version {
189 return Err(AxError::failure(
190 AxCode::ConfigInvalid,
191 "read a release tag",
192 tag.to_owned(),
193 )
194 .with_recovery(format!(
195 "the tag says {version} and the build says {expected_version}; \
196 one release cannot have two version numbers"
197 )));
198 }
199 Release::assemble(version, date, tag, "read a release tag")
200 }
201
202 pub fn from_published_tag(tag: &str) -> Result<Release, AxError> {
207 for maturity in [Maturity::Alpha, Maturity::PreAlpha] {
208 if let Some((version, _)) = tag
209 .strip_prefix('v')
210 .and_then(|body| body.split_once(maturity.tag_infix().as_str()))
211 {
212 return Self::decode_tag(tag, version, maturity);
213 }
214 }
215 Err(
216 AxError::failure(AxCode::ConfigInvalid, "read a published tag", tag)
217 .with_recovery("use a published Alpha or Pre-alpha release tag"),
218 )
219 }
220
221 pub fn from_npm_version(text: &str) -> Result<Release, AxError> {
226 let (version, date) = text.split_once(NPM_INFIX).ok_or_else(|| {
227 AxError::failure(
228 AxCode::ConfigInvalid,
229 "read an npm version",
230 text.to_owned(),
231 )
232 .with_recovery(
233 "every release of this project is published as \
234 `<version>-pre.<YYMMDD>`; a version of another shape was \
235 published by something other than this workflow",
236 )
237 })?;
238 Release::assemble(version, date, text, "read an npm version")
239 }
240
241 fn assemble(
244 version: &str,
245 date: &str,
246 subject: &str,
247 action: &'static str,
248 ) -> Result<Release, AxError> {
249 let refused = |msg: String| {
250 AxError::failure(AxCode::ConfigInvalid, action, subject.to_owned()).with_recovery(msg)
251 };
252 let version = Version::parse(version, &refused)?;
253 let mut digits = date.chars();
254 let (Some(year), Some(month), Some(day)) =
255 (pair(&mut digits), pair(&mut digits), pair(&mut digits))
256 else {
257 return Err(refused(format!(
258 "the date reads `{date}`, which is not six digits"
259 )));
260 };
261 if digits.next().is_some() {
262 return Err(refused(format!(
263 "the date reads `{date}`, which is longer than six digits"
264 )));
265 }
266 let year = year
267 .checked_add(CENTURY)
268 .ok_or_else(|| refused(format!("the year in `{date}` does not fit")))?;
269 if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
270 return Err(refused(format!(
271 "the date reads `{date}`, and `{month:02}-{day:02}` is not a day of any month"
272 )));
273 }
274 Ok(Release {
275 version,
276 year,
277 month,
278 day,
279 })
280 }
281
282 #[must_use]
285 pub fn version(&self) -> String {
286 self.version.to_string()
287 }
288
289 #[must_use]
291 pub fn tag(&self) -> String {
292 self.tag_at(MATURITY)
293 }
294
295 fn tag_at(&self, maturity: Maturity) -> String {
297 let Release {
298 year, month, day, ..
299 } = *self;
300 let short = year.checked_sub(CENTURY).unwrap_or(year);
301 format!(
302 "v{}{}{short:02}{month:02}{day:02}",
303 self.version(),
304 maturity.tag_infix()
305 )
306 }
307
308 #[must_use]
310 pub fn npm_version(&self) -> String {
311 let Release {
312 year, month, day, ..
313 } = *self;
314 let short = year.checked_sub(CENTURY).unwrap_or(year);
315 format!("{}{NPM_INFIX}{short:02}{month:02}{day:02}", self.version())
316 }
317
318 #[must_use]
325 pub fn released(&self) -> String {
326 let Release {
327 year, month, day, ..
328 } = *self;
329 format!("{year:04}-{month:02}-{day:02}")
330 }
331}
332
333#[must_use]
335pub fn stands(mine: &Release, newest: &Release) -> ReleaseVerdict {
336 verdict_of(mine.cmp(newest))
337}
338
339#[must_use]
345pub fn stands_on_crates(mine: &Release, newest: &Version) -> ReleaseVerdict {
346 stands_on_versions(&mine.version, newest)
347}
348
349#[must_use]
351pub fn stands_on_versions(mine: &Version, newest: &Version) -> ReleaseVerdict {
352 verdict_of(mine.cmp(newest))
353}
354
355fn verdict_of(order: std::cmp::Ordering) -> ReleaseVerdict {
358 match order {
359 std::cmp::Ordering::Less => ReleaseVerdict::Behind,
360 std::cmp::Ordering::Equal => ReleaseVerdict::Current,
361 std::cmp::Ordering::Greater => ReleaseVerdict::Ahead,
362 }
363}
364
365fn pair(digits: &mut impl Iterator<Item = char>) -> Option<u32> {
367 let tens = digits.next()?.to_digit(10)?;
368 let ones = digits.next()?.to_digit(10)?;
369 tens.checked_mul(10)?.checked_add(ones)
370}
371
372#[cfg(test)]
373#[allow(
374 clippy::unwrap_used,
375 clippy::expect_used,
376 clippy::panic,
377 reason = "test code"
378)]
379mod tests {
380 use super::{Maturity, Release, ReleaseVerdict, Version, stands, stands_on_crates};
381 use proptest::prelude::*;
382
383 fn any_release() -> impl Strategy<Value = Release> {
384 (
385 0u32..=9,
386 0u32..=9,
387 0u32..=9,
388 0u32..=99,
389 1u32..=12,
390 1u32..=31,
391 )
392 .prop_map(|(major, minor, patch, year, month, day)| {
393 Release::from_npm_version(&format!(
394 "{major}.{minor}.{patch}-pre.{year:02}{month:02}{day:02}"
395 ))
396 .unwrap()
397 })
398 }
399
400 fn semver_key(npm: &str) -> (u32, u32, u32, u32) {
404 let (core, pre) = npm.split_once("-pre.").unwrap();
405 let mut numbers = core.split('.').map(|part| part.parse::<u32>().unwrap());
406 let mut next = || numbers.next().unwrap();
407 (next(), next(), next(), pre.parse().unwrap())
408 }
409
410 proptest! {
411 #[test]
416 fn the_three_spellings_give_one_order(
417 a in any_release(),
418 other in any_release(),
419 same_version in any::<bool>(),
420 ) {
421 let b = if same_version {
424 Release { version: a.version, ..other }
425 } else {
426 other
427 };
428 let crates = Version::from_crates_version(&b.version()).unwrap();
429 if a.version == b.version {
430 prop_assert_eq!(stands_on_crates(&a, &crates), ReleaseVerdict::Current);
431 } else {
432 prop_assert_eq!(stands_on_crates(&a, &crates), stands(&a, &b));
433 }
434 prop_assert_eq!(
435 semver_key(&a.npm_version()).cmp(&semver_key(&b.npm_version())),
436 a.cmp(&b)
437 );
438 }
439 }
440
441 #[test]
444 fn a_crates_answer_of_the_same_release_reads_as_current() {
445 let mine = Release::from_npm_version("0.0.8-pre.261002").unwrap();
446 let newest = Version::from_crates_version("0.0.8").unwrap();
447 assert_eq!(stands_on_crates(&mine, &newest), ReleaseVerdict::Current);
448 assert!(Version::from_crates_version("0.0.8-pre.261002").is_err());
449 }
450
451 #[test]
456 fn an_alpha_build_cuts_and_reads_alpha_tags() {
457 let read = |tag: &str| {
458 Release::decode_tag(tag, "0.1.0", Maturity::Alpha)
459 .map(|cut| cut.tag_at(Maturity::Alpha))
460 .map_err(|err| err.recovery().to_owned())
461 };
462 assert_eq!(
463 (
464 read("v0.1.0-Alpha-270101"),
465 read("v0.1.0-Pre-alpha-270101").is_err()
466 ),
467 (Ok("v0.1.0-Alpha-270101".to_owned()), true)
468 );
469 }
470
471 #[test]
472 fn a_release_tag_becomes_the_semver_npm_accepts() {
473 let cut =
474 Release::decode_tag("v0.0.5-Pre-alpha-260912", "0.0.5", Maturity::PreAlpha).unwrap();
475 assert_eq!(cut.npm_version(), "0.0.5-pre.260912");
476 assert_eq!(cut.version(), "0.0.5");
477 assert_eq!(cut.released(), "2026-09-12");
478 }
479
480 #[test]
484 fn both_spellings_round_trip_through_one_another() {
485 let tag = "v0.0.5-Pre-alpha-260912";
486 let cut = Release::decode_tag(tag, "0.0.5", Maturity::PreAlpha).unwrap();
487 assert_eq!(cut.tag_at(Maturity::PreAlpha), tag);
488 assert_eq!(Release::from_npm_version(&cut.npm_version()).unwrap(), cut);
489 }
490
491 proptest! {
492 #[test]
495 fn a_release_reads_back_from_the_tag_it_cuts(cut in any_release()) {
496 let read = Release::from_tag(&cut.tag(), &cut.version()).unwrap();
497 prop_assert_eq!(read, cut);
498 }
499 }
500
501 #[test]
504 fn a_maturity_is_one_word_in_two_cases() {
505 for maturity in [Maturity::PreAlpha, Maturity::Alpha] {
506 assert_eq!(maturity.word(), maturity.titled().to_lowercase());
507 }
508 }
509
510 #[test]
513 fn a_version_part_of_several_digits_reads_as_its_number() {
514 assert_eq!(
515 Version::from_crates_version("10.12.30")
516 .unwrap()
517 .to_string(),
518 "10.12.30"
519 );
520 assert!(Version::from_crates_version("0.012.0").is_err());
521 }
522
523 #[test]
525 fn a_tag_disagreeing_with_the_build_is_refused() {
526 let err = Release::decode_tag("v0.0.3-Pre-alpha-260911", "0.0.4", Maturity::PreAlpha)
527 .unwrap_err();
528 assert!(err.recovery().contains("two version numbers"), "{err:?}");
529 }
530
531 #[test]
532 fn a_tag_of_another_shape_is_refused_rather_than_guessed_at() {
533 for refused in [
534 "0.0.4",
535 "v0.0.4",
536 "v0.0.4-Pre-alpha-26091",
537 "v0.0.4-Pre-alpha-2609111",
538 "v0.0.4-Pre-alpha-2609xx",
539 "v0.0.4-Pre-alpha-261301",
540 "v0.0.4-Pre-alpha-260900",
541 "v0.0.4.1-Pre-alpha-260911",
542 "v0.00.4-Pre-alpha-260911",
543 ] {
544 assert!(
545 Release::decode_tag(refused, "0.0.4", Maturity::PreAlpha).is_err(),
546 "{refused} was accepted"
547 );
548 }
549 }
550
551 #[test]
556 fn the_date_decides_between_two_releases_of_one_version() {
557 let older = Release::from_npm_version("0.0.5-pre.260911").unwrap();
558 let newer = Release::from_npm_version("0.0.5-pre.260912").unwrap();
559 assert_eq!(stands(&older, &newer), ReleaseVerdict::Behind);
560 assert_eq!(stands(&newer, &older), ReleaseVerdict::Ahead);
561 assert_eq!(stands(&newer, &newer), ReleaseVerdict::Current);
562 }
563
564 #[test]
567 fn the_version_outranks_the_date() {
568 let old_version_late = Release::from_npm_version("0.0.5-pre.261231").unwrap();
569 let new_version_early = Release::from_npm_version("0.0.6-pre.260101").unwrap();
570 assert_eq!(
571 stands(&old_version_late, &new_version_early),
572 ReleaseVerdict::Behind
573 );
574 }
575
576 #[test]
579 fn an_npm_version_this_project_does_not_publish_is_refused() {
580 for refused in ["0.0.5", "0.0.5-alpha.1", "0.0.5-pre.", "latest"] {
581 assert!(
582 Release::from_npm_version(refused).is_err(),
583 "{refused} was accepted"
584 );
585 }
586 }
587}