xtask/src/open_crates.rs
//! **`cargo xtask open-crates`** — the open crates a lone clone of Holger needs, put on crates.io
//! through the forge's own release path (`dwarves release`), as a verb (A28 H22, M27; lane N4,
//! 2026-10-25). It replaces the hand script `~/script-publish-open-crates-2026-10-24.sh` (A25 §5).
//!
//! ```text
//! cargo xtask open-crates [--siblings <dir>] the PLAN: each crate, its tree version, the release line
//! cargo xtask open-crates [--siblings <dir>] --live-dry-run dwarves release … --live-dry-run: crates.io asked, nothing uploaded, no token read
//! cargo xtask open-crates [--siblings <dir>] --execute dwarves release … --execute: the publish (the coordinator's)
//! ```
//!
//! [`OPEN_CRATES`] is the list: each crate, the version a lone clone needs, and where it lives
//! in the constellation (`<siblings>/<repo>/<dir>`; the siblings default to this checkout's
//! parent). The verb reads each crate's version from its manifest (a `version.workspace = true`
//! from its repository's `[workspace.package]`). A crate whose tree names another version is
//! **HOLD**: it is left out of the run, by name, and an `--execute` with a HOLD row is refused
//! before anything runs. The rest go up in ONE `dwarves release <repos…> --only <crates…>`, the
//! repositories in the order below (korp-installer's tunnr first, A18 §2.2). That line already
//! skips a crate crates.io serves byte-identical, halts on same-version drift, weighs each crate
//! against the upload limit and rehearses the cascade (Gate B) before any upload; so a second
//! run publishes nothing twice. `monetize-impl` is never named, and `dwarves release` refuses it
//! anyway.
//!
//! The forge: `$DWARVES_BIN`, else `cargo run -q --release -p dwarves --features release --` in
//! this checkout.
use std::path::{Path, PathBuf};
use std::process::Command;
/// One open crate a lone clone of Holger needs on crates.io.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OpenCrate {
pub name: &'static str,
/// The version the lone clone needs.
pub version: &'static str,
/// The sibling repository it is published from.
pub repo: &'static str,
/// Its directory in that repository.
pub dir: &'static str,
}
/// **The open crates** (A18 §2.2, A25 §5, measured against index.crates.io on 2026-10-24 and
/// again on 2026-10-25): the seven holger names at a version crates.io lacks, plus
/// nornir-xtask-support 0.1.4 (plan P0) and monetize-product-guest 0.1.5 (plan P0). The
/// other crates of holger's `lone_clone::publish_first` (appliance-pid1 0.1.8, estate-clock
/// 0.1.1, nordisk-narrate 0.1.2, nornir-build-thing 0.2.6) are on crates.io already.
pub const OPEN_CRATES: &[OpenCrate] = &[
OpenCrate { name: "korp-installer", version: "0.2.7", repo: "tunnr", dir: "crates/korp-installer" },
OpenCrate { name: "appliance-grow", version: "0.1.0", repo: "edda", dir: "crates/appliance-grow" },
OpenCrate { name: "nornir-auth-policy", version: "0.1.5", repo: "edda", dir: "crates/nornir-auth-policy" },
OpenCrate { name: "nornir-xtask-support", version: "0.1.4", repo: "edda", dir: "crates/nornir-xtask-support" },
OpenCrate { name: "monetize-proto", version: "0.1.3", repo: "monetize", dir: "crates/monetize-proto" },
OpenCrate { name: "nordisk-estate-svg", version: "0.1.0", repo: "monetize", dir: "crates/estate-svg" },
OpenCrate { name: "monetize-product-guest", version: "0.1.5", repo: "monetize", dir: "crates/monetize-product-guest" },
OpenCrate { name: "nordisk-pairing", version: "0.5.26", repo: "Skidbladnir", dir: "crates/installer/nordisk-pairing" },
OpenCrate { name: "nordisk-twin", version: "0.1.2", repo: "Skidbladnir", dir: "crates/installer/nordisk-twin" },
];
/// What the run does.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
/// Print the plan; run nothing.
Plan,
/// `dwarves release … --live-dry-run`: crates.io asked, nothing uploaded, no token read.
LiveDryRun,
/// `dwarves release … --execute`: the publish.
Execute,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Opts {
pub mode: Mode,
/// Where the sibling repositories are checked out (`None`: this checkout's parent).
pub siblings: Option<PathBuf>,
}
/// The verb's words.
pub const USAGE: &str = "cargo xtask open-crates [--siblings <dir>] [--live-dry-run | --execute]";
/// Pure: the words after `open-crates`.
pub fn parse(args: &[String]) -> Result<Opts, String> {
let mut o = Opts { mode: Mode::Plan, siblings: None };
let mut it = args.iter();
while let Some(a) = it.next() {
let mode = match a.as_str() {
"--live-dry-run" => Mode::LiveDryRun,
"--execute" => Mode::Execute,
"--siblings" => {
o.siblings = Some(PathBuf::from(it.next().ok_or_else(|| format!("REFUSED [open-crates-args] --siblings needs a directory: {USAGE}"))?));
continue;
}
other => return Err(format!("REFUSED [open-crates-args] `{other}`: {USAGE}")),
};
if o.mode != Mode::Plan && o.mode != mode {
return Err(format!("REFUSED [open-crates-args] --live-dry-run or --execute, not both: {USAGE}"));
}
o.mode = mode;
}
Ok(o)
}
/// Pure: the `version = "…"` of a manifest's `[package]` (or of `[workspace.package]`, `table`).
fn version_in(text: &str, table: &str) -> Option<VersionLine> {
let mut inside = false;
for line in text.lines().map(str::trim) {
if line.starts_with('[') {
inside = line == format!("[{table}]");
continue;
}
if !inside {
continue;
}
let compact: String = line.chars().filter(|c| !c.is_whitespace()).collect();
if compact.starts_with("version.workspace=true") || compact.starts_with("version={workspace=true") {
return Some(VersionLine::Workspace);
}
if let Some(v) = compact.strip_prefix("version=\"") {
return v.split('"').next().map(|v| VersionLine::Own(v.to_string()));
}
}
None
}
enum VersionLine {
Own(String),
Workspace,
}
/// Pure: **a crate's tree version** from its manifest, and its repository's root manifest when the
/// crate takes the workspace's.
pub fn tree_version(manifest: &str, workspace: Option<&str>) -> Option<String> {
match version_in(manifest, "package")? {
VersionLine::Own(v) => Some(v),
VersionLine::Workspace => match version_in(workspace?, "workspace.package")? {
VersionLine::Own(v) => Some(v),
VersionLine::Workspace => None,
},
}
}
/// One crate as the tree has it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Tree {
/// The tree names the version needed: it goes up.
Ready,
/// The tree names another version: left out, by name.
Hold(String),
/// No manifest (or no version in it) where the crate lives.
Missing(String),
}
/// Reads each crate's tree under `siblings`.
pub fn trees(siblings: &Path) -> Vec<(OpenCrate, Tree)> {
OPEN_CRATES
.iter()
.map(|c| {
let manifest = siblings.join(c.repo).join(c.dir).join("Cargo.toml");
let t = match std::fs::read_to_string(&manifest) {
Err(e) => Tree::Missing(format!("{}: {e}", manifest.display())),
Ok(m) => match tree_version(&m, std::fs::read_to_string(siblings.join(c.repo).join("Cargo.toml")).ok().as_deref()) {
None => Tree::Missing(format!("{} names no version", manifest.display())),
Some(v) if v == c.version => Tree::Ready,
Some(v) => Tree::Hold(v),
},
};
(*c, t)
})
.collect()
}
/// Pure: **the release line**: `release <repo dirs…> --only <crates…> [--live-dry-run|--execute]`,
/// the repositories once each in [`OPEN_CRATES`]' order, only the Ready crates.
pub fn release_words(siblings: &Path, rows: &[(OpenCrate, Tree)], mode: Mode) -> Option<Vec<String>> {
let ready: Vec<&OpenCrate> = rows.iter().filter(|(_, t)| *t == Tree::Ready).map(|(c, _)| c).collect();
if ready.is_empty() {
return None;
}
let mut repos: Vec<&str> = Vec::new();
for c in &ready {
if !repos.contains(&c.repo) {
repos.push(c.repo);
}
}
let mut w = vec!["release".to_string()];
w.extend(repos.iter().map(|r| siblings.join(r).display().to_string()));
w.push("--only".into());
w.push(ready.iter().map(|c| c.name).collect::<Vec<_>>().join(","));
match mode {
Mode::Plan => {}
Mode::LiveDryRun => w.push("--live-dry-run".into()),
Mode::Execute => w.push("--execute".into()),
}
Some(w)
}
/// Pure: **the plan**, one row per crate, then the line the forge is handed.
pub fn plan(siblings: &Path, rows: &[(OpenCrate, Tree)], mode: Mode) -> String {
let mut p = format!("open-crates — {} open crate(s) for a lone clone of Holger, from {}\n", rows.len(), siblings.display());
for (c, t) in rows {
let said = match t {
Tree::Ready => "ready".to_string(),
Tree::Hold(v) => format!("HOLD: the tree says {v}; bump it to {} first", c.version),
Tree::Missing(why) => format!("MISSING: {why}"),
};
p.push_str(&format!(" {:<24} {:<8} {:<46} {said}\n", c.name, c.version, format!("{}/{}", c.repo, c.dir)));
}
match release_words(siblings, rows, mode) {
None => p.push_str(" nothing is ready: no release line\n"),
Some(w) => p.push_str(&format!(" dwarves {}\n", w.join(" "))),
}
p.push_str(match mode {
Mode::Plan => "PLAN ONLY — `--live-dry-run` asks crates.io and dry-runs each crate (nothing uploaded, no token read); `--execute` publishes (the coordinator's).\n",
Mode::LiveDryRun => "LIVE DRY-RUN — nothing reaches crates.io.\n",
Mode::Execute => "EXECUTE — the crates go up to crates.io.\n",
});
p
}
/// Pure: **what refuses a run before the forge is asked**: a missing crate for any run that asks
/// the forge, a HOLD row for `--execute`.
pub fn refusal(rows: &[(OpenCrate, Tree)], mode: Mode) -> Option<String> {
if mode == Mode::Plan {
return None;
}
if let Some((c, Tree::Missing(why))) = rows.iter().find(|(_, t)| matches!(t, Tree::Missing(_))) {
return Some(format!("REFUSED [open-crates-missing] {}: {why}", c.name));
}
if mode == Mode::Execute {
let held: Vec<&str> = rows.iter().filter(|(_, t)| matches!(t, Tree::Hold(_))).map(|(c, _)| c.name).collect();
if !held.is_empty() {
return Some(format!("REFUSED [open-crates-hold] {} on HOLD (the tree names another version): bump them, or take them off the list, before anything is published", held.join(", ")));
}
}
None
}
/// **`cargo xtask open-crates`** — see the module. Returns the exit code.
pub fn run(root: &Path, args: &[String]) -> i32 {
let o = match parse(args) {
Ok(o) => o,
Err(e) => {
eprintln!("{e}");
return 2;
}
};
let siblings = o.siblings.clone().unwrap_or_else(|| root.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("..")));
let rows = trees(&siblings);
print!("{}", plan(&siblings, &rows, o.mode));
if let Some(r) = refusal(&rows, o.mode) {
eprintln!("{r}");
return 1;
}
let Some(words) = release_words(&siblings, &rows, o.mode).filter(|_| o.mode != Mode::Plan) else {
return 0;
};
let mut cmd = match std::env::var_os("DWARVES_BIN").filter(|v| !v.is_empty()) {
Some(bin) => Command::new(bin),
None => {
let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()));
c.args(["run", "-q", "--release", "-p", "dwarves", "--features", "release", "--"]).current_dir(root);
c
}
};
match cmd.args(&words).status() {
Ok(s) if s.success() => 0,
Ok(s) => {
eprintln!("open-crates: dwarves {} exited {s}", words.join(" "));
s.code().unwrap_or(1)
}
Err(e) => {
eprintln!("open-crates: the forge did not start: {e}");
1
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn s(t: &str) -> Vec<String> {
t.split_whitespace().map(str::to_string).collect()
}
/// The words: a plan by default, one of the two runs, never both.
#[test]
fn the_words_are_a_plan_unless_a_run_is_named() {
assert_eq!(parse(&[]).unwrap(), Opts { mode: Mode::Plan, siblings: None });
assert_eq!(parse(&s("--live-dry-run")).unwrap().mode, Mode::LiveDryRun);
assert_eq!(parse(&s("--execute --siblings /w")).unwrap(), Opts { mode: Mode::Execute, siblings: Some(PathBuf::from("/w")) });
assert!(parse(&s("--execute --live-dry-run")).unwrap_err().contains("not both"));
assert!(parse(&s("--force")).unwrap_err().contains("[open-crates-args]"), "no --force: never force a release");
assert!(parse(&s("--siblings")).unwrap_err().contains("needs a directory"));
}
/// A crate's version is its own, or its repository's `[workspace.package]` one.
#[test]
fn a_tree_version_is_the_crates_own_or_its_workspaces() {
assert_eq!(tree_version("[package]\nname = \"a\"\nversion = \"0.2.7\"\n[dependencies]\nx = { version = \"1\" }\n", None).as_deref(), Some("0.2.7"));
let ws = "[workspace]\nmembers = []\n[workspace.package]\nauthors = []\nversion = \"0.1.3\"\n";
assert_eq!(tree_version("[package]\nname = \"monetize-proto\"\nversion.workspace = true\n", Some(ws)).as_deref(), Some("0.1.3"));
assert_eq!(tree_version("[package]\nname = \"b\"\nversion = { workspace = true }\n", Some(ws)).as_deref(), Some("0.1.3"));
assert_eq!(tree_version("[package]\nversion.workspace = true\n", None), None);
assert_eq!(tree_version("[dependencies]\nversion = \"9\"\n", None), None, "only [package] says the crate's version");
}
/// ★ The list: nine open crates, korp-installer first, never monetize-impl, one row each.
#[test]
fn the_list_is_the_nine_open_crates_korp_installer_first() {
let names: Vec<&str> = OPEN_CRATES.iter().map(|c| c.name).collect();
assert_eq!(names.len(), 9);
assert_eq!(names[0], "korp-installer");
for n in &names {
assert_eq!(names.iter().filter(|m| *m == n).count(), 1, "{n} twice");
}
assert!(OPEN_CRATES.iter().all(|c| c.repo != "monetize-impl"), "monetize-impl is never published");
assert!(names.contains(&"monetize-product-guest") && names.contains(&"nordisk-pairing"));
}
fn rows(t: impl Fn(&OpenCrate) -> Tree) -> Vec<(OpenCrate, Tree)> {
OPEN_CRATES.iter().map(|c| (*c, t(c))).collect()
}
/// ★ The release line: ONE `dwarves release`, each repository once in order, the ready crates
/// in `--only`; a HOLD crate is left out by name, and `--execute` with one is refused.
#[test]
fn one_release_line_over_the_ready_crates_and_hold_is_left_out() {
let sib = Path::new("/w");
let all = rows(|_| Tree::Ready);
let w = release_words(sib, &all, Mode::LiveDryRun).unwrap();
assert_eq!(w[..5], s("release /w/tunnr /w/edda /w/monetize /w/Skidbladnir")[..]);
assert_eq!(w[5..], s("--only korp-installer,appliance-grow,nornir-auth-policy,nornir-xtask-support,monetize-proto,nordisk-estate-svg,monetize-product-guest,nordisk-pairing,nordisk-twin --live-dry-run")[..]);
assert_eq!(release_words(sib, &all, Mode::Execute).unwrap().last().map(String::as_str), Some("--execute"));
assert!(!release_words(sib, &all, Mode::Plan).unwrap().iter().any(|w| w.starts_with("--live") || w == "--execute"));
assert_eq!(refusal(&all, Mode::Execute), None);
let held = rows(|c| if c.name == "monetize-product-guest" { Tree::Hold("0.1.4".into()) } else { Tree::Ready });
let w = release_words(sib, &held, Mode::LiveDryRun).unwrap().join(" ");
assert!(!w.contains("monetize-product-guest") && w.contains("monetize-proto"), "{w}");
assert_eq!(refusal(&held, Mode::LiveDryRun), None, "a dry-run goes on without the HOLD row");
assert!(refusal(&held, Mode::Execute).unwrap().contains("[open-crates-hold] monetize-product-guest"));
let p = plan(sib, &held, Mode::Plan);
assert!(p.contains("HOLD: the tree says 0.1.4; bump it to 0.1.5 first"), "{p}");
assert!(p.ends_with("`--execute` publishes (the coordinator's).\n"), "{p}");
let missing = rows(|c| if c.repo == "tunnr" { Tree::Missing("no tunnr".into()) } else { Tree::Ready });
assert!(refusal(&missing, Mode::LiveDryRun).unwrap().contains("[open-crates-missing] korp-installer"));
assert_eq!(refusal(&missing, Mode::Plan), None, "a plan only says it");
assert!(release_words(sib, &missing, Mode::Plan).unwrap().join(" ").starts_with("release /w/edda "));
assert_eq!(release_words(sib, &rows(|_| Tree::Hold("0".into())), Mode::Plan), None);
}
}