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

nordisk/gunnar

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