xtask/src/release_version.rs
//! The one reader of "what version is this workspace", and the one scanner for
//! hand-typed copies of it.
//!
//! # The defect this module exists to make impossible
//!
//! `README.md:50` said the binary was `gunnar 0.1.0` on the day
//! `Cargo.toml` said `1.1.0` and `v1.1` was tagged and pushed. The root
//! `README.md` is the **front page of a released project**, it is *generated*
//! from `.nornir/README.md`, and nothing in the tree compared the number in the
//! prose against the number in the manifest — so it named a build the tree
//! cannot produce, on the release day, in the one file a stranger reads first.
//!
//! The precedent is worse than a typo: a lane found `**1082 tests**` typed into
//! the same README with no producer behind it at all. Prose that quotes a
//! measurement is a second writer of that measurement (one implementation, not two), and the second
//! writer is the one that goes stale.
//!
//! # Why a scanner and not a generator
//!
//! Splicing the version in from a generated block (the `mashup` mechanism) is
//! the by-construction fix and would be right if the README carried *many*
//! generated numbers. It carries one, in running prose, inside 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 — gunnar-ui's and monetize's `release_version.rs`
//!
//! MEASURED 2026-09-10 by `nornir mimir analyze duplicates`: three copies of
//! this file exist — here (167 lines), `gunnar-ui/xtask/src/release_version.rs`
//! (159) and `monetize/xtask/src/release_version.rs` (158). Between gunnar-ui's
//! and monetize's, **every code difference is `PREFIXES`**; this one carries the
//! same scanner with more prose and the `mashup` sentence above. The three
//! `tests/readme_version.rs` files are the same story. One parameterized
//! scanner, written out longhand three times, and the parameter is a `&[&str]`.
//!
//! It does NOT move, and the reason is the rule in `xtask/Cargo.toml`: **this
//! crate has ZERO dependencies, deliberately**, because a workspace member takes
//! part in resolver-2 feature unification and a dependency added here could turn
//! a feature on in a crate the appliance links — and because this crate is the
//! dependency guard, which must not become the thing it guards against. The
//! natural home otherwise is `edda/crates/nornir-xtask-support`, which already
//! holds the std-only xtask primitives and took this repository's `expand_home`
//! in the same sweep (see `bench/src/lib.rs`).
//!
//! So the twin is named instead. If the rule is ever relaxed for xtasks, the
//! move is 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 is the step that already fails on a machine with no sibling znippy
/// checkout for the detached cold tier.
///
/// # 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: `v1.1` and `1.1.0` are the same release.
///
/// `None` for anything that is not a numeric release name, so a tag like
/// `nightly` or `v1.1-rc1` is not silently ranked against `v1.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 1.1.0` or `gunnar/1.1.0`.
pub token: String,
/// The version component, e.g. `1.1.0`.
pub version: String,
}
/// What a scan of one document found.
#[derive(Debug, Default)]
pub struct VersionLiterals {
/// Every `gunnar <version>` / `gunnar/<version>` token, in file order.
pub found: Vec<Literal>,
}
/// The prefixes that make a number a claim about *this* binary's version.
///
/// `gunnar 1.1.0` is what `gunnar --version` prints; `gunnar/1.1.0` is what
/// `caps::default_agent` puts on the wire; `gunnar_1.1.0` is the SSH banner.
/// A bare `1.1.0` is not scanned: the README quotes upstream versions
/// (`git 2.53.0`, `OpenSSH_10.2p1`) that have nothing to do with this
/// manifest, and a scanner that flagged those would be turned off within a day.
const PREFIXES: &[&str] = &["gunnar ", "gunnar/", "gunnar_", "gunnar-cli "];
/// Every version literal about gunnar 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 1.1.0 (v1.1-3-gabc)` yields `1.1.0` and
/// `gunnar 1.1.0.` yields `1.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 1.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, and all of them numeric: this is
// what keeps `gunnar 0` and `gunnar/master` 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()
}