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
}