xtask/src/release_version.rs
//! The one reader of "what version is this workspace", and the one scanner for
//! hand-typed copies of it.
//!
//! # What this exists to make impossible
//!
//! The root `README.md` is the front page of a released project, and it names a
//! version in running prose. Nothing else in the tree compares that number
//! against the manifest, so a bumped `[workspace.package] version` leaves the
//! front page advertising a build this tree cannot produce — in the one file a
//! stranger reads first. The same shape has shipped before in a sibling
//! repository, on release day.
//!
//! # Why a scanner and not a generator
//!
//! Splicing the version in from a generated block is the by-construction fix,
//! and would be right if the README carried *many* generated numbers. It
//! carries one, in a sentence a human wrote. A scanner that refuses to let that
//! one literal disagree with the manifest costs one file and cannot be
//! satisfied by an empty README — see [`VersionLiterals::found`].
//!
//! # ⚠ A KNOWN TWIN — `monetize/xtask/src/release_version.rs`, and gunnar's
//!
//! MEASURED 2026-09-10 by `nornir mimir analyze duplicates`: three copies of
//! this file exist — here (159 lines), `monetize/xtask/src/release_version.rs`
//! (158) and `gunnar/xtask/src/release_version.rs` (167). Against monetize's,
//! **every code difference is [`PREFIXES`]** plus one `rustfmt` line break; the
//! rest of the diff is prose naming the other repository. `tests/readme_version.rs`
//! is the same story: 38 diff lines across 166. Three longhand writings of one
//! parameterized scanner, and the parameter is a `&[&str]`.
//!
//! It is NOT unified, and the reason is a rule rather than an oversight:
//! `xtask/Cargo.toml` here, in monetize and in gunnar all declare **zero
//! dependencies, deliberately** — a workspace member takes part in resolver-2
//! feature unification for `cargo build --workspace`, so a dependency added to
//! an xtask can turn a feature on in a crate the BFF links, and in gunnar's case
//! the xtask IS the dependency guard. A shared leaf — the natural home is
//! `edda/crates/nornir-xtask-support`, which already holds the std-only xtask
//! primitives and took `expand_home` in this same sweep — is a dependency.
//!
//! So the twin is named instead. The moment that rule is relaxed for xtasks, the
//! move is small and mechanical: lift everything below into the leaf taking
//! `prefixes: &[&str]`, and leave three `const PREFIXES` behind.
use std::path::Path;
/// `[workspace.package] version` from a root manifest, read textually.
///
/// Textual on purpose: `cargo metadata` needs a resolve, and a resolve of this
/// workspace fails on a machine with an empty registry cache. A guard that
/// cannot run without the whole world around it is a guard nobody runs.
///
/// # Panics
///
/// If the manifest cannot be read or carries no `[workspace.package] version`.
/// A guard that cannot find the fact it compares against has not checked
/// anything, and saying so is the point (measure, never assert).
pub fn workspace_version(root: &Path) -> String {
let path = root.join("Cargo.toml");
let text =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
let mut in_package = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_package = trimmed == "[workspace.package]";
continue;
}
if !in_package {
continue;
}
if let Some(rest) = trimmed.strip_prefix("version") {
let Some((_, rhs)) = rest.split_once('=') else {
continue;
};
return rhs.trim().trim_matches('"').to_string();
}
}
panic!(
"no `version` under [workspace.package] in {}",
path.display()
);
}
/// `(major, minor, patch)` from a version or a `v`-prefixed tag, with the
/// missing components zero: `v0.1` and `0.1.0` are the same release.
///
/// `None` for anything that is not a numeric release name, so a tag like
/// `nightly` or `v0.1-rc1` is not silently ranked against `v0.1`.
pub fn triple(s: &str) -> Option<(u64, u64, u64)> {
let s = s.trim().strip_prefix('v').unwrap_or(s.trim());
let mut parts = s.split('.');
let mut next = || -> Option<u64> {
match parts.next() {
Some(p) => p.parse().ok(),
None => Some(0),
}
};
let (a, b, c) = (next()?, next()?, next()?);
if parts.next().is_some() {
return None;
}
Some((a, b, c))
}
/// One version literal found in prose: the whole matched token and the version
/// inside it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Literal {
/// 1-based line number in the scanned text.
pub line: usize,
/// The token as written, e.g. `gunnar-ui 0.1.0`.
pub token: String,
/// The version component, e.g. `0.1.0`.
pub version: String,
}
/// What a scan of one document found.
#[derive(Debug, Default)]
pub struct VersionLiterals {
/// Every version token about this workspace, in file order.
pub found: Vec<Literal>,
}
/// The prefixes that make a number a claim about *this* workspace's version.
///
/// `gunnar-ui 0.1.0` is the front page's sentence; `gunnar-ui-server 0.1.0` and
/// `gunnar-ui-web 0.1.0` name the packages. A bare `0.1.0` is not scanned: the
/// README quotes other projects' versions, and a scanner that flagged those
/// would be switched off within a day.
const PREFIXES: &[&str] = &["gunnar-ui ", "gunnar-ui-server ", "gunnar-ui-web "];
/// Every version literal about gunnar-ui itself in `text`.
///
/// A literal is a [`PREFIXES`] prefix followed immediately by a dotted numeric
/// version of at least two components. The version ends at the first character
/// that is neither a digit nor a dot, so `gunnar-ui 0.1.0 (v0.1-3-gabc)` yields
/// `0.1.0` and `gunnar-ui 0.1.0.` yields `0.1.0` too.
pub fn scan(text: &str) -> VersionLiterals {
let mut found = Vec::new();
for (index, line) in text.lines().enumerate() {
for prefix in PREFIXES {
let mut from = 0usize;
while let Some(at) = line[from..].find(prefix) {
let start = from + at;
let after = start + prefix.len();
from = after;
let rest = &line[after..];
let end = rest
.find(|c: char| !c.is_ascii_digit() && c != '.')
.unwrap_or(rest.len());
let mut version = &rest[..end];
// `gunnar-ui 0.1.0.` — a sentence-ending full stop is not a
// fourth component.
while version.ends_with('.') {
version = &version[..version.len() - 1];
}
// At least two components, all numeric: what keeps
// `gunnar-ui 1` and `gunnar-ui main` out.
if version.matches('.').count() < 1 || triple(version).is_none() {
continue;
}
found.push(Literal {
line: index + 1,
token: format!("{prefix}{version}"),
version: version.to_string(),
});
}
}
}
found.sort_by_key(|l| l.line);
VersionLiterals { found }
}
/// Every literal in `text` that does not name `version`.
pub fn disagreements(text: &str, version: &str) -> Vec<Literal> {
let want = triple(version);
scan(text)
.found
.into_iter()
.filter(|l| triple(&l.version) != want)
.collect()
}