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/// How far this project stands, as every release it cuts names it
46/// (kernel D18).
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum Maturity {
49    /// Runs end to end, and nothing is promised.
50    PreAlpha,
51    /// Usable.
52    Alpha,
53}
54
55/// The maturity of every release this tree cuts.
56///
57/// The one place it is written: the tag infix, the first line of
58/// `sprawling status` and the documents' `maturity` spans are rendered
59/// from it, so entering alpha moves this constant and nothing else.
60pub const MATURITY: Maturity = Maturity::Alpha;
61
62impl Maturity {
63    /// `pre-alpha` or `alpha`, the word as a sentence writes it.
64    #[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    /// `Pre-alpha` or `Alpha`, the word as a tag and a heading write it.
73    #[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    /// `-Pre-alpha-`, what separates the version from the date in a tag.
82    fn tag_infix(self) -> String {
83        format!("-{}-", self.titled())
84    }
85}
86
87/// What separates them in the version npm carries.
88const NPM_INFIX: &str = "-pre.";
89
90/// The century the two-digit year in a tag belongs to. A pre-alpha that
91/// is still being released in 2100 has larger problems than this
92/// constant, and a tag cannot carry four digits without changing the
93/// name of every release already published.
94const CENTURY: u32 = 2000;
95
96/// One published release of this project, in the form both spellings
97/// decode to.
98///
99/// Field order is the comparison order, and the comparison order is
100/// semver's: version first, then the date inside the pre-release field.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
102pub struct Release {
103    version: Version,
104    /// Four digits. The tag carries two and this holds what they mean,
105    /// so a reader is never asked whether `26` is a year or a week.
106    year: u32,
107    month: u32,
108    day: u32,
109}
110
111/// Where one release stands against the newest one published.
112///
113/// Three states rather than a bool, because the third is reachable: the
114/// workflow publishes the GitHub release before it publishes the npm
115/// packages, so a binary downloaded from the release page is newer than
116/// the registry for as long as that job takes.
117///
118/// Named for what it judges rather than `Standing`, which `wire`
119/// already spends on where a toolkit stands: both cross the same wire,
120/// and the generated client gives one name to one type.
121#[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    /// The newest published release is this one.
126    Current,
127    /// Something newer is published.
128    Behind,
129    /// This release is newer than anything published.
130    Ahead,
131}
132
133impl Release {
134    /// The release a git tag names.
135    ///
136    /// `expected_version` is the version the caller was built from -
137    /// `workspace.package.version` for `xtask`, `CARGO_PKG_VERSION` for
138    /// the binary. Both callers hold the same number, and a tag that
139    /// disagrees with it is refused here rather than producing a release
140    /// whose name and whose contents state different versions.
141    ///
142    /// # Errors
143    /// When the tag is not shaped like a release of this project, when
144    /// its version disagrees with `expected_version`, or when its date
145    /// is not a real day.
146    pub fn from_tag(tag: &str, expected_version: &str) -> Result<Release, AxError> {
147        Release::decode_tag(tag, expected_version, MATURITY)
148    }
149
150    /// The release a tag names, read as a build of `maturity` reads it.
151    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    /// The release an npm version string names.
187    ///
188    /// # Errors
189    /// When the string is not one this project publishes.
190    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    /// Both spellings decode through here, so neither can accept a
207    /// version or a date the other would refuse.
208    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    /// `0.0.5`, the number alone, which is also the version crates.io
248    /// carries.
249    #[must_use]
250    pub fn version(&self) -> String {
251        self.version.to_string()
252    }
253
254    /// `v0.0.9-Alpha-261004`, the tag this release was cut from.
255    #[must_use]
256    pub fn tag(&self) -> String {
257        self.tag_at(MATURITY)
258    }
259
260    /// The tag a build of `maturity` cuts for this release.
261    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    /// `0.0.5-pre.260912`, the version npm carries.
274    #[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    /// `2026-09-12`, the day this release was cut.
284    ///
285    /// The one rendering meant for a person rather than for a registry:
286    /// a 0.0.x version number says almost nothing about how old a
287    /// tree is, and how old it is, is what its reader most needs
288    /// (CHANGELOG.md, opening note).
289    #[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/// Where `mine` stands against the newest release published.
299#[must_use]
300pub fn stands(mine: &Release, newest: &Release) -> ReleaseVerdict {
301    verdict_of(mine.cmp(newest))
302}
303
304/// Where `mine` stands against the newest version crates.io offers,
305/// judged on the version number alone (kernel D35): crates.io takes one
306/// upload per version, so a re-cut that changed only the date never
307/// reaches it, and for a binary cargo installed the same version is
308/// current.
309#[must_use]
310pub fn stands_on_crates(mine: &Release, newest: &Version) -> ReleaseVerdict {
311    verdict_of(mine.version.cmp(newest))
312}
313
314/// The step `stands` and `stands_on_crates` share: an order read as a
315/// verdict.
316fn 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
324/// Two digits off the front of an iterator, as the number they spell.
325fn 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    /// Semver's rule 11 for the one shape this project publishes, written
360    /// out by hand: the three numbers, then the numeric identifier after
361    /// `pre`, each compared as a number.
362    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        /// kernel D35 over every pair of releases: crates.io and npm give
371        /// one verdict when the versions differ, crates.io cannot tell two
372        /// cuts of one version apart, and the npm spelling orders as
373        /// `Release` does.
374        #[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            // Half the pairs are two cuts of one version, which a draw of
381            // two independent releases almost never makes.
382            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    /// The answer a binary cargo installed reads: crates.io names the
401    /// same version, and that is current.
402    #[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    /// Entering alpha moves `MATURITY` and nothing else, so the tag a
411    /// build cuts and the tag it reads have to follow the maturity they
412    /// are given: an alpha build reads an alpha tag as its own and
413    /// refuses a pre-alpha one, as it refuses a tag of another version.
414    #[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    /// The two spellings are one release, so each has to decode to the
440    /// other. A channel that published one and checked the other would
441    /// drift the first time either string changed.
442    #[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        /// The tag a build reads is the tag it cuts: the public reader and
452        /// the public writer, both at this tree's maturity, are one spelling.
453        #[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    /// A maturity is one word, which a sentence writes in lower case and a
461    /// tag or a heading writes with a capital.
462    #[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    /// A version part of several digits reads as its number; only a part
470    /// that opens with a zero and goes on is refused.
471    #[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    /// The check that keeps one release from having two version numbers.
483    #[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    /// A binary reading its own bare `0.0.5` against the registry's
511    /// `0.0.5-pre.260912` is the failure this whole type exists to stop:
512    /// semver ranks a release above its own pre-releases, so the naive
513    /// comparison reports the newest published release as the older one.
514    #[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    /// A larger version outranks a later date, which is what the
524    /// registry does with the same two strings.
525    #[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    /// The registry answering something this workflow never published is
536    /// a refusal, not a release ranked against the running binary.
537    #[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}