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 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, `-Pre-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
42/// How far this project stands, as every release it cuts names it
43/// (kernel D18).
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45pub enum Maturity {
46 /// Runs end to end, and nothing is promised.
47 PreAlpha,
48 /// Usable.
49 Alpha,
50}
51
52/// The maturity of every release this tree cuts.
53///
54/// The one place it is written: the tag infix, the first line of
55/// `sprawling status` and the documents' `maturity` spans are rendered
56/// from it, so entering alpha moves this constant and nothing else.
57pub const MATURITY: Maturity = Maturity::PreAlpha;
58
59impl Maturity {
60 /// `pre-alpha`, the word as a sentence writes it.
61 #[must_use]
62 pub const fn word(self) -> &'static str {
63 match self {
64 Maturity::PreAlpha => "pre-alpha",
65 Maturity::Alpha => "alpha",
66 }
67 }
68
69 /// `Pre-alpha`, the word as a tag and a heading write it.
70 #[must_use]
71 pub const fn titled(self) -> &'static str {
72 match self {
73 Maturity::PreAlpha => "Pre-alpha",
74 Maturity::Alpha => "Alpha",
75 }
76 }
77
78 /// `-Pre-alpha-`, what separates the version from the date in a tag.
79 fn tag_infix(self) -> String {
80 format!("-{}-", self.titled())
81 }
82}
83
84/// What separates them in the version npm carries.
85const NPM_INFIX: &str = "-pre.";
86
87/// The century the two-digit year in a tag belongs to. A pre-alpha that
88/// is still being released in 2100 has larger problems than this
89/// constant, and a tag cannot carry four digits without changing the
90/// name of every release already published.
91const CENTURY: u32 = 2000;
92
93/// One published release of this project, in the form both spellings
94/// decode to.
95///
96/// Field order is the comparison order, and the comparison order is
97/// semver's: version first, then the date inside the pre-release field.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
99pub struct Release {
100 major: u32,
101 minor: u32,
102 patch: u32,
103 /// Four digits. The tag carries two and this holds what they mean,
104 /// so a reader is never asked whether `26` is a year or a week.
105 year: u32,
106 month: u32,
107 day: u32,
108}
109
110/// Where one release stands against the newest one published.
111///
112/// Three states rather than a bool, because the third is reachable: the
113/// workflow publishes the GitHub release before it publishes the npm
114/// packages, so a binary downloaded from the release page is newer than
115/// the registry for as long as that job takes.
116///
117/// Named for what it judges rather than `Standing`, which `wire`
118/// already spends on where a toolkit stands: both cross the same wire,
119/// and the generated client gives one name to one type.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "snake_case")]
122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
123pub enum ReleaseVerdict {
124 /// The newest published release is this one.
125 Current,
126 /// Something newer is published.
127 Behind,
128 /// This release is newer than anything published.
129 Ahead,
130}
131
132impl Release {
133 /// The release a git tag names.
134 ///
135 /// `expected_version` is the version the caller was built from -
136 /// `workspace.package.version` for `xtask`, `CARGO_PKG_VERSION` for
137 /// the binary. Both callers hold the same number, and a tag that
138 /// disagrees with it is refused here rather than producing a release
139 /// whose name and whose contents state different versions.
140 ///
141 /// # Errors
142 /// When the tag is not shaped like a release of this project, when
143 /// its version disagrees with `expected_version`, or when its date
144 /// is not a real day.
145 pub fn from_tag(tag: &str, expected_version: &str) -> Result<Release, AxError> {
146 Release::decode_tag(tag, expected_version, MATURITY)
147 }
148
149 /// The release a tag names, read as a build of `maturity` reads it.
150 fn decode_tag(
151 tag: &str,
152 expected_version: &str,
153 maturity: Maturity,
154 ) -> Result<Release, AxError> {
155 let refused = |msg: &str| {
156 AxError::failure(AxCode::ConfigInvalid, "read a release tag", tag.to_owned())
157 .with_recovery(msg.to_owned())
158 };
159 let infix = maturity.tag_infix();
160 let body = tag.strip_prefix('v').ok_or_else(|| {
161 refused(&format!(
162 "a release tag begins with `v`, as in `v0.0.5{infix}260912`"
163 ))
164 })?;
165 let (version, date) = body.split_once(infix.as_str()).ok_or_else(|| {
166 refused(&format!(
167 "a release tag of this build reads `v<version>{infix}<YYMMDD>`; \
168 a tag of another shape or another maturity is not one of its releases"
169 ))
170 })?;
171 if version != expected_version {
172 return Err(AxError::failure(
173 AxCode::ConfigInvalid,
174 "read a release tag",
175 tag.to_owned(),
176 )
177 .with_recovery(format!(
178 "the tag says {version} and the build says {expected_version}; \
179 one release cannot have two version numbers"
180 )));
181 }
182 Release::assemble(version, date, tag, "read a release tag")
183 }
184
185 /// The release an npm version string names.
186 ///
187 /// # Errors
188 /// When the string is not one this project publishes.
189 pub fn from_npm_version(text: &str) -> Result<Release, AxError> {
190 let (version, date) = text.split_once(NPM_INFIX).ok_or_else(|| {
191 AxError::failure(
192 AxCode::ConfigInvalid,
193 "read an npm version",
194 text.to_owned(),
195 )
196 .with_recovery(
197 "every release of this project is published as \
198 `<version>-pre.<YYMMDD>`; a version of another shape was \
199 published by something other than this workflow",
200 )
201 })?;
202 Release::assemble(version, date, text, "read an npm version")
203 }
204
205 /// Both spellings decode through here, so neither can accept a
206 /// version or a date the other would refuse.
207 fn assemble(
208 version: &str,
209 date: &str,
210 subject: &str,
211 action: &'static str,
212 ) -> Result<Release, AxError> {
213 let refused = |msg: String| {
214 AxError::failure(AxCode::ConfigInvalid, action, subject.to_owned()).with_recovery(msg)
215 };
216 let mut parts = version.split('.');
217 let mut next_number = || parts.next().and_then(number);
218 let (Some(major), Some(minor), Some(patch)) = (next_number(), next_number(), next_number())
219 else {
220 return Err(refused(format!(
221 "`{version}` is not three dot-separated numbers, as in `0.0.5`"
222 )));
223 };
224 if parts.next().is_some() {
225 return Err(refused(format!(
226 "`{version}` carries more than the three numbers a version of \
227 this project has"
228 )));
229 }
230 let mut digits = date.chars();
231 let (Some(year), Some(month), Some(day)) =
232 (pair(&mut digits), pair(&mut digits), pair(&mut digits))
233 else {
234 return Err(refused(format!(
235 "the date reads `{date}`, which is not six digits"
236 )));
237 };
238 if digits.next().is_some() {
239 return Err(refused(format!(
240 "the date reads `{date}`, which is longer than six digits"
241 )));
242 }
243 let year = year
244 .checked_add(CENTURY)
245 .ok_or_else(|| refused(format!("the year in `{date}` does not fit")))?;
246 if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
247 return Err(refused(format!(
248 "the date reads `{date}`, and `{month:02}-{day:02}` is not a day of any month"
249 )));
250 }
251 Ok(Release {
252 major,
253 minor,
254 patch,
255 year,
256 month,
257 day,
258 })
259 }
260
261 /// `0.0.5`, the number alone.
262 #[must_use]
263 pub fn version(&self) -> String {
264 let Release {
265 major,
266 minor,
267 patch,
268 ..
269 } = *self;
270 format!("{major}.{minor}.{patch}")
271 }
272
273 /// `v0.0.5-Pre-alpha-260912`, the tag this release was cut from.
274 #[must_use]
275 pub fn tag(&self) -> String {
276 self.tag_at(MATURITY)
277 }
278
279 /// The tag a build of `maturity` cuts for this release.
280 fn tag_at(&self, maturity: Maturity) -> String {
281 let Release {
282 year, month, day, ..
283 } = *self;
284 let short = year.checked_sub(CENTURY).unwrap_or(year);
285 format!(
286 "v{}{}{short:02}{month:02}{day:02}",
287 self.version(),
288 maturity.tag_infix()
289 )
290 }
291
292 /// `0.0.5-pre.260912`, the version npm carries.
293 #[must_use]
294 pub fn npm_version(&self) -> String {
295 let Release {
296 year, month, day, ..
297 } = *self;
298 let short = year.checked_sub(CENTURY).unwrap_or(year);
299 format!("{}{NPM_INFIX}{short:02}{month:02}{day:02}", self.version())
300 }
301
302 /// `2026-09-12`, the day this release was cut.
303 ///
304 /// The one rendering meant for a person rather than for a registry:
305 /// a pre-alpha version number says almost nothing about how old a
306 /// tree is, and how old it is, is what its reader most needs
307 /// (CHANGELOG.md, opening note).
308 #[must_use]
309 pub fn released(&self) -> String {
310 let Release {
311 year, month, day, ..
312 } = *self;
313 format!("{year:04}-{month:02}-{day:02}")
314 }
315}
316
317/// Where `mine` stands against the newest release published.
318#[must_use]
319pub fn stands(mine: &Release, newest: &Release) -> ReleaseVerdict {
320 match mine.cmp(newest) {
321 std::cmp::Ordering::Less => ReleaseVerdict::Behind,
322 std::cmp::Ordering::Equal => ReleaseVerdict::Current,
323 std::cmp::Ordering::Greater => ReleaseVerdict::Ahead,
324 }
325}
326
327/// One version component: digits only, and no leading zero that would
328/// make two spellings of one number.
329///
330/// Without the second rule `0.00.5` and `0.0.5` decode alike, and the
331/// tag that round-trips back out is not the tag that came in.
332fn number(text: &str) -> Option<u32> {
333 let mut chars = text.chars();
334 let first = chars.next()?;
335 if !first.is_ascii_digit() {
336 return None;
337 }
338 if first == '0' && chars.next().is_some() {
339 return None;
340 }
341 text.parse().ok()
342}
343
344/// Two digits off the front of an iterator, as the number they spell.
345fn pair(digits: &mut impl Iterator<Item = char>) -> Option<u32> {
346 let tens = digits.next()?.to_digit(10)?;
347 let ones = digits.next()?.to_digit(10)?;
348 tens.checked_mul(10)?.checked_add(ones)
349}
350
351#[cfg(test)]
352#[allow(
353 clippy::unwrap_used,
354 clippy::expect_used,
355 clippy::panic,
356 reason = "test code"
357)]
358mod tests {
359 use super::{Maturity, Release, ReleaseVerdict, stands};
360
361 /// Entering alpha moves `MATURITY` and nothing else, so the tag a
362 /// build cuts and the tag it reads have to follow the maturity they
363 /// are given: an alpha build reads an alpha tag as its own and
364 /// refuses a pre-alpha one, as it refuses a tag of another version.
365 #[test]
366 fn an_alpha_build_cuts_and_reads_alpha_tags() {
367 let read = |tag: &str| {
368 Release::decode_tag(tag, "0.1.0", Maturity::Alpha)
369 .map(|cut| cut.tag_at(Maturity::Alpha))
370 .map_err(|err| err.recovery().to_owned())
371 };
372 assert_eq!(
373 (
374 read("v0.1.0-Alpha-270101"),
375 read("v0.1.0-Pre-alpha-270101").is_err()
376 ),
377 (Ok("v0.1.0-Alpha-270101".to_owned()), true)
378 );
379 }
380
381 #[test]
382 fn a_release_tag_becomes_the_semver_npm_accepts() {
383 let cut =
384 Release::decode_tag("v0.0.5-Pre-alpha-260912", "0.0.5", Maturity::PreAlpha).unwrap();
385 assert_eq!(cut.npm_version(), "0.0.5-pre.260912");
386 assert_eq!(cut.version(), "0.0.5");
387 assert_eq!(cut.released(), "2026-09-12");
388 }
389
390 /// The two spellings are one release, so each has to decode to the
391 /// other. A channel that published one and checked the other would
392 /// drift the first time either string changed.
393 #[test]
394 fn both_spellings_round_trip_through_one_another() {
395 let tag = "v0.0.5-Pre-alpha-260912";
396 let cut = Release::decode_tag(tag, "0.0.5", Maturity::PreAlpha).unwrap();
397 assert_eq!(cut.tag_at(Maturity::PreAlpha), tag);
398 assert_eq!(Release::from_npm_version(&cut.npm_version()).unwrap(), cut);
399 }
400
401 /// The check that keeps one release from having two version numbers.
402 #[test]
403 fn a_tag_disagreeing_with_the_build_is_refused() {
404 let err = Release::decode_tag("v0.0.3-Pre-alpha-260911", "0.0.4", Maturity::PreAlpha)
405 .unwrap_err();
406 assert!(err.recovery().contains("two version numbers"), "{err:?}");
407 }
408
409 #[test]
410 fn a_tag_of_another_shape_is_refused_rather_than_guessed_at() {
411 for refused in [
412 "0.0.4",
413 "v0.0.4",
414 "v0.0.4-Pre-alpha-26091",
415 "v0.0.4-Pre-alpha-2609111",
416 "v0.0.4-Pre-alpha-2609xx",
417 "v0.0.4-Pre-alpha-261301",
418 "v0.0.4-Pre-alpha-260900",
419 "v0.0.4.1-Pre-alpha-260911",
420 "v0.00.4-Pre-alpha-260911",
421 ] {
422 assert!(
423 Release::decode_tag(refused, "0.0.4", Maturity::PreAlpha).is_err(),
424 "{refused} was accepted"
425 );
426 }
427 }
428
429 /// A binary reading its own bare `0.0.5` against the registry's
430 /// `0.0.5-pre.260912` is the failure this whole type exists to stop:
431 /// semver ranks a release above its own pre-releases, so the naive
432 /// comparison reports the newest published release as the older one.
433 #[test]
434 fn the_date_decides_between_two_releases_of_one_version() {
435 let older = Release::from_npm_version("0.0.5-pre.260911").unwrap();
436 let newer = Release::from_npm_version("0.0.5-pre.260912").unwrap();
437 assert_eq!(stands(&older, &newer), ReleaseVerdict::Behind);
438 assert_eq!(stands(&newer, &older), ReleaseVerdict::Ahead);
439 assert_eq!(stands(&newer, &newer), ReleaseVerdict::Current);
440 }
441
442 /// A larger version outranks a later date, which is what the
443 /// registry does with the same two strings.
444 #[test]
445 fn the_version_outranks_the_date() {
446 let old_version_late = Release::from_npm_version("0.0.5-pre.261231").unwrap();
447 let new_version_early = Release::from_npm_version("0.0.6-pre.260101").unwrap();
448 assert_eq!(
449 stands(&old_version_late, &new_version_early),
450 ReleaseVerdict::Behind
451 );
452 }
453
454 /// The registry answering something this workflow never published is
455 /// a refusal, not a release ranked against the running binary.
456 #[test]
457 fn an_npm_version_this_project_does_not_publish_is_refused() {
458 for refused in ["0.0.5", "0.0.5-alpha.1", "0.0.5-pre.", "latest"] {
459 assert!(
460 Release::from_npm_version(refused).is_err(),
461 "{refused} was accepted"
462 );
463 }
464 }
465}