xtask/src/both_ways.rs
//! **Gunnar builds BOTH ways, from crates.io alone** (owner order 2026-10-26, G-BOTH-1;
//! lane GUN-INSTALL; Holger's H1 `lone_clone` is the model).
//!
//! On 2026-10-26 `cargo install gunnar` (crates.io gunnar 1.5.0) did not build:
//! gunnar-recipe 0.1.0 names every field of nornir-build-thing's `InstallerBuild` in a
//! struct literal, and nornir-build-thing 0.2.6 added one (`payload_entries`) in a
//! semver-compatible release, so the install resolved 0.2.6 and stopped at E0063. Even
//! `--locked` did: the lock packaged into gunnar 1.5.0 was written in an estate checkout,
//! so its nornir-build-thing entry was a sibling PATH entry with no registry source,
//! and cargo resolved that one afresh. Nothing in this repository ran the install a
//! user runs, so nothing saw it. Two CI steps do now:
//!
//! * **`crates-io-install`** (needs the network): in an EMPTY directory with an EMPTY
//! `CARGO_HOME`, exactly what the README tells a user, `cargo install gunnar
//! --features client`, and the same `--locked`; each installed `gunnar --version`
//! must run. It is red until crates.io holds a gunnar that builds.
//! * **`crates-io-install-staged`**, the pre-publish twin: every crate of the
//! `gunnar --features client` graph that comes from this workspace is packaged from
//! a clone of this commit ALONE (`cargo package`, which rewrites path dependencies to
//! registry ones exactly as a publish does). A crate crates.io already serves at that
//! version must be the SAME content there (else: bump it); the ones it does not serve
//! are the publish rows, and they are patched in from their packaged form
//! (`--config patch.crates-io.<name>.path=…`). Then the packaged gunnar is installed
//! from its own directory with `--features client`, so a broken install is caught
//! BEFORE the publish. The packaged gunnar's `Cargo.lock` must name no package
//! without a registry source (the 1.5.0 defect).
//!
//! The lone clone (`lone-clone`, [`lone_steps`]): this commit cloned with no sibling
//! beside it, with an EMPTY `CARGO_HOME` ([`lone_home`]), runs `cargo build --locked` and
//! the two install lines [`LONE_INSTALLS`] (`cargo install --locked --path crates/gunnar-cli
//! --features client` and the same for `crates/gunnar`), runs each binary and boots the
//! server. The committed root lock is the crates.io lock (G-B7: the root takes no sibling
//! patch in any checkout), so the plain run takes it as it is; a lock that names a sibling
//! crate by path again is refused first, by name ([`lock_path_entries`] beside
//! [`in_repo_packages`]). `--stage <dir>` relocks once with the staged crates patched in
//! and then runs every step `--locked`.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::process::Command;
/// The crates.io package a user installs, and the feature the owner's order names.
pub const CRATES_IO_PACKAGE: &str = "gunnar";
/// `--features client`: the server with its client half (`gunnar clone`, `gunnar push`).
pub const CRATES_IO_FEATURES: &str = "client";
/// **The lone clone's install lines**: a name, the package directory, the features,
/// and the binary each leaves in `<root>/bin`.
pub const LONE_INSTALLS: &[(&str, &str, &str, &str)] = &[
// gunnar-cli's binary is `gunnar-cli` since GUN-PRODUCTS (57542fb4).
("cli", "crates/gunnar-cli", "client", "gunnar-cli"),
("server", "crates/gunnar", "client", "gunnar"),
];
/// A crate packaged from this workspace for the stage: name, version, unpacked dir.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Staged {
pub name: String,
pub version: String,
pub dir: PathBuf,
}
/// One cargo step: a name for the log, its argv (without `cargo`), and the installed
/// binary it must leave behind, if it installs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Step {
pub name: String,
pub argv: Vec<String>,
pub leaves: Option<PathBuf>,
}
fn s(v: &[&str]) -> Vec<String> {
v.iter().map(|x| (*x).to_string()).collect()
}
fn path_str(p: &Path) -> String {
p.display().to_string()
}
/// The `--config patch.crates-io.<name>.path="<dir>"` pairs for staged crates.
pub fn patch_args(staged: &[Staged]) -> Vec<String> {
let mut out = Vec::new();
for c in staged {
out.push("--config".to_string());
out.push(format!(
"patch.crates-io.{}.path={:?}",
c.name,
path_str(&c.dir)
));
}
out
}
/// **The lone clone's steps**: `cargo build --locked`, then every [`LONE_INSTALLS`] line
/// `--locked` into its own root (two lines leave a `gunnar`, so one root each). With
/// staged crates, a relock (`cargo metadata`) comes first, with the patches, so the
/// lock names the staged crates; every later step keeps the patches and `--locked`.
pub fn lone_steps(target: &Path, install_root: &Path, staged: &[Staged]) -> Vec<Step> {
let t = path_str(target);
let patches = patch_args(staged);
let mut out = Vec::new();
if !staged.is_empty() {
let mut argv = s(&["metadata", "--format-version", "1"]);
argv.extend(patches.iter().cloned());
out.push(Step {
name: "relock with the staged crates".into(),
argv,
leaves: None,
});
}
let mut build = s(&["build", "--locked", "--target-dir"]);
build.push(t.clone());
build.extend(patches.iter().cloned());
out.push(Step {
name: "cargo build --locked".into(),
argv: build,
leaves: None,
});
for (name, dir, features, bin) in LONE_INSTALLS {
let root = install_root.join(name);
let mut argv = s(&[
"install",
"--locked",
"--path",
dir,
"--features",
features,
"--root",
]);
argv.push(path_str(&root));
argv.push("--target-dir".into());
argv.push(t.clone());
argv.extend(patches.iter().cloned());
out.push(Step {
name: format!("cargo install --locked --path {dir} --features {features}"),
argv,
leaves: Some(root.join("bin").join(bin)),
});
}
out
}
/// **The crates.io steps**, run in an empty directory with an empty `CARGO_HOME`:
/// without a stage, `cargo install gunnar --features client` as a user types it and
/// the same `--locked`; with the packaged gunnar of a stage, that package installed
/// from its directory with every OTHER staged crate patched in (no `--locked`: the
/// patches rewrite the resolution, as the publish will).
pub fn crates_io_steps(
target: &Path,
install_root: &Path,
stage: Option<(&Staged, &[Staged])>,
) -> Vec<Step> {
let t = path_str(target);
let line = |root: &Path, extra: &[&str]| {
let mut argv = s(&[
"install",
CRATES_IO_PACKAGE,
"--features",
CRATES_IO_FEATURES,
]);
argv.extend(s(extra));
argv.extend([
"--root".to_string(),
path_str(root),
"--target-dir".to_string(),
t.clone(),
]);
argv
};
match stage {
None => {
let plain = install_root.join("plain");
let locked = install_root.join("locked");
vec![
Step {
name: "cargo install gunnar --features client".into(),
argv: line(&plain, &[]),
leaves: Some(plain.join("bin/gunnar")),
},
Step {
name: "cargo install gunnar --features client --locked".into(),
argv: line(&locked, &["--locked"]),
leaves: Some(locked.join("bin/gunnar")),
},
]
}
Some((gunnar, others)) => {
let root = install_root.join("staged");
let mut argv = s(&["install", "--path"]);
argv.push(path_str(&gunnar.dir));
argv.extend(s(&["--features", CRATES_IO_FEATURES, "--root"]));
argv.push(path_str(&root));
argv.extend(["--target-dir".to_string(), t]);
argv.extend(patch_args(others));
vec![Step {
name: format!(
"cargo install --path <packaged gunnar {}> --features client",
gunnar.version
),
argv,
leaves: Some(root.join("bin/gunnar")),
}]
}
}
}
/// The packages of a `Cargo.lock` that carry no `source` (a path package), other than
/// the ones named in `own` (the package itself, or the workspace's members). In a
/// packaged crate's lock every one of them is a defect: `cargo install --locked`
/// cannot use the entry and resolves that crate afresh.
pub fn lock_path_entries(lock: &str, own: &BTreeSet<String>) -> Vec<String> {
let mut out = Vec::new();
for block in lock.split("[[package]]").skip(1) {
let field = |k: &str| {
block.lines().map(str::trim).find_map(|l| {
l.strip_prefix(&format!("{k} = \""))
.and_then(|r| r.strip_suffix('"'))
.map(str::to_string)
})
};
let (Some(name), Some(version)) = (field("name"), field("version")) else {
continue;
};
if field("source").is_none() && !own.contains(&name) {
out.push(format!("{name} {version}"));
}
}
out.sort();
out.dedup();
out
}
/// The files that differ between our packaged crate and the published one (the
/// generated `Cargo.toml`, `Cargo.lock`, `.cargo_vcs_info.json` and the registry's
/// `.cargo-ok` aside: the manifest
/// as written is `Cargo.toml.orig`, so a changed requirement still shows).
pub fn differing_files(
ours: &BTreeMap<String, Vec<u8>>,
theirs: &BTreeMap<String, Vec<u8>>,
) -> Vec<String> {
const GENERATED: &[&str] = &[
"Cargo.toml",
"Cargo.lock",
".cargo_vcs_info.json",
".cargo-ok",
];
let keys: BTreeSet<&String> = ours
.keys()
.chain(theirs.keys())
.filter(|k| !GENERATED.contains(&k.as_str()))
.collect();
keys.into_iter()
.filter(|k| ours.get(*k) != theirs.get(*k))
.cloned()
.collect()
}
/// The dependency requirements of a NORMALIZED (packaged) `Cargo.toml`: each
/// `[…dependencies.<name>]` table's `version`, keyed by the table header. A crate whose
/// `Cargo.toml.orig` says `{ workspace = true }` has the same `.orig` text when the
/// workspace raises the requirement, so the requirement is read here instead.
pub fn dependency_requirements(normalized: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let mut table: Option<String> = None;
for line in normalized.lines().map(str::trim) {
if line.starts_with('[') {
let h = line.trim_matches(|c| c == '[' || c == ']').to_string();
table = h.contains("dependencies.").then_some(h);
continue;
}
if let (Some(t), Some(v)) = (
&table,
line.strip_prefix("version = \"")
.and_then(|r| r.strip_suffix('"')),
) {
out.insert(t.clone(), v.to_string());
}
}
out
}
/// The workspace crates in a `cargo tree --prefix none --format "{p}"` listing: the
/// lines whose path is inside `root`, as (name, version), once each.
pub fn workspace_crates_in_tree(listing: &str, root: &Path) -> Vec<(String, String)> {
let root = path_str(root);
let mut out: Vec<(String, String)> = listing
.lines()
.filter_map(|l| {
let open = l.find(" (")?;
let path = l[open + 2..].split(')').next()?;
if !(path == root || path.starts_with(&format!("{root}/"))) {
return None;
}
let mut w = l[..open].split_whitespace();
let name = w.next()?.to_string();
let version = w.next()?.trim_start_matches('v').to_string();
Some((name, version))
})
.collect();
out.sort();
out.dedup();
out
}
/// The crates.io sparse index path of a crate.
pub fn index_path(name: &str) -> String {
let n = name.to_lowercase();
match n.len() {
1 => format!("1/{n}"),
2 => format!("2/{n}"),
3 => format!("3/{}/{n}", &n[..1]),
_ => format!("{}/{}/{n}", &n[..2], &n[2..4]),
}
}
/// **The published copy of `name version`**, through cargo alone (no curl: the
/// toolchain image has none): `cargo info name@version`, run in an empty directory with
/// the step's own `CARGO_HOME`, downloads and unpacks the `.crate` into that home's
/// registry when crates.io serves the version. `Ok(None)`: crates.io does not serve it.
fn published_copy(
cargo: &str,
name: &str,
version: &str,
home: &Path,
empty: &Path,
) -> Result<Option<PathBuf>, String> {
let out = Command::new(cargo)
.current_dir(empty)
.env("CARGO_HOME", home)
.env_remove("CARGO_TARGET_DIR")
.args(["info", &format!("{name}@{version}")])
.output()
.map_err(|e| format!("{cargo} info: {e}"))?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
if err.contains("could not find") {
return Ok(None);
}
return Err(format!(
"cargo info {name}@{version}: {}",
err.lines()
.filter(|l| l.starts_with("error"))
.collect::<Vec<_>>()
.join(" / ")
));
}
let src = home.join("registry").join("src");
let found = std::fs::read_dir(&src)
.map_err(|e| format!("{}: {e}", src.display()))?
.flatten()
.map(|e| e.path().join(format!("{name}-{version}")))
.find(|p| p.join("Cargo.toml").is_file());
found.map(Some).ok_or_else(|| {
format!(
"cargo info {name}@{version} left no unpacked copy under {}",
src.display()
)
})
}
/// The files of an unpacked crate directory, by path relative to it.
fn files_of(dir: &Path) -> BTreeMap<String, Vec<u8>> {
fn walk(base: &Path, d: &Path, out: &mut BTreeMap<String, Vec<u8>>) {
let Ok(rd) = std::fs::read_dir(d) else { return };
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
walk(base, &p, out);
} else if let (Ok(rel), Ok(bytes)) = (p.strip_prefix(base), std::fs::read(&p)) {
out.insert(rel.to_string_lossy().into_owned(), bytes);
}
}
}
let mut out = BTreeMap::new();
walk(dir, dir, &mut out);
out
}
fn unpack(file: &Path, into: &Path) -> Result<(), String> {
let ok = Command::new("tar")
.arg("xzf")
.arg(file)
.arg("-C")
.arg(into)
.status()
.map(|s| s.success())
.unwrap_or(false);
if ok {
Ok(())
} else {
Err(format!("tar xzf {}: failed", file.display()))
}
}
fn clone_alone(root: &Path, dir: &Path) -> Result<(), String> {
let _ = std::fs::remove_dir_all(dir);
let ok = Command::new("git")
.env("GIT_LFS_SKIP_SMUDGE", "1")
.args(["clone", "--quiet", "--no-local"])
.arg(root)
.arg(dir)
.status()
.map(|s| s.success())
.unwrap_or(false);
if ok {
Ok(())
} else {
Err(format!(
"git clone {} {}: failed",
root.display(),
dir.display()
))
}
}
fn run_cargo(
cargo: &str,
cwd: &Path,
argv: &[String],
cargo_home: Option<&Path>,
) -> Result<(), String> {
let mut c = Command::new(cargo);
c.current_dir(cwd).env_remove("CARGO_TARGET_DIR").args(argv);
if let Some(h) = cargo_home {
c.env("CARGO_HOME", h);
}
eprintln!("both-ways: cargo {}", argv.join(" "));
let out = c.output().map_err(|e| format!("{cargo}: {e}"))?;
if out.status.success() {
return Ok(());
}
let err = String::from_utf8_lossy(&out.stderr);
// The compiler's verdict, not cargo's progress lines: each `error…` line, the
// `-->` place it names, and a `Caused by` chain.
let lines: Vec<&str> = err
.lines()
.filter(|l| {
l.starts_with("error")
|| l.trim_start().starts_with("-->")
|| l.starts_with("Caused by")
})
.take(14)
.collect();
Err(lines.join("\n"))
}
/// Run a binary once (`--version`) to show it starts.
fn runs(bin: &Path) -> Result<String, String> {
let out = Command::new(bin)
.arg("--version")
.output()
.map_err(|e| format!("start {}: {e}", bin.display()))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
Err(format!(
"{} --version: {}",
bin.display(),
String::from_utf8_lossy(&out.stderr).trim()
))
}
}
/// What the stage holds: the packaged gunnar, the other publish rows, and the crates
/// that were already on crates.io with the same content.
#[derive(Debug, Clone, Default)]
pub struct Stage {
pub gunnar: Option<Staged>,
pub rows: Vec<Staged>,
pub same: Vec<(String, String)>,
}
/// **Build the stage** in `work`: clone this commit alone, find the workspace crates of
/// the `gunnar --features client` graph, `cargo package` them in the clone, and judge
/// each against crates.io. A crate crates.io serves at that version with OTHER content
/// is a red (bump its version); the packaged gunnar's lock must name no path package.
pub fn make_stage(root: &Path, cargo: &str, work: &Path) -> Result<Stage, Vec<String>> {
let clone = work.join("clone");
clone_alone(root, &clone).map_err(|e| vec![e])?;
let tree = Command::new(cargo)
.current_dir(&clone)
.args([
"tree",
"-p",
CRATES_IO_PACKAGE,
"--features",
CRATES_IO_FEATURES,
"-e",
"normal,build",
"--prefix",
"none",
"--format",
"{p}",
])
.output()
.map_err(|e| vec![format!("cargo tree: {e}")])?;
if !tree.status.success() {
return Err(vec![format!(
"cargo tree -p gunnar in the lone clone: {}",
String::from_utf8_lossy(&tree.stderr).trim()
)]);
}
// Members only: an in-repo `[patch]` (the vendored gix-parallel) is a path package
// under the root too, but a crates.io user never sees the patch, so it is no row.
let members = member_names(&clone, cargo);
let mut crates: Vec<(String, String)> =
workspace_crates_in_tree(&String::from_utf8_lossy(&tree.stdout), &clone)
.into_iter()
.filter(|(n, _)| members.contains(n))
.collect();
// An OPTIONAL in-repo dependency of a row is a publish row too: `cargo package` resolves
// every dependency of a crate, the off-by-default ones included, and a crates.io user
// may turn the feature on (gunnar-server's `heimdall` names gunnar-heimdall, which the
// `client` tree above never reaches: the staged steps' red of 2026-11-03).
for extra in optional_member_deps(&clone, cargo, &crates) {
crates.push(extra);
}
crates.sort();
crates.dedup();
if !crates.iter().any(|(n, _)| n == CRATES_IO_PACKAGE) {
return Err(vec!["the gunnar graph names no gunnar package".into()]);
}
let pkg_target = work.join("package-target");
let mut argv = s(&["package", "--no-verify", "--allow-dirty", "--target-dir"]);
argv.push(path_str(&pkg_target));
for (n, _) in &crates {
argv.push("-p".into());
argv.push(n.clone());
}
run_cargo(cargo, &clone, &argv, None).map_err(|e| vec![format!("cargo package: {e}")])?;
let stage_dir = work.join("stage");
// The published copies land in a home of their own, read from an empty directory
// (no workspace, no `.cargo/config.toml` of this repository on the way up).
let home = work.join("published-home");
let empty = work.join("published-empty");
for d in [&stage_dir, &home, &empty] {
std::fs::create_dir_all(d).map_err(|e| vec![format!("{}: {e}", d.display())])?;
}
let mut stage = Stage::default();
let mut red = Vec::new();
for (name, version) in &crates {
let file = pkg_target
.join("package")
.join(format!("{name}-{version}.crate"));
if let Err(e) = unpack(&file, &stage_dir) {
red.push(e);
continue;
}
let dir = stage_dir.join(format!("{name}-{version}"));
let staged = Staged {
name: name.clone(),
version: version.clone(),
dir: dir.clone(),
};
let served = match published_copy(cargo, name, version, &home, &empty) {
Ok(p) => p,
Err(e) => {
red.push(format!("{name}: {e}"));
continue;
}
};
if let Some(theirs) = served {
let mut diff = differing_files(&files_of(&dir), &files_of(&theirs));
let reqs = |d: &Path| {
dependency_requirements(
&std::fs::read_to_string(d.join("Cargo.toml")).unwrap_or_default(),
)
};
let (ours, published) = (reqs(&dir), reqs(&theirs));
for (t, v) in &ours {
if published.get(t) != Some(v) {
diff.push(format!(
"Cargo.toml [{t}] version {v} (crates.io: {})",
published.get(t).map(String::as_str).unwrap_or("none")
));
}
}
if diff.is_empty() {
stage.same.push((name.clone(), version.clone()));
} else {
red.push(format!(
"{name} {version} is on crates.io with OTHER content ({}{}): bump its version, it is a publish row",
diff.iter().take(6).cloned().collect::<Vec<_>>().join(", "),
if diff.len() > 6 { ", …" } else { "" }
));
}
if name == CRATES_IO_PACKAGE {
stage.gunnar = Some(staged);
}
} else if name == CRATES_IO_PACKAGE {
stage.gunnar = Some(staged);
} else {
stage.rows.push(staged);
}
}
if let Some(g) = &stage.gunnar {
let own: BTreeSet<String> = [CRATES_IO_PACKAGE.to_string()].into();
match std::fs::read_to_string(g.dir.join("Cargo.lock")) {
Ok(lock) => {
let bad = lock_path_entries(&lock, &own);
if !bad.is_empty() {
red.push(format!("the packaged gunnar's Cargo.lock names path packages (no registry source), which `cargo install --locked` re-resolves: {}", bad.join(", ")));
}
}
Err(e) => red.push(format!("the packaged gunnar has no Cargo.lock: {e}")),
}
}
if red.is_empty() {
Ok(stage)
} else {
Err(red)
}
}
/// The verdict of a both-ways step.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict {
Passed(String),
Failed(String),
}
fn work_dir(tag: &str) -> PathBuf {
std::env::var("GUNNAR_BOTH_WAYS_WORK")
.ok()
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
.join(format!("gunnar-{tag}-{}", std::process::id()))
}
fn run_steps(cargo: &str, cwd: &Path, steps: &[Step], cargo_home: Option<&Path>) -> Verdict {
let mut said = Vec::new();
for st in steps {
if let Err(e) = run_cargo(cargo, cwd, &st.argv, cargo_home) {
return Verdict::Failed(format!("`{}` failed:\n{e}", st.name));
}
if let Some(bin) = &st.leaves {
match runs(bin) {
Ok(v) => said.push(format!(
"{} -> {} ({v})",
st.name,
bin.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
)),
Err(e) => {
return Verdict::Failed(format!("`{}` left no working binary: {e}", st.name))
}
}
}
}
Verdict::Passed(said.join("; "))
}
/// **`crates-io-install`** (no stage) or **`crates-io-install-staged`**: see the module
/// docs. Needs the network. The work directory is removed afterwards.
pub fn crates_io_install(root: &Path, cargo: &str, staged: bool) -> Verdict {
let work = work_dir(if staged {
"crates-io-staged"
} else {
"crates-io"
});
let _ = std::fs::remove_dir_all(&work);
let empty = work.join("empty");
let home = work.join("cargo-home");
for d in [&empty, &home] {
if let Err(e) = std::fs::create_dir_all(d) {
return Verdict::Failed(format!("{}: {e}", d.display()));
}
}
let target = work.join("target");
let installs = work.join("installs");
let verdict = if staged {
match make_stage(root, cargo, &work) {
Err(red) => Verdict::Failed(red.join("\n")),
Ok(stage) => match &stage.gunnar {
None => Verdict::Failed("the stage has no packaged gunnar".into()),
Some(g) => {
eprintln!(
"both-ways: stage: gunnar {}; publish rows {}; on crates.io already {}",
g.version,
stage
.rows
.iter()
.map(|r| format!("{} {}", r.name, r.version))
.collect::<Vec<_>>()
.join(", "),
stage
.same
.iter()
.map(|(n, v)| format!("{n} {v}"))
.collect::<Vec<_>>()
.join(", ")
);
run_steps(
cargo,
&empty,
&crates_io_steps(&target, &installs, Some((g, &stage.rows))),
Some(&home),
)
}
},
}
} else {
run_steps(
cargo,
&empty,
&crates_io_steps(&target, &installs, None),
Some(&home),
)
};
let _ = std::fs::remove_dir_all(&work);
verdict
}
/// **The lone clone**, plain or staged (`stage`: the stage is built from this commit
/// by [`make_stage`]): see the module docs. Returns the clone's directory with the
/// verdict so the caller can boot the installed server; the caller removes `work`.
pub fn lone_clone(
root: &Path,
cargo: &str,
staged: bool,
work: &Path,
) -> (Verdict, Option<PathBuf>) {
let clone = work.join("lone");
if let Err(e) = clone_alone(root, &clone) {
return (Verdict::Failed(e), None);
}
let rows = if staged {
match make_stage(root, cargo, &work.join("stage-work")) {
Ok(stage) => stage.rows,
Err(red) => return (Verdict::Failed(red.join("\n")), None),
}
} else {
let lock = std::fs::read_to_string(clone.join("Cargo.lock")).unwrap_or_default();
let bad = lock_path_entries(&lock, &in_repo_packages(&clone, cargo));
if !bad.is_empty() {
return (
Verdict::Failed(format!(
"the committed Cargo.lock is not the crates.io lock: it names {} crate(s) by path that are neither members nor an in-repo patch ({}{}), so a lone clone's `cargo build --locked` refuses it. The root takes no sibling patch (G-B7): relock it with none (`cargo metadata` at the root) and commit that lock",
bad.len(),
bad.iter().take(8).cloned().collect::<Vec<_>>().join(", "),
if bad.len() > 8 { ", …" } else { "" }
)),
None,
);
}
Vec::new()
};
let steps = lone_steps(
&work.join("lone-target"),
&work.join("lone-installs"),
&rows,
);
// The plain run is a user's first build: nothing in CARGO_HOME (G-BOTH-1).
let home = if staged { None } else { Some(lone_home(work)) };
if let Some(h) = &home {
let _ = std::fs::remove_dir_all(h);
if let Err(e) = std::fs::create_dir_all(h) {
return (Verdict::Failed(format!("{}: {e}", h.display())), None);
}
}
let verdict = run_steps(cargo, &clone, &steps, home.as_deref());
let server = work
.join("lone-installs")
.join("server")
.join("bin")
.join("gunnar");
(verdict, Some(server))
}
/// The plain lone clone's `CARGO_HOME`: a fresh, empty directory in its work directory, so
/// the run downloads every crate from crates.io as a user's first build does.
pub fn lone_home(work: &Path) -> PathBuf {
work.join("lone-cargo-home")
}
/// The path packages a crates.io root lock may hold: the root workspace's members and the
/// crates its own `[patch]` supplies from inside the repository (`vendor/gix-parallel`;
/// `xtask/tests/lone_clone.rs` holds that table inside the repo).
pub fn in_repo_packages(root: &Path, cargo: &str) -> BTreeSet<String> {
let mut own = member_names(root, cargo);
if let Ok(text) = std::fs::read_to_string(root.join("Cargo.toml")) {
own.extend(crate::siblings::patch_names(&text));
}
own
}
/// The workspace members that `rows` depend on (normal or build, optional ones included),
/// to a fixed point, minus the rows themselves: name and version each. From `cargo metadata
/// --no-deps`, whose `dependencies` carry a `path` for an in-repo dependency.
pub fn optional_member_deps(
root: &Path,
cargo: &str,
rows: &[(String, String)],
) -> Vec<(String, String)> {
let out = Command::new(cargo)
.current_dir(root)
.args(["metadata", "--no-deps", "--format-version", "1"])
.output();
let Ok(out) = out else { return Vec::new() };
let Ok(json) = crate::json::Json::parse(&String::from_utf8_lossy(&out.stdout)) else {
return Vec::new();
};
let mut version: BTreeMap<String, String> = BTreeMap::new();
let mut deps: BTreeMap<String, Vec<String>> = BTreeMap::new();
for p in json.get("packages").map(|p| p.arr()).unwrap_or(&[]) {
let Some(name) = p.get("name").and_then(crate::json::Json::str) else {
continue;
};
if let Some(v) = p.get("version").and_then(crate::json::Json::str) {
version.insert(name.to_string(), v.to_string());
}
let list = p
.get("dependencies")
.map(|d| d.arr())
.unwrap_or(&[])
.iter()
.filter(|d| {
d.get("path").is_some()
&& d.get("kind").and_then(crate::json::Json::str) != Some("dev")
})
.filter_map(|d| d.get("name").and_then(crate::json::Json::str))
.map(str::to_string)
.collect();
deps.insert(name.to_string(), list);
}
let mut have: BTreeSet<String> = rows.iter().map(|(n, _)| n.clone()).collect();
let mut todo: Vec<String> = have.iter().cloned().collect();
let mut added = Vec::new();
while let Some(n) = todo.pop() {
for d in deps.get(&n).cloned().unwrap_or_default() {
if let Some(v) = version.get(&d) {
if have.insert(d.clone()) {
added.push((d.clone(), v.clone()));
todo.push(d);
}
}
}
}
added
}
/// The root workspace's member names (from `cargo metadata --no-deps`, which needs no
/// resolution); empty if cargo cannot read the manifests.
pub fn member_names(root: &Path, cargo: &str) -> BTreeSet<String> {
let out = Command::new(cargo)
.current_dir(root)
.args(["metadata", "--no-deps", "--format-version", "1"])
.output();
let Ok(out) = out else { return BTreeSet::new() };
let Ok(json) = crate::json::Json::parse(&String::from_utf8_lossy(&out.stdout)) else {
return BTreeSet::new();
};
json.get("packages")
.map(|p| {
p.arr()
.iter()
.filter_map(|x| {
x.get("name")
.and_then(crate::json::Json::str)
.map(str::to_string)
})
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn staged(name: &str, v: &str) -> Staged {
Staged {
name: name.into(),
version: v.into(),
dir: PathBuf::from(format!("/s/{name}-{v}")),
}
}
#[test]
fn the_plain_lone_clone_has_its_own_empty_cargo_home_in_its_work_dir() {
let work = Path::new("/w/gunnar-lone-clone-1");
assert_eq!(
lone_home(work),
PathBuf::from("/w/gunnar-lone-clone-1/lone-cargo-home")
);
assert!(
lone_home(work).starts_with(work),
"removed with the work directory"
);
}
#[test]
fn an_in_repo_patch_is_not_a_sibling_path_entry() {
// The vendored gix-features is a path package in the crates.io lock; a sibling is not.
let lock = "version = 4\n\n[[package]]\nname = \"gix-features\"\nversion = \"0.50.0\"\n\n[[package]]\nname = \"nornir-git\"\nversion = \"0.2.0\"\n";
let mut own: BTreeSet<String> = ["gunnar".to_string()].into();
own.extend(crate::siblings::patch_names(
"[patch.crates-io]\ngix-features = { path = \"vendor/gix-features\" }\n",
));
assert_eq!(lock_path_entries(lock, &own), ["nornir-git 0.2.0"]);
}
#[test]
fn a_lock_path_entry_is_named_and_a_member_or_registry_entry_is_not() {
let lock = "version = 4\n\n[[package]]\nname = \"gunnar\"\nversion = \"1.5.0\"\n\n[[package]]\nname = \"nornir-build-thing\"\nversion = \"0.2.5\"\ndependencies = [\n \"serde\",\n]\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n";
let own: BTreeSet<String> = ["gunnar".to_string()].into();
assert_eq!(lock_path_entries(lock, &own), ["nornir-build-thing 0.2.5"]);
assert!(lock_path_entries("version = 4\n", &own).is_empty());
}
#[test]
fn the_generated_files_are_not_a_difference_and_every_other_file_is() {
let m = |kv: &[(&str, &str)]| {
kv.iter()
.map(|(k, v)| (k.to_string(), v.as_bytes().to_vec()))
.collect::<BTreeMap<_, _>>()
};
let ours = m(&[
("Cargo.toml", "a"),
("Cargo.toml.orig", "x"),
("src/lib.rs", "1"),
("src/new.rs", "n"),
]);
let theirs = m(&[
("Cargo.toml", "b"),
("Cargo.toml.orig", "x"),
("src/lib.rs", "2"),
("Cargo.lock", "l"),
]);
assert_eq!(
differing_files(&ours, &theirs),
["src/lib.rs", "src/new.rs"]
);
assert!(differing_files(&ours, &ours).is_empty());
}
#[test]
fn a_raised_requirement_shows_in_the_normalized_manifest() {
let a = "[package]\nname = \"gunnar-http\"\nversion = \"1.5.0\"\n\n[dependencies.gunnar-ssh]\nversion = \"1.1.0\"\n\n[dev-dependencies.tempfile]\nversion = \"3\"\n";
let r = dependency_requirements(a);
assert_eq!(
r.get("dependencies.gunnar-ssh").map(String::as_str),
Some("1.1.0")
);
assert_eq!(
r.get("dev-dependencies.tempfile").map(String::as_str),
Some("3")
);
assert!(
!r.values().any(|v| v == "1.5.0"),
"the package's own version is not a requirement"
);
}
#[test]
fn the_workspace_crates_of_a_tree_are_the_ones_under_the_root() {
let listing = "gunnar v1.5.1 (/c/gunnar/crates/gunnar)\ngunnar-server v1.2.0 (/c/gunnar/crates/gunnar-server)\nserde v1.0.0\nnornir-git v0.2.1 (/c/edda/crates/nornir-git)\ngunnar-server v1.2.0 (/c/gunnar/crates/gunnar-server)\ngunnar-x v1.0.0 (/c/gunnar-other/x)\n";
assert_eq!(
workspace_crates_in_tree(listing, Path::new("/c/gunnar")),
[
("gunnar".to_string(), "1.5.1".to_string()),
("gunnar-server".to_string(), "1.2.0".to_string())
]
);
}
#[test]
fn the_index_path_follows_the_crates_io_layout() {
assert_eq!(index_path("gunnar-recipe"), "gu/nn/gunnar-recipe");
assert_eq!(index_path("syn"), "3/s/syn");
assert_eq!(index_path("cc"), "2/cc");
assert_eq!(index_path("a"), "1/a");
}
#[test]
fn the_plain_lone_clone_builds_locked_and_installs_both_lines_locked() {
let st = lone_steps(Path::new("/t"), Path::new("/r"), &[]);
let argv: Vec<Vec<String>> = st.iter().map(|x| x.argv.clone()).collect();
assert_eq!(argv[0], s(&["build", "--locked", "--target-dir", "/t"]));
assert_eq!(
argv[1],
s(&[
"install",
"--locked",
"--path",
"crates/gunnar-cli",
"--features",
"client",
"--root",
"/r/cli",
"--target-dir",
"/t"
])
);
assert_eq!(
argv[2],
s(&[
"install",
"--locked",
"--path",
"crates/gunnar",
"--features",
"client",
"--root",
"/r/server",
"--target-dir",
"/t"
])
);
assert_eq!(st.len(), 3);
assert_eq!(
st[1].leaves.as_deref(),
Some(Path::new("/r/cli/bin/gunnar-cli"))
);
assert_eq!(
st[2].leaves.as_deref(),
Some(Path::new("/r/server/bin/gunnar"))
);
}
#[test]
fn a_staged_lone_clone_relocks_first_and_keeps_the_patches_and_locked() {
let st = lone_steps(
Path::new("/t"),
Path::new("/r"),
&[staged("gunnar-recipe", "0.1.1")],
);
assert_eq!(st[0].argv[0], "metadata");
assert!(
st[0].argv.iter().all(|a| a != "--locked"),
"the relock writes the lock"
);
for x in &st[1..] {
assert!(x.argv.contains(&"--locked".to_string()), "{:?}", x.argv);
assert!(
x.argv.contains(
&"patch.crates-io.gunnar-recipe.path=\"/s/gunnar-recipe-0.1.1\"".to_string()
),
"{:?}",
x.argv
);
}
}
#[test]
fn the_crates_io_install_is_the_readme_line_plain_and_locked() {
let st = crates_io_steps(Path::new("/t"), Path::new("/r"), None);
assert_eq!(
st[0].argv,
s(&[
"install",
"gunnar",
"--features",
"client",
"--root",
"/r/plain",
"--target-dir",
"/t"
])
);
assert_eq!(
st[1].argv,
s(&[
"install",
"gunnar",
"--features",
"client",
"--locked",
"--root",
"/r/locked",
"--target-dir",
"/t"
])
);
assert!(st
.iter()
.all(|x| x.leaves.as_ref().is_some_and(|b| b.ends_with("bin/gunnar"))));
}
#[test]
fn the_staged_install_installs_the_packaged_gunnar_with_the_other_rows_patched_in() {
let g = staged("gunnar", "1.5.1");
let rows = [
staged("gunnar-recipe", "0.1.1"),
staged("gunnar-server", "1.2.0"),
];
let st = crates_io_steps(Path::new("/t"), Path::new("/r"), Some((&g, &rows)));
assert_eq!(st.len(), 1);
let a = &st[0].argv;
assert_eq!(&a[..3], &s(&["install", "--path", "/s/gunnar-1.5.1"])[..]);
assert!(a.contains(&"client".to_string()));
assert!(
!a.contains(&"--locked".to_string()),
"the patches rewrite the resolution"
);
assert_eq!(
a.iter()
.filter(|x| x.starts_with("patch.crates-io."))
.count(),
2
);
assert!(
!a.iter().any(|x| x.starts_with("patch.crates-io.gunnar.")),
"gunnar itself is installed by --path"
);
}
}