xtask/src/lone_clone.rs
//! **`cargo xtask lone-clone [--stage]`: a clone of holger ALONE builds, installs and
//! serves** (owner order 2026-10-22; the CI step `lone_clone`, H1 of docs/HOLGER-V2.md).
//!
//! `git clone` of this commit into an empty directory (no sibling beside it, no
//! `.cargo/siblings.toml`, no patch), then exactly what a user types:
//!
//! 1. `cargo build --locked` (the default member, `server/cli`);
//! 2. `cargo install --locked --path server/cli --root <tmp>`, and the `cargo install`
//! line of every other product of this repository (`holger-downstream`, `holger-cli`),
//! each bin run once;
//! 3. the installed `holger` is started on free loopback ports and `GET /healthz` must
//! answer `ok`.
//!
//! The root workspace is the open core (the estate crates are members of `estate/`), so
//! the clone resolves from crates.io alone once every crate of [`ROOT_NEEDS`] is there at
//! the version this workspace locks. Until then the plain run is RED by its reason.
//!
//! `--stage` stands in for that publish: every crate of [`ROOT_NEEDS`] that crates.io does
//! not serve yet is packaged from its sibling checkout (`cargo package`, which rewrites its
//! path dependencies to registry ones exactly as a publish does), unpacked into a local
//! overlay and patched into the clone's build with `--config patch.crates-io.<name>.path=…`.
//! Everything else comes from crates.io, so a sibling whose version crates.io already
//! serves with OLDER content shows up as a compile error here, as it would after the
//! publish. The staged run relocks once (`cargo metadata`: the lock's sibling entries are
//! path entries, see below), then runs steps 1 to 3 with `--locked`.
//!
//! The committed root lock is the crates.io lock (B7, after the owner's publish of
//! 2026-10-26): the root takes no sibling patch in any checkout (only `estate/` and
//! `holger-estate-front/` carry them, in their manifests), so the plain run resolves with
//! `--locked`. A sibling crate whose tree is newer than crates.io reaches the root only
//! after it is published; until then `--stage` stands in for that publish.
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
/// **The sibling crates the root resolution holds**, in the order they must be published
/// (a crate after every sibling crate it depends on): name, the version the root locks,
/// and the checkout it is packaged from (relative to the parent of the repository).
/// cargo resolves EVERY root member's dependencies, optional and dev included, so this is
/// the whole set, not only what the default build links. `xtask/tests/lone_clone.rs`
/// (`the_publish_list_is_exactly_the_sibling_crates_of_the_root_resolution`) holds it
/// equal to `cargo metadata --all-features` of the root. Open crates only: nothing from
/// `monetize-impl`.
pub const ROOT_NEEDS: &[(&str, &str, &str)] = &[
("nornir-hash", "0.1.1", "edda/crates/nornir-hash"),
("nornir-service", "0.1.14", "edda/crates/nornir-service"),
("jera", "0.2.1", "edda/crates/jera"),
("nornir-escape", "0.1.1", "edda/crates/nornir-escape"),
(
"skidbladnir-systemd",
"0.1.0",
"Skidbladnir/crates/skidbladnir-systemd",
),
("skidbladnir", "0.2.3", "Skidbladnir"),
(
"nornir-build-thing",
"0.2.6",
"edda/crates/nornir-build-thing",
),
(
"wg-appliance-core",
"0.1.8",
"edda/crates/wg-appliance-core",
),
("korp-installer", "0.2.11", "tunnr/crates/korp-installer"),
(
"monetize-product",
"0.3.0",
"monetize/crates/monetize-product",
),
("monetize-embed", "0.3.0", "monetize/crates/monetize-embed"),
("appliance-grow", "0.1.1", "edda/crates/appliance-grow"),
("appliance-pid1", "0.1.8", "edda/crates/appliance-pid1"),
("disk-snapshot", "0.1.1", "edda/crates/disk-snapshot"),
("edda-admit", "0.1.0", "edda/crates/admit"),
("estate-clock", "0.1.1", "edda/crates/estate-clock"),
("facett-core", "0.2.0", "facett/facett-core"),
("facett-app", "0.2.0", "facett/facett-app"),
("facett-syschart", "0.2.0", "facett/facett-syschart"),
("facett-table", "0.2.0", "facett/facett-table"),
("guest-host", "0.2.1", "edda/crates/guest-host"),
("modgunn", "0.0.8", "modgunn"),
("monetize-proto", "0.3.3", "monetize/crates/monetize-proto"),
(
"nordisk-pairing",
"0.5.28",
"Skidbladnir/crates/installer/nordisk-pairing",
),
(
"monetize-estate-side",
"0.3.0",
"monetize/crates/monetize-estate-side",
),
("monetize-mail", "0.3.0", "monetize/crates/monetize-mail"),
(
"nordisk-narrate",
"0.1.2",
"Skidbladnir/crates/installer/nordisk-narrate",
),
(
"nordisk-twin",
"0.1.2",
"Skidbladnir/crates/installer/nordisk-twin",
),
("nornir-auth-oidc", "0.2.0", "edda/crates/nornir-auth-oidc"),
(
"nornir-auth-webauthn",
"0.2.2",
"edda/crates/nornir-auth-webauthn",
),
("nornir-identity", "0.2.2", "edda/crates/nornir-identity"),
(
"nornir-identity-session",
"0.1.1",
"edda/crates/nornir-identity-session",
),
(
"nornir-auth-policy",
"0.1.6",
"edda/crates/nornir-auth-policy",
),
("nornir-gate", "0.1.0", "edda/crates/nornir-gate"),
("nornir-vault", "0.2.0", "edda/crates/nornir-vault"),
(
"nornir-testmatrix",
"0.2.14",
"edda/crates/nornir-testmatrix",
),
(
"plugin-door-wire",
"0.1.1",
"Skidbladnir/crates/installer/plugin-door-wire",
),
];
/// The list, in publish order.
pub fn publish_first() -> impl Iterator<Item = &'static (&'static str, &'static str, &'static str)>
{
ROOT_NEEDS.iter()
}
/// What the verb did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoneClone {
Passed,
Failed(String),
}
/// Clone, build, install and boot; see the module doc.
pub fn lone_clone(root: &Path, stage: bool) -> LoneClone {
let work = std::env::temp_dir().join(format!("holger-lone-clone-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&work);
if let Err(e) = std::fs::create_dir_all(&work) {
return LoneClone::Failed(format!("create {}: {e}", work.display()));
}
let verdict = (|| -> Result<LoneClone> {
let patches = if stage {
stage_overlay(root, &work.join("stage"))?
} else {
Vec::new()
};
let clone = work.join("holger");
// LFS objects (docs, pictures) are not fetched: the build reads none of them,
// and a local clone has no LFS remote to fetch them from.
let ok = Command::new("git")
.env("GIT_LFS_SKIP_SMUDGE", "1")
.args(["clone", "--quiet", "--no-local"])
.arg(root)
.arg(&clone)
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
return Ok(LoneClone::Failed(format!(
"git clone {} {}: failed",
root.display(),
clone.display()
)));
}
if clone.join(".cargo/siblings.toml").exists() {
return Ok(LoneClone::Failed(
"the clone carries .cargo/siblings.toml: it must never be committed".into(),
));
}
Ok(build_install_and_boot(&clone, &patches, stage, &work))
})();
let _ = std::fs::remove_dir_all(&work);
verdict.unwrap_or_else(|e| LoneClone::Failed(format!("{e:#}")))
}
/// Package every crate of [`publish_first`] that crates.io does not serve yet from its
/// sibling checkout, unpack it under `stage`, and return the `--config
/// patch.crates-io.<name>.path=…` arguments. A crate crates.io already serves at that
/// version is taken from crates.io, so the overlay shrinks as the publish goes on.
pub fn stage_overlay(root: &Path, stage: &Path) -> Result<Vec<String>> {
let parent = root.parent().context("the repository has a parent")?;
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let target = stage.join("target");
std::fs::create_dir_all(stage)?;
let mut todo: Vec<(&str, &str, &str)> = Vec::new();
for &(name, version, dir) in publish_first() {
if on_crates_io(name, version) {
println!("lone-clone: {name} {version} is on crates.io, not staged");
continue;
}
let manifest = parent.join(dir).join("Cargo.toml");
if !manifest.is_file() {
bail!(
"--stage packages {name} from {}, which is not checked out",
manifest.display()
);
}
todo.push((name, version, dir));
}
let mut out = Vec::new();
let mut earlier: Vec<(String, PathBuf)> = Vec::new();
for g in package_groups(&todo) {
// Only what this group's crates name: a patch for anything else would move
// the sibling's own resolution and break its --locked.
let mut needs = std::collections::BTreeSet::new();
for &(_, _, dir) in &g.crates {
needs.extend(dependency_names(
&std::fs::read_to_string(parent.join(dir).join("Cargo.toml")).unwrap_or_default(),
));
}
let patches: Vec<String> = earlier
.iter()
.filter(|(n, _)| needs.contains(n))
.flat_map(|(n, d)| patch_args(n, d))
.collect();
let names: Vec<&str> = g.crates.iter().map(|c| c.0).collect();
let o = package_group_command(&cargo, parent, &g, &target, &patches)
.output()
.with_context(|| format!("cargo package {}", names.join(" ")))?;
if !o.status.success() {
bail!(
"cargo package {} (in {}): {}",
names.join(" "),
parent.join(g.repo).display(),
String::from_utf8_lossy(&o.stderr).trim()
);
}
for &(name, version, _) in &g.crates {
let file = target
.join("package")
.join(format!("{name}-{version}.crate"));
if !file.is_file() {
bail!("cargo package {name} left no {} — the publish list names {version}; is the sibling at another version?", file.display());
}
let ok = Command::new("tar")
.arg("-xzf")
.arg(&file)
.arg("-C")
.arg(stage)
.status()?
.success();
if !ok {
bail!("unpack {}", file.display());
}
out.extend(patch_args(name, &stage.join(format!("{name}-{version}"))));
}
for &(name, _, dir) in &g.crates {
earlier.push((name.to_string(), parent.join(dir)));
}
}
Ok(out)
}
/// One sibling workspace's share of the stage: its checkout (`repo`, the first segment of
/// [`ROOT_NEEDS`]'s path) and every crate of it the stage packages, in publish order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackageGroup<'a> {
pub repo: &'a str,
pub crates: Vec<(&'a str, &'a str, &'a str)>,
}
/// Pure: the staged crates grouped by their sibling workspace (each sibling's workspace is
/// its checkout's root), the workspaces in the order their first crate is published. One
/// `cargo package -p … -p …` per workspace lets cargo resolve a crate against another
/// crate it packages in the same run (A3's 0.3.0 train: monetize-embed needs
/// monetize-product 0.3.0 before crates.io serves it).
pub fn package_groups<'a>(staged: &[(&'a str, &'a str, &'a str)]) -> Vec<PackageGroup<'a>> {
let mut groups: Vec<PackageGroup<'a>> = Vec::new();
for &(name, version, dir) in staged {
let repo = dir.split('/').next().unwrap_or(dir);
match groups.iter_mut().find(|g| g.repo == repo) {
Some(g) => g.crates.push((name, version, dir)),
None => groups.push(PackageGroup {
repo,
crates: vec![(name, version, dir)],
}),
}
}
groups
}
/// Every dependency name a manifest names, of every kind and target.
pub fn dependency_names(manifest: &str) -> std::collections::BTreeSet<String> {
let mut out = std::collections::BTreeSet::new();
let Ok(v) = manifest.parse::<toml::Table>() else {
return out;
};
let mut tables: Vec<&toml::Table> = Vec::new();
for k in ["dependencies", "dev-dependencies", "build-dependencies"] {
if let Some(t) = v.get(k).and_then(|t| t.as_table()) {
tables.push(t);
}
}
if let Some(targets) = v.get("target").and_then(|t| t.as_table()) {
for t in targets.values().filter_map(|t| t.as_table()) {
for k in ["dependencies", "dev-dependencies", "build-dependencies"] {
if let Some(d) = t.get(k).and_then(|d| d.as_table()) {
tables.push(d);
}
}
}
}
for t in tables {
for (key, val) in t {
let name = val.get("package").and_then(|p| p.as_str()).unwrap_or(key);
out.insert(name.to_string());
}
}
out
}
/// The `cargo package` of one [`PackageGroup`]: in the sibling's own checkout, over its
/// workspace manifest, `--locked`, one `-p` per crate; the `.crate`s into `target`.
/// `earlier`: the `--config patch…` arguments of every crate staged before this group
/// (other sibling workspaces), pointing at its sibling checkout (the path the
/// sibling's own lock records, so `--locked` holds), so a crate that needs an
/// unpublished crate of another workspace resolves it.
pub fn package_group_command(
cargo: &str,
parent: &Path,
g: &PackageGroup<'_>,
target: &Path,
earlier: &[String],
) -> Command {
let mut c = Command::new(cargo);
c.current_dir(parent.join(g.repo))
.args([
"package",
"--quiet",
"--locked",
"--no-verify",
"--allow-dirty",
"--manifest-path",
])
.arg(parent.join(g.repo).join("Cargo.toml"))
.arg("--target-dir")
.arg(target);
for (name, _, _) in &g.crates {
c.args(["-p", name]);
}
c.args(earlier);
c
}
/// Does crates.io serve `name` at `version`? The sparse index through `curl` (xtask
/// has no HTTP client to spend), or `cargo info` where there is no `curl` (the
/// toolchain container). Unreachable counts as no: the crate is then staged.
fn on_crates_io(name: &str, version: &str) -> bool {
match Command::new("curl")
.args(["-sf", "-A", "nordisk-dep-audit"])
.arg(index_url(name))
.output()
{
Ok(o) if o.status.success() => {
String::from_utf8_lossy(&o.stdout).contains(&format!("\"vers\":\"{version}\""))
}
Ok(_) => false,
Err(_) => {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
Command::new(cargo)
.current_dir(std::env::temp_dir())
.args(["info", "--quiet", "--registry", "crates-io"])
.arg(format!("{name}@{version}"))
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
}
}
/// Pure: the sparse-index URL of a crate.
pub fn index_url(name: &str) -> String {
let n = name.to_lowercase();
let p = match n.len() {
1 => format!("1/{n}"),
2 => format!("2/{n}"),
3 => format!("3/{}/{n}", &n[..1]),
_ => format!("{}/{}/{n}", &n[..2], &n[2..4]),
};
format!("https://index.crates.io/{p}")
}
/// Pure: the two arguments that patch `name` to the unpacked crate at `dir`.
pub fn patch_args(name: &str, dir: &Path) -> [String; 2] {
[
"--config".to_string(),
format!(
"patch.crates-io.{name}.path={:?}",
dir.display().to_string()
),
]
}
/// **The products a clone of holger alone installs** (HOL-PRODUCTS): each product row of this
/// repository, its `cargo install` line (`holger_recipe::twins::PRODUCTS`) and the bin it
/// leaves. The console (`holger-ui`) is the holger-ui repository's, so it is not here.
pub fn open_rows() -> Vec<(String, &'static str, String)> {
let declared = holger_recipe::variants::variants();
holger_recipe::twins::PRODUCTS
.iter()
.filter(|r| r.name != holger_recipe::variants::HOLGER_UI)
.map(|r| {
let v = declared
.iter()
.find(|v| v.name == r.name)
.unwrap_or_else(|| panic!("product `{}` has no row", r.name));
(
r.name.to_string(),
r.install,
v.bin.clone().unwrap_or_default(),
)
})
.collect()
}
/// The argv of the cargo steps (pure, for the tests): the relock of a staged run,
/// `cargo build --locked`, then every product's `cargo install` line ([`open_rows`]),
/// each into `install_root`.
pub fn cargo_steps(
target: &Path,
install_root: &Path,
patches: &[String],
stage: bool,
) -> Vec<Vec<String>> {
let t = target.display().to_string();
let mut steps = Vec::new();
if stage {
let mut relock = vec![
"metadata".to_string(),
"--format-version".into(),
"1".into(),
];
relock.extend(patches.iter().cloned());
steps.push(relock);
}
let mut build = vec![
"build".to_string(),
"--locked".into(),
"--target-dir".into(),
t.clone(),
];
build.extend(patches.iter().cloned());
steps.push(build);
for (_, line, _) in open_rows() {
// `cargo install …` as the user types it, minus `cargo`.
let mut install: Vec<String> = line.split_whitespace().skip(1).map(String::from).collect();
install.extend([
"--root".to_string(),
install_root.display().to_string(),
"--target-dir".into(),
t.clone(),
]);
install.extend(patches.iter().cloned());
steps.push(install);
}
steps
}
fn build_install_and_boot(clone: &Path, patches: &[String], stage: bool, work: &Path) -> LoneClone {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
// The clone's own target dir unless the caller names one: a shared target dir only
// saves compile time, it changes nothing about what resolves.
let target: PathBuf = std::env::var_os("HOLGER_LONE_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| clone.join("target"));
let installed = work.join("installed");
println!(
"lone-clone: {} ({} staged crate(s))",
clone.display(),
patches.len() / 2
);
for args in cargo_steps(&target, &installed, patches, stage) {
// The overlay's `--config patch…` pairs are counted above, not printed.
let shown: Vec<&str> = args
.iter()
.map(String::as_str)
.take_while(|a| *a != "--config")
.collect();
println!("lone-clone: cargo {}", shown.join(" "));
let out = Command::new(&cargo)
.current_dir(clone)
.env_remove("CARGO_TARGET_DIR")
.args(&args)
.stdout(if args[0] == "metadata" {
std::process::Stdio::null()
} else {
std::process::Stdio::inherit()
})
.status();
match out {
Ok(s) if s.success() => {}
Ok(s) => {
return LoneClone::Failed(format!("`cargo {}` in a lone clone exited {s}", args[0]))
}
Err(e) => return LoneClone::Failed(format!("{cargo} {}: {e}", args[0])),
}
}
// Every product's bin is installed; each one runs, and the server answers /healthz.
for (row, line, bin) in open_rows() {
let path = installed.join("bin").join(&bin);
if !path.is_file() {
return LoneClone::Failed(format!("`{line}` (row `{row}`) left no {}", path.display()));
}
match Command::new(&path)
.arg("--help")
.stdout(std::process::Stdio::null())
.status()
{
Ok(s) if s.success() => println!("lone-clone: row `{row}`: {} runs", path.display()),
other => {
return LoneClone::Failed(format!(
"{} --help (row `{row}`): {other:?}",
path.display()
))
}
}
}
boot(&installed.join("bin/holger"), &work.join("data"))
}
/// Start the built server on free loopback ports and ask `GET /healthz`.
fn boot(bin: &Path, data: &Path) -> LoneClone {
use std::io::{Read, Write};
let free = || {
std::net::TcpListener::bind("127.0.0.1:0")
.and_then(|l| l.local_addr())
.map(|a| a.port())
};
let (Ok(grpc), Ok(http)) = (free(), free()) else {
return LoneClone::Failed("no free loopback port".into());
};
let child = Command::new(bin)
.args(["dev-pair", "--data"])
.arg(data)
.args([
"--grpc",
&format!("127.0.0.1:{grpc}"),
"--http",
&format!("127.0.0.1:{http}"),
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
let mut child = match child {
Ok(c) => c,
Err(e) => return LoneClone::Failed(format!("start {}: {e}", bin.display())),
};
let mut answer = String::new();
for _ in 0..120 {
std::thread::sleep(std::time::Duration::from_millis(500));
if let Ok(mut s) = std::net::TcpStream::connect(("127.0.0.1", http)) {
let _ = s.write_all(
b"GET /healthz HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n",
);
let _ = s.read_to_string(&mut answer);
if !answer.is_empty() {
break;
}
}
}
// Our own child only: never a pattern kill.
let _ = child.kill();
let _ = child.wait();
if healthy(&answer) {
println!(
"lone-clone: the installed {} answered /healthz on 127.0.0.1:{http}: ok",
bin.display()
);
LoneClone::Passed
} else {
LoneClone::Failed(format!("/healthz on 127.0.0.1:{http} answered {answer:?}"))
}
}
/// Pure: a `200` whose body is `ok`.
pub fn healthy(answer: &str) -> bool {
answer.starts_with("HTTP/1.1 200")
&& answer.split("\r\n\r\n").nth(1).map(str::trim) == Some("ok")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_steps_build_and_install_locked_and_a_staged_run_relocks_first() {
let t = Path::new("/t");
let r = Path::new("/r");
let plain = cargo_steps(t, r, &[], false);
assert_eq!(plain.len(), 4, "no relock in the plain run: {plain:?}");
assert_eq!(plain[0], ["build", "--locked", "--target-dir", "/t"]);
assert_eq!(
plain[1],
[
"install",
"--locked",
"--path",
"server/cli",
"--root",
"/r",
"--target-dir",
"/t"
]
);
// Every product of this repository is installed by its own `cargo install` line.
assert_eq!(
plain[2],
[
"install",
"--locked",
"--path",
"holger-downstream",
"--root",
"/r",
"--target-dir",
"/t"
]
);
assert_eq!(
plain[3],
[
"install",
"--locked",
"--path",
"holger-cli",
"--root",
"/r",
"--target-dir",
"/t"
]
);
let p = patch_args("nordisk-twin", Path::new("/s/nordisk-twin-0.1.2")).to_vec();
let staged = cargo_steps(t, r, &p, true);
assert_eq!(staged.len(), 5);
assert_eq!(staged[0][0], "metadata");
for step in &staged {
assert!(
step.ends_with(&p),
"every staged step carries the overlay: {step:?}"
);
}
}
#[test]
fn the_lone_clone_installs_every_product_of_this_repository_and_nothing_else() {
let rows = open_rows();
assert_eq!(
rows.iter()
.map(|(row, _, _)| row.as_str())
.collect::<Vec<_>>(),
["holger", "holger-downstream", "holger-cli"]
);
assert_eq!(
rows[0].2, "holger",
"the server line installs the default bin"
);
assert_eq!(rows[1].2, "holger-downstream");
assert_eq!(rows[2].2, "holger-cli");
}
#[test]
fn a_staged_crate_is_patched_in_by_name() {
let [flag, value] = patch_args("nordisk-twin", Path::new("/s/nordisk-twin-0.1.2"));
assert_eq!(flag, "--config");
assert_eq!(
value,
"patch.crates-io.nordisk-twin.path=\"/s/nordisk-twin-0.1.2\""
);
}
#[test]
fn healthz_is_a_200_saying_ok() {
assert!(healthy(
"HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ncontent-length: 2\r\n\r\nok"
));
assert!(!healthy("HTTP/1.1 503 Service Unavailable\r\n\r\nok"));
assert!(!healthy("HTTP/1.1 200 OK\r\n\r\nnot yet"));
assert!(!healthy(""));
}
#[test]
fn the_publish_list_names_each_crate_once() {
let all: std::collections::BTreeSet<&str> = publish_first().map(|c| c.0).collect();
assert_eq!(all.len(), ROOT_NEEDS.len(), "a crate is named twice");
}
#[test]
fn nothing_from_monetize_impl_is_ever_staged_for_publish() {
for (name, _, dir) in publish_first() {
assert!(
!dir.starts_with("monetize-impl/"),
"{name} is from monetize-impl, which is never published"
);
}
}
/// T14-FIX, the red as it happened, in a tree laid out like the estate's: `holger` with
/// its sibling patches ON beside a sibling workspace `mon` with a committed lock. The
/// stage's `cargo package` of a crate of `mon` leaves `mon`'s lock byte for byte.
#[test]
fn the_stage_leaves_a_sibling_workspaces_lock_as_committed() {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let t = tempfile::tempdir().unwrap();
let e = t.path();
let write = |p: &str, text: &str| {
let p = e.join(p);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, text).unwrap();
};
// holger's sibling patches, switched on (`.cargo/siblings.toml`, as `cargo xtask siblings` writes it).
write(
"holger/.cargo/config.toml",
"include = [{ path = \"siblings.toml\", optional = true }]\n",
);
write(
"holger/.cargo/siblings.toml",
"[patch.crates-io]\nlone-patched = { path = \"../lone-patched\" }\n",
);
write(
"lone-patched/Cargo.toml",
"[package]\nname = \"lone-patched\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
);
write("lone-patched/src/lib.rs", "");
// The sibling workspace (monetize's root), its lock committed without holger's patches.
write(
"mon/Cargo.toml",
"[workspace]\nmembers = [\"crates/proto\"]\nresolver = \"2\"\n",
);
write("mon/crates/proto/Cargo.toml", "[package]\nname = \"proto\"\nversion = \"0.1.0\"\nedition = \"2021\"\nlicense = \"MIT\"\ndescription = \"x\"\n");
write("mon/crates/proto/src/lib.rs", "");
let ok = Command::new(&cargo)
.current_dir(e.join("mon"))
.env("CARGO_NET_OFFLINE", "true")
.args(["generate-lockfile", "--offline"])
.status()
.unwrap();
assert!(ok.success());
let committed = std::fs::read(e.join("mon/Cargo.lock")).unwrap();
let group = PackageGroup {
repo: "mon",
crates: vec![("proto", "0.1.0", "mon/crates/proto")],
};
let out = package_group_command(&cargo, e, &group, &e.join("stage/target"), &[])
.env("CARGO_NET_OFFLINE", "true")
.env_remove("CARGO_TARGET_DIR")
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(e.join("stage/target/package/proto-0.1.0.crate").is_file());
let after = std::fs::read_to_string(e.join("mon/Cargo.lock")).unwrap();
assert_eq!(
after,
String::from_utf8(committed).unwrap(),
"the stage rewrote the sibling's lock with holger's patches"
);
}
#[test]
fn the_dependency_names_of_a_manifest_cover_every_table_and_rename() {
let m = "[package]\nname = \"a\"\n[dependencies]\njera = { path = \"x\", version = \"0.2.1\" }\n[dev-dependencies]\ngatling = { package = \"rotaryengine\", version = \"0.1\" }\n[target.'cfg(unix)'.dependencies]\nnix = \"0.31\"\n";
let n: Vec<String> = dependency_names(m).into_iter().collect();
assert_eq!(n, ["jera", "nix", "rotaryengine"]);
}
/// ★ B4's second publish: skidbladnir 0.2.3 needs jera 0.2.1, and crates.io serves
/// neither yet. jera is in ANOTHER sibling workspace (edda), so the overlay of
/// skidbladnir's own `cargo package` cannot see it: a later workspace packages with
/// the patches of every crate staged before it.
#[test]
fn a_later_workspace_packages_against_the_crates_staged_before_it() {
let staged = [
("jera", "0.2.1", "edda/crates/jera"),
("skidbladnir", "0.2.3", "Skidbladnir"),
];
let groups = package_groups(&staged);
// The sibling checkout itself: the path the sibling's own lock records, so
// `--locked` holds (a patch to the unpacked stage moves the source).
let earlier: Vec<String> = patch_args("jera", &Path::new("/e").join(staged[0].2)).to_vec();
let c = package_group_command(
"cargo",
Path::new("/e"),
&groups[1],
Path::new("/s/target"),
&earlier,
);
let args: Vec<String> = c
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
args.windows(2).any(|w| w[0] == "--config"
&& w[1] == "patch.crates-io.jera.path=\"/e/edda/crates/jera\""),
"skidbladnir's package resolves jera from the stage: {args:?}"
);
let first = package_group_command(
"cargo",
Path::new("/e"),
&groups[0],
Path::new("/s/target"),
&[],
);
assert!(
!first.get_args().any(|a| a == "--config"),
"the first workspace has nothing staged before it"
);
}
/// ★ A3's 0.3.0 train: monetize-embed 0.3.0 needs monetize-product 0.3.0, and crates.io
/// serves neither yet. The stage packages each sibling WORKSPACE once, with every crate
/// of it that it stages, so cargo resolves the later ones against the earlier ones it
/// packages in the same run (its local overlay), still `--locked` on the sibling's lock.
#[test]
fn the_stage_packages_each_sibling_workspace_once_with_all_its_crates() {
let staged = [
(
"monetize-product",
"0.3.0",
"monetize/crates/monetize-product",
),
("jera", "0.2.1", "edda/crates/jera"),
("monetize-embed", "0.3.0", "monetize/crates/monetize-embed"),
("draupnir", "0.2.0", "draupnir"),
];
let groups = package_groups(&staged);
let names: Vec<(&str, Vec<&str>)> = groups
.iter()
.map(|g| (g.repo, g.crates.iter().map(|c| c.0).collect()))
.collect();
assert_eq!(
names,
[
("monetize", vec!["monetize-product", "monetize-embed"]),
("edda", vec!["jera"]),
("draupnir", vec!["draupnir"])
]
);
let c = package_group_command(
"cargo",
Path::new("/e"),
&groups[0],
Path::new("/s/target"),
&[],
);
assert_eq!(c.get_current_dir(), Some(Path::new("/e/monetize")));
let args: Vec<String> = c
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(args.iter().any(|a| a == "--locked"), "{args:?}");
assert!(
args.windows(2)
.any(|w| w[0] == "--manifest-path" && w[1] == "/e/monetize/Cargo.toml"),
"{args:?}"
);
for n in ["monetize-product", "monetize-embed"] {
assert!(
args.windows(2).any(|w| w[0] == "-p" && w[1] == n),
"{n}: {args:?}"
);
}
}
/// ★ The same, for real: a sibling workspace whose `emb` 0.3.0 needs its `prod` 0.3.0,
/// neither on any registry (offline). Packaged alone, `emb` cannot be prepared; packaged
/// with `prod` in one run, both `.crate`s come out and the sibling's lock is untouched.
#[test]
fn a_staged_crate_whose_own_dependency_is_staged_too_is_packaged() {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let t = tempfile::tempdir().unwrap();
let e = t.path();
let write = |p: &str, text: &str| {
let p = e.join(p);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, text).unwrap();
};
write(
"mon/Cargo.toml",
"[workspace]\nmembers = [\"crates/prod\", \"crates/emb\"]\nresolver = \"2\"\n",
);
write("mon/crates/prod/Cargo.toml", "[package]\nname = \"prod\"\nversion = \"0.3.0\"\nedition = \"2021\"\nlicense = \"MIT\"\ndescription = \"x\"\n");
write("mon/crates/prod/src/lib.rs", "pub fn p() {}\n");
write(
"mon/crates/emb/Cargo.toml",
"[package]\nname = \"emb\"\nversion = \"0.3.0\"\nedition = \"2021\"\nlicense = \"MIT\"\ndescription = \"x\"\n[dependencies]\nprod = { version = \"0.3.0\", path = \"../prod\" }\n",
);
write("mon/crates/emb/src/lib.rs", "pub fn e() { prod::p() }\n");
let ok = Command::new(&cargo)
.current_dir(e.join("mon"))
.env("CARGO_NET_OFFLINE", "true")
.args(["generate-lockfile", "--offline"])
.status()
.unwrap();
assert!(ok.success());
let committed = std::fs::read_to_string(e.join("mon/Cargo.lock")).unwrap();
let staged = [
("prod", "0.3.0", "mon/crates/prod"),
("emb", "0.3.0", "mon/crates/emb"),
];
let groups = package_groups(&staged);
assert_eq!(groups.len(), 1);
let out = package_group_command(&cargo, e, &groups[0], &e.join("stage/target"), &[])
.env("CARGO_NET_OFFLINE", "true")
.env_remove("CARGO_TARGET_DIR")
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
for f in ["prod-0.3.0.crate", "emb-0.3.0.crate"] {
assert!(e.join("stage/target/package").join(f).is_file(), "{f}");
}
assert_eq!(
std::fs::read_to_string(e.join("mon/Cargo.lock")).unwrap(),
committed,
"the stage rewrote the sibling's lock"
);
}
#[test]
fn the_index_url_follows_the_sparse_layout() {
assert_eq!(index_url("jera"), "https://index.crates.io/je/ra/jera");
assert_eq!(
index_url("nordisk-twin"),
"https://index.crates.io/no/rd/nordisk-twin"
);
assert_eq!(index_url("syn"), "https://index.crates.io/3/s/syn");
}
}