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

nordisk/gunnar

xtask/src/installer_pin.rs

//! `cargo xtask installer-pin`: **the three declarations of `korp-installer`
//! must be ONE declaration**, and the three lockfiles must resolve it to one
//! version.
//!
//! # The defect this module exists to make impossible
//!
//! The appliance's medium installs itself with edda's `korp-installer`, and
//! gunnar names that crate in THREE manifests, each of which resolves through
//! a lockfile of its own:
//!
//! | manifest | lockfile | what it decides |
//! |---|---|---|
//! | `crates/gunnar-server/Cargo.toml` | `Cargo.lock` | the installer the APPLIANCE ships and grows with (`grow-runtime`) |
//! | `installtest/Cargo.toml` | `installtest/Cargo.lock` | the installer the INSTALL TEST proves |
//! | `crates/gunnar-growbench/Cargo.toml` | `crates/gunnar-growbench/Cargo.lock` | the installer the GROW BENCH measures |
//!
//! The second and third are DETACHED workspaces — `xtask/src/main.rs`'s
//! `DETACHED` — so their lockfiles are resolved separately and can move
//! separately. Nothing compared them. The failure that buys is the worst shape
//! this repository knows: **a bench and a test that prove a version the
//! appliance does not ship**, with every reading green. The requirement is a
//! caret (`version = "0.1.7"` is `^0.1.7`), so the three can land on three
//! different patch releases of the installer without one line of any manifest
//! changing, simply by being resolved on different days.
//!
//! MEASURED 2026-09-21 on master: all three manifests ask for `0.1.7` and all
//! three lockfiles resolve `0.1.12`. They agree TODAY. Nothing was keeping them
//! that way, which is the whole content of this module — the same argument
//! `images.rs` makes about a floating tag, one repository over.
//!
//! # A scanner, not a manifest parser
//!
//! `xtask` has **zero dependencies** on purpose (see its manifest), so there is
//! no `toml` crate here and there is not going to be one. This reads the six
//! files as TEXT and compares exact strings, which is weaker than parsing and
//! is recorded rather than papered over: a requirement written as a
//! `[dependencies.korp-installer]` table, or a version spelled with different
//! whitespace, is not seen. That is not the shape these lines have ever had,
//! and a file that stops declaring the crate at all is itself a finding
//! ([`Finding::NotDeclared`]) rather than a silent pass.
//!
//! # Every check is proved to bite
//!
//! [`Injury`] is `deploy_check.rs`'s idea, for the same reason: a guard that
//! cannot report red is worth nothing, and this repository has shipped several
//! that could not. `xtask/tests/installer_pin.rs` applies each injury to the
//! text in memory and fails if the check stays green.

use std::path::Path;

/// The crate every rule here is about.
pub const CRATE: &str = "korp-installer";

/// `(manifest, lockfile)`, relative to the workspace root — one row per place
/// gunnar decides which installer it links. The first row is the APPLIANCE's:
/// it is the one that ships, and the other two exist to prove it.
pub const PLACES: &[(&str, &str)] = &[
    ("crates/gunnar-server/Cargo.toml", "Cargo.lock"),
    ("installtest/Cargo.toml", "installtest/Cargo.lock"),
    (
        "crates/gunnar-growbench/Cargo.toml",
        "crates/gunnar-growbench/Cargo.lock",
    ),
];

/// A deliberate defect, for proving a check bites. Applied as a text
/// substitution before the check, which is exactly how the defect arrives in
/// real life: somebody edited a line, or a lockfile was re-resolved.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Injury {
    /// No defect: the tree must PASS.
    None,
    /// One manifest asks for a different version from the others.
    DriftedRequirement,
    /// One lockfile resolved a different version from the others.
    DriftedLock,
    /// A lockfile resolved something OLDER than the manifest asks for.
    LockBelowRequirement,
    /// A manifest stopped declaring the installer at all.
    NotDeclared,
}

/// The six files, read once.
pub struct Pins {
    /// `(manifest path, requirement)` — `None` when the file does not declare
    /// the crate.
    pub required: Vec<(String, Option<String>)>,
    /// `(lockfile path, resolved version)` — `None` when the lock does not
    /// carry the crate.
    pub resolved: Vec<(String, Option<String>)>,
}

impl Pins {
    /// Read them off the tree at `root`.
    pub fn read(root: &Path) -> Result<Pins, String> {
        let mut required = Vec::with_capacity(PLACES.len());
        let mut resolved = Vec::with_capacity(PLACES.len());
        for (manifest, lock) in PLACES {
            let m = std::fs::read_to_string(root.join(manifest))
                .map_err(|e| format!("{manifest}: {e}"))?;
            let l = std::fs::read_to_string(root.join(lock)).map_err(|e| format!("{lock}: {e}"))?;
            required.push(((*manifest).to_string(), requirement(&m)));
            resolved.push(((*lock).to_string(), resolved_version(&l)));
        }
        Ok(Pins { required, resolved })
    }

    /// The same six files with `injury` applied to the FIRST place, in memory.
    pub fn injured(&self, injury: Injury) -> Pins {
        let mut out = Pins {
            required: self.required.clone(),
            resolved: self.resolved.clone(),
        };
        match injury {
            Injury::None => {}
            Injury::DriftedRequirement => out.required[0].1 = Some("0.1.99".to_string()),
            Injury::DriftedLock => out.resolved[0].1 = Some("0.1.99".to_string()),
            Injury::LockBelowRequirement => {
                out.required[0].1 = Some("0.1.12".to_string());
                out.resolved[0].1 = Some("0.1.9".to_string());
            }
            Injury::NotDeclared => out.required[0].1 = None,
        }
        out
    }
}

/// **The requirement out of a manifest TEXT.**
///
/// The line has one shape in all three files —
/// `korp-installer = { version = "0.1.7", … }` — and a plain
/// `korp-installer = "0.1.7"` is read too, because that is the other spelling
/// somebody would type. A commented line is not a declaration: the scan starts
/// at the first non-space character, so `# korp-installer = …` is skipped, and
/// the prose around these pins is full of such lines.
pub fn requirement(manifest: &str) -> Option<String> {
    for line in manifest.lines() {
        let line = line.trim_start();
        let Some(rest) = line.strip_prefix(CRATE) else {
            continue;
        };
        let Some(rest) = rest.trim_start().strip_prefix('=') else {
            continue;
        };
        let rest = rest.trim_start();
        if let Some(inner) = rest.strip_prefix('{') {
            // `{ version = "x", … }`
            let v = inner
                .split_once("version")?
                .1
                .trim_start()
                .strip_prefix('=')?;
            return quoted(v);
        }
        return quoted(rest);
    }
    None
}

/// **The resolved version out of a lockfile TEXT** — the `version` of the
/// `[[package]]` block whose `name` is the crate. Read as the first `version =`
/// after the name line, which is the order `cargo` writes and the only order a
/// lockfile has ever had.
pub fn resolved_version(lock: &str) -> Option<String> {
    let mut lines = lock.lines();
    while let Some(line) = lines.next() {
        if line.trim() != format!("name = \"{CRATE}\"") {
            continue;
        }
        for next in lines.by_ref() {
            let next = next.trim_start();
            if let Some(v) = next.strip_prefix("version") {
                return quoted(v.trim_start().strip_prefix('=')?);
            }
            if next.starts_with("[[package]]") {
                break;
            }
        }
    }
    None
}

/// The first double-quoted run in `s`.
fn quoted(s: &str) -> Option<String> {
    let s = s.trim_start().strip_prefix('"')?;
    s.split_once('"').map(|(v, _)| v.to_string())
}

/// One thing that is wrong.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Finding {
    /// The file it is about.
    pub where_: String,
    /// What is wrong, in one sentence an operator can act on.
    pub what: String,
}

/// The rules, in order. Empty = the tree holds.
pub fn check(pins: &Pins) -> Vec<Finding> {
    let mut out = Vec::new();

    // 1. Every place declares it. A manifest that stopped is not a pass: the
    //    crate is optional on gunnar-server, and an appliance built without it
    //    is an appliance that cannot grow its own data set.
    for (path, req) in &pins.required {
        if req.is_none() {
            out.push(Finding {
                where_: path.clone(),
                what: format!(
                    "declares no `{CRATE}` requirement. All three places must name it: this one decides what \
                     the appliance links, and the other two exist to prove that exact version"
                ),
            });
        }
    }
    for (path, res) in &pins.resolved {
        if res.is_none() {
            out.push(Finding {
                where_: path.clone(),
                what: format!("carries no `{CRATE}` package, so nothing here proves which installer this tree builds"),
            });
        }
    }

    // 2. One requirement. The caret makes the LINE weaker than it looks, so the
    //    lines at least have to say the same thing.
    let reqs: Vec<(&String, &String)> = pins
        .required
        .iter()
        .filter_map(|(p, r)| r.as_ref().map(|r| (p, r)))
        .collect();
    if let Some((first_path, first)) = reqs.first() {
        for (path, req) in reqs.iter().skip(1) {
            if req != first {
                out.push(Finding {
                    where_: (*path).clone(),
                    what: format!(
                        "asks for `{CRATE}` {req} while {first_path} asks for {first}. Three manifests, one installer: \
                         the bench and the install test exist to prove the version the appliance ships"
                    ),
                });
            }
        }
    }

    // 3. One resolved version. THIS is the rule with teeth: the requirement is
    //    a caret and the lockfiles are resolved separately, so two of these can
    //    move apart without any manifest changing.
    let res: Vec<(&String, &String)> = pins
        .resolved
        .iter()
        .filter_map(|(p, r)| r.as_ref().map(|r| (p, r)))
        .collect();
    if let Some((first_path, first)) = res.first() {
        for (path, got) in res.iter().skip(1) {
            if got != first {
                out.push(Finding {
                    where_: (*path).clone(),
                    what: format!(
                        "resolved `{CRATE}` {got} while {first_path} resolved {first}. The appliance would SHIP one \
                         installer and the bench would MEASURE another, with every reading green. Re-resolve both \
                         lockfiles to one version"
                    ),
                });
            }
        }
    }

    // 4. The lock is not older than the line. A caret cannot resolve below its
    //    own floor, so this can only be a hand-edited lockfile — and a
    //    hand-edited lockfile is exactly how an installer bug gets re-shipped
    //    after it was fixed.
    for ((mpath, req), (lpath, got)) in pins.required.iter().zip(pins.resolved.iter()) {
        let (Some(req), Some(got)) = (req, got) else {
            continue;
        };
        if parts(got) < parts(req) {
            out.push(Finding {
                where_: lpath.clone(),
                what: format!(
                    "resolved `{CRATE}` {got}, which is BELOW the {req} {mpath} asks for"
                ),
            });
        }
    }
    out
}

/// A dotted version as numbers, for ordering. A component that is not a number
/// sorts as 0, which is the cautious direction: it makes a version look OLDER
/// and so cannot silence rule 4.
fn parts(v: &str) -> Vec<u64> {
    v.split('.')
        .map(|p| {
            p.trim_matches(|c: char| !c.is_ascii_digit())
                .parse()
                .unwrap_or(0)
        })
        .collect()
}

/// `cargo xtask installer-pin`, and the `installer-pin` step of `xtask ci`:
/// read the tree, print what is wrong, answer whether it holds.
pub fn run(root: &Path) -> bool {
    let pins = match Pins::read(root) {
        Ok(p) => p,
        Err(e) => {
            eprintln!("installer-pin: {e}");
            return false;
        }
    };
    let findings = check(&pins);
    if findings.is_empty() {
        let shipped = pins
            .resolved
            .first()
            .and_then(|(_, r)| r.clone())
            .unwrap_or_default();
        println!(
            "installer-pin: {} places agree on {CRATE} {shipped}",
            PLACES.len()
        );
        return true;
    }
    for f in &findings {
        eprintln!("installer-pin: {}: {}", f.where_, f.what);
    }
    false
}