nornir 0.1.0

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! Publish-order walker. Per-repo TOML defines `publish_order` as
//! `Vec<Vec<String>>`: outer entries are sequential phases, inner
//! entries are independent and may be published in parallel.
//!
//! Tag creation is done in-process via the pure-Rust `gix` crate (no
//! `git` shellout). Tag *pushing* over the network is deferred to a
//! follow-up that wires `gix` network features — for now we print the
//! exact `git push` invocation so a human or CI can complete the step.
//!
//! `cargo publish` itself remains a `cargo` subprocess because cargo
//! owns crate packaging (sources → `.crate` tarball → upload). That is
//! cargo's domain, not a script we can replace with a few lines of
//! HTTP. Isolated to [`run_cargo_publish`] and clearly annotated.

use std::path::Path;
use std::process::Command;

use anyhow::{anyhow, Context, Result};

/// Walk the publish order, invoking cargo to publish each crate.
/// `dry_run` adds `--dry-run`.
pub fn publish_all(repo_root: &Path, order: &[Vec<String>], dry_run: bool) -> Result<()> {
    for phase in order {
        for krate in phase {
            run_cargo_publish(repo_root, krate, dry_run)
                .with_context(|| format!("cargo publish -p {krate}"))?;
        }
    }
    Ok(())
}

/// The single intentional `cargo` subprocess. Cargo owns crate
/// packaging+upload; replacing it would require reimplementing
/// `cargo package` (thousands of lines covering manifest validation,
/// `.cargo_vcs_info.json`, exclude/include globs, tarball
/// determinism, license-file handling, etc.). Out of scope; not
/// every wheel needs reinventing.
fn run_cargo_publish(repo_root: &Path, krate: &str, dry_run: bool) -> Result<()> {
    let mut cmd = Command::new("cargo");
    cmd.arg("publish").arg("-p").arg(krate);
    if dry_run {
        cmd.arg("--dry-run");
    }
    cmd.current_dir(repo_root);
    let status = cmd.status().context("spawn cargo publish")?;
    if !status.success() {
        return Err(anyhow!("cargo publish -p {krate} exited {status}"));
    }
    Ok(())
}

/// Create an annotated `vX.Y.Z` tag pointing at HEAD using the
/// pure-Rust `gix` crate (no `git` shellout). Does *not* push — prints
/// the exact push command the operator/CI should run.
pub fn tag(repo_root: &Path, version: &str) -> Result<()> {
    let tag = format!("v{version}");
    let repo = gix::open(repo_root)
        .with_context(|| format!("gix::open {}", repo_root.display()))?;
    let head_commit = repo.head_commit().context("resolve HEAD commit")?;

    let message = format!("Release {tag}\n");
    let signature = repo
        .author()
        .ok_or_else(|| anyhow!("git author not configured (user.name / user.email)"))?
        .map_err(|e| anyhow!("read git author: {e}"))?;

    repo.tag(
        &tag,
        head_commit.id,
        gix::objs::Kind::Commit,
        Some(signature),
        &message,
        gix::refs::transaction::PreviousValue::MustNotExist,
    )
    .with_context(|| format!("create tag {tag}"))?;

    eprintln!(
        "created local tag {tag} at {}. Push with: git push origin {tag}",
        head_commit.id
    );
    Ok(())
}