Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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()
}