Skip to main content

kernel/
release.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Which release a binary is, and how two releases order.
7//! The part `crates/kernel/spec/Release.lean` specifies this module.
8//!
9//! **One release is spelled two ways, and this is the only place that
10//! knows both.** A pre-alpha git tag reads `v0.0.5-Pre-alpha-260912`; npm accepts
11//! semver and nothing else, so the same release reaches the registry as
12//! `0.0.5-pre.260912`. `xtask channel` converts in order to publish, and
13//! a running binary converts in order to ask the registry which release
14//! is newest. Either conversion written a second time would be a second
15//! answer to "which release is this".
16//!
17//! **The order is semver's own.** Putting the date in the pre-release
18//! field ranks `0.0.5-pre.260912` above `0.0.5-pre.260911` and below a
19//! plain `0.0.5`, because semver compares dot-separated numeric
20//! identifiers numerically. Deriving `Ord` over these fields in
21//! declaration order reproduces that rule, so this crate and the
22//! registry sort one pair of releases the same way. A binary that
23//! compared its own bare `0.0.5` against `0.0.5-pre.260912` would read
24//! itself as the newer of the two, which is the reason the date has to
25//! travel with the version rather than beside it.
26//!
27//! **The maturity is written once.** A tag carries it between the
28//! version and the date, `-Alpha-` today; [`MATURITY`] is the one
29//! place it is decided, and the tag, the first line of `sprawling status`
30//! and the documents render it (kernel D18). The npm spelling keeps
31//! `-pre.` whatever the maturity, because semver sorts pre-release
32//! identifiers as text and `alpha` sorts before `pre`.
33//!
34//! No clock and no socket. What the registry currently offers is the
35//! caller's to fetch; this judges only what it is handed
36//! (ARCHITECTURE.md paragraph 1).
37
38use serde::{Deserialize, Serialize};
39
40use crate::error::{AxCode, AxError};
41
42mod number;
43pub use number::Version;
44
45/// The package identity shared by the AUR definition and installed-origin reader.
46pub const AUR_PACKAGE_NAME: &str = "sprawling-bin";
47
48/// The POSIX directory relative to the installation root, including bundled resources.
49#[must_use]
50pub fn aur_install_directory() -> String {
51    format!("usr/lib/{AUR_PACKAGE_NAME}")
52}
53
54/// Whether the executable occupies the directory emitted by the AUR packager.
55#[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/// How far this project stands, as every release it cuts names it
62/// (kernel D18).
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum Maturity {
65    /// Runs end to end, and nothing is promised.
66    PreAlpha,
67    /// Usable.
68    Alpha,
69}
70
71/// The maturity of every release this tree cuts.
72///
73/// The one place it is written: the tag infix, the first line of
74/// `sprawling status` and the documents' `maturity` spans are rendered
75/// from it, so entering alpha moves this constant and nothing else.
76pub const MATURITY: Maturity = Maturity::Alpha;
77
78impl Maturity {
79    /// `pre-alpha` or `alpha`, the word as a sentence writes it.
80    #[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    /// `Pre-alpha` or `Alpha`, the word as a tag and a heading write it.
89    #[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    /// `-Pre-alpha-`, what separates the version from the date in a tag.
98    fn tag_infix(self) -> String {
99        format!("-{}-", self.titled())
100    }
101}
102
103/// What separates them in the version npm carries.
104const NPM_INFIX: &str = "-pre.";
105
106/// The century the two-digit year in a tag belongs to. A pre-alpha that
107/// is still being released in 2100 has larger problems than this
108/// constant, and a tag cannot carry four digits without changing the
109/// name of every release already published.
110const CENTURY: u32 = 2000;
111
112/// One published release of this project, in the form both spellings
113/// decode to.
114///
115/// Field order is the comparison order, and the comparison order is
116/// semver's: version first, then the date inside the pre-release field.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
118pub struct Release {
119    version: Version,
120    /// Four digits. The tag carries two and this holds what they mean,
121    /// so a reader is never asked whether `26` is a year or a week.
122    year: u32,
123    month: u32,
124    day: u32,
125}
126
127/// Where one release stands against the newest one published.
128///
129/// Three states rather than a bool, because the third is reachable: the
130/// workflow publishes the GitHub release before it publishes the npm
131/// packages, so a binary downloaded from the release page is newer than
132/// the registry for as long as that job takes.
133///
134/// Named for what it judges rather than `Standing`, which `wire`
135/// already spends on where a toolkit stands: both cross the same wire,
136/// and the generated client gives one name to one type.
137#[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    /// The newest published release is this one.
142    Current,
143    /// Something newer is published.
144    Behind,
145    /// This release is newer than anything published.
146    Ahead,
147}
148
149impl Release {
150    /// The release a git tag names.
151    ///
152    /// `expected_version` is the version the caller was built from -
153    /// `workspace.package.version` for `xtask`, `CARGO_PKG_VERSION` for
154    /// the binary. Both callers hold the same number, and a tag that
155    /// disagrees with it is refused here rather than producing a release
156    /// whose name and whose contents state different versions.
157    ///
158    /// # Errors
159    /// When the tag is not shaped like a release of this project, when
160    /// its version disagrees with `expected_version`, or when its date
161    /// is not a real day.
162    pub fn from_tag(tag: &str, expected_version: &str) -> Result<Release, AxError> {
163        Release::decode_tag(tag, expected_version, MATURITY)
164    }
165
166    /// The release a tag names, read as a build of `maturity` reads it.
167    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    /// A published tag, including releases cut before the current maturity.
203    ///
204    /// # Errors
205    /// When neither supported maturity decodes the tag's version and date.
206    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    /// The release an npm version string names.
222    ///
223    /// # Errors
224    /// When the string is not one this project publishes.
225    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    /// Both spellings decode through here, so neither can accept a
242    /// version or a date the other would refuse.
243    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    /// `0.0.5`, the number alone, which is also the version crates.io
283    /// carries.
284    #[must_use]
285    pub fn version(&self) -> String {
286        self.version.to_string()
287    }
288
289    /// `v0.0.9-Alpha-261004`, the tag this release was cut from.
290    #[must_use]
291    pub fn tag(&self) -> String {
292        self.tag_at(MATURITY)
293    }
294
295    /// The tag a build of `maturity` cuts for this release.
296    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    /// `0.0.5-pre.260912`, the version npm carries.
309    #[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    /// `2026-09-12`, the day this release was cut.
319    ///
320    /// The one rendering meant for a person rather than for a registry:
321    /// a 0.0.x version number says almost nothing about how old a
322    /// tree is, and how old it is, is what its reader most needs
323    /// (CHANGELOG.md, opening note).
324    #[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/// Where `mine` stands against the newest release published.
334#[must_use]
335pub fn stands(mine: &Release, newest: &Release) -> ReleaseVerdict {
336    verdict_of(mine.cmp(newest))
337}
338
339/// Where `mine` stands against the newest version crates.io offers,
340/// judged on the version number alone (kernel D35): crates.io takes one
341/// upload per version, so a re-cut that changed only the date never
342/// reaches it, and for a binary cargo installed the same version is
343/// current.
344#[must_use]
345pub fn stands_on_crates(mine: &Release, newest: &Version) -> ReleaseVerdict {
346    stands_on_versions(&mine.version, newest)
347}
348
349/// Compares Cargo versions when a registry install was compiled without a release tag.
350#[must_use]
351pub fn stands_on_versions(mine: &Version, newest: &Version) -> ReleaseVerdict {
352    verdict_of(mine.cmp(newest))
353}
354
355/// The step `stands` and `stands_on_crates` share: an order read as a
356/// verdict.
357fn 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
365/// Two digits off the front of an iterator, as the number they spell.
366fn 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    /// Semver's rule 11 for the one shape this project publishes, written
401    /// out by hand: the three numbers, then the numeric identifier after
402    /// `pre`, each compared as a number.
403    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        /// kernel D35 over every pair of releases: crates.io and npm give
412        /// one verdict when the versions differ, crates.io cannot tell two
413        /// cuts of one version apart, and the npm spelling orders as
414        /// `Release` does.
415        #[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            // Half the pairs are two cuts of one version, which a draw of
422            // two independent releases almost never makes.
423            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    /// The answer a binary cargo installed reads: crates.io names the
442    /// same version, and that is current.
443    #[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    /// Entering alpha moves `MATURITY` and nothing else, so the tag a
452    /// build cuts and the tag it reads have to follow the maturity they
453    /// are given: an alpha build reads an alpha tag as its own and
454    /// refuses a pre-alpha one, as it refuses a tag of another version.
455    #[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    /// The two spellings are one release, so each has to decode to the
481    /// other. A channel that published one and checked the other would
482    /// drift the first time either string changed.
483    #[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        /// The tag a build reads is the tag it cuts: the public reader and
493        /// the public writer, both at this tree's maturity, are one spelling.
494        #[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    /// A maturity is one word, which a sentence writes in lower case and a
502    /// tag or a heading writes with a capital.
503    #[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    /// A version part of several digits reads as its number; only a part
511    /// that opens with a zero and goes on is refused.
512    #[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    /// The check that keeps one release from having two version numbers.
524    #[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    /// A binary reading its own bare `0.0.5` against the registry's
552    /// `0.0.5-pre.260912` is the failure this whole type exists to stop:
553    /// semver ranks a release above its own pre-releases, so the naive
554    /// comparison reports the newest published release as the older one.
555    #[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    /// A larger version outranks a later date, which is what the
565    /// registry does with the same two strings.
566    #[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    /// The registry answering something this workflow never published is
577    /// a refusal, not a release ranked against the running binary.
578    #[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}