Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

xtask/src/gen_proto.rs

//! `cargo xtask gen-proto` — compile holger's `.proto` files to Rust **without
//! `protoc` and without shelling out**, then commit the generated code.
//!
//! Why this exists: the old `server/lib/build.rs` called
//! `tonic_prost_build::compile_protos(...)`, which shells out to the `protoc` binary at
//! every `cargo build`. On checkouts that lack `protoc` (e.g. the `nornir`
//! server user) that build-script FAILS and takes down `holger-server-lib` and
//! everything path-dep'd on it (viz test arms, holger/skade/nornir benches).
//!
//! The fix (Rickard's directive: "use an xtask, avoid shelling"): move codegen
//! out of build-time entirely. This xtask uses **protox** — the pure-Rust
//! protobuf compiler — to parse the `.proto` into a `FileDescriptorSet` (no
//! `protoc`, no subprocess), then hands that to **tonic-build**'s `compile_fds`
//! (with `skip_protoc_run()`) to emit Rust. The output is written into the
//! source tree at `server/lib/src/generated/holger.v1.rs` and **committed**.
//!
//! After this, a plain `cargo build` compiles ONLY the checked-in generated
//! file — no `protoc`, no shell-out. Regenerate (idempotently) with
//! `cargo xtask gen-proto`.

use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

/// Repo-relative location of the proto sources and the committed generated code.
///
/// **Two files, two packages, two generated modules — on purpose.**
/// `holger.proto` is the product's wire surface, compiled into every build.
/// `holger-metered.proto` is the paid-state contract (`holger.metered.v1`), and
/// it is a SEPARATE package because tonic-build emits one Rust file per package:
/// putting `plan` / `paid_until` / `caps` in the shared file would compile the
/// metered product's vocabulary into the open build's string table even with the
/// service never registered. The open build does not decline to enforce plans —
/// it does not contain them.
const PROTO_FILE: &str = "proto/holger.proto";
const METERED_PROTO_FILE: &str = "proto/holger-metered.proto";
/// `holger-estate.proto` (`holger.estate.v1`, 2026-09-27): the estate over the wire —
/// a twin's and a recharge's manifest + fetch. Its own package so holger.proto, and
/// every downstream copy of it, does not move.
const ESTATE_PROTO_FILE: &str = "proto/holger-estate.proto";
/// `holger-grow.proto` (`holger.grow.v1`, 2026-09-27): the data set grows without a
/// restart — Start / Status (no credential) / Resume / Flush, and a twin's position.
/// Its own package for the same reason as the estate's.
const GROW_PROTO_FILE: &str = "proto/holger-grow.proto";
/// `holger-room.proto` (`holger.room.v1`, 2026-10-17): where more room for a
/// repository is bought — the ONE shop's link, holger's word for the console.
const ROOM_PROTO_FILE: &str = "proto/holger-room.proto";
/// `holger-account.proto` (`holger.account.v1`, 2026-10-21, lane P4): the repositories a
/// person has here, the private ones an admin makes, and each one's client setup.
const ACCOUNT_PROTO_FILE: &str = "proto/holger-account.proto";
/// `holger-token.proto` (`holger.token.v1`, 2026-10-21): repository tokens for
/// package clients — mint, list, revoke.
const TOKEN_PROTO_FILE: &str = "proto/holger-token.proto";
/// `holger-doors.proto` (`holger.doors.v1`, 2026-10-26, lane B3): the bundle export and
/// the first admin key, the two acts that had no gRPC door for the thin client.
const DOORS_PROTO_FILE: &str = "proto/holger-doors.proto";
/// **The thin client's own copy** (`holger-cli`, lane B3): CLIENT stubs only, of the
/// packages a console speaks (`holger.v1`, room, account, token and doors). It is
/// generated here, from the same files, so the client never compiles server code and
/// never depends on `holger-server-lib`.
const CLI_GENERATED_DIR: &str = "holger-cli/src/generated";
const CLI_PROTO_FILES: [&str; 5] = [
    PROTO_FILE,
    ROOM_PROTO_FILE,
    ACCOUNT_PROTO_FILE,
    TOKEN_PROTO_FILE,
    DOORS_PROTO_FILE,
];
const GENERATED_DIR: &str = "server/lib/src/generated";
/// tonic-build names each output after the proto `package`.
const GENERATED_FILE: &str = "holger.v1.rs";
const METERED_GENERATED_FILE: &str = "holger.metered.v1.rs";

/// Find the workspace root by walking up from the xtask manifest dir until we
/// see the top-level `proto/holger.proto`. Keeps the verb runnable from any cwd.
fn workspace_root() -> Result<PathBuf> {
    // `CARGO_MANIFEST_DIR` for xtask is `<root>/xtask`; the root is its parent.
    let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let root = manifest
        .parent()
        .context("xtask manifest has no parent dir")?
        .to_path_buf();
    anyhow::ensure!(
        root.join(PROTO_FILE).is_file(),
        "could not locate {PROTO_FILE} under workspace root {}",
        root.display()
    );
    Ok(root)
}

/// Compile `proto/holger.proto` → committed Rust. Returns the path written.
///
/// Pure-Rust pipeline: `protox::compile` (parse, no protoc) → tonic-build
/// `compile_fds` with `skip_protoc_run()` (codegen, no protoc).
pub fn run() -> Result<PathBuf> {
    let root = workspace_root()?;
    generate_into(
        &root,
        &root.join(GENERATED_DIR),
        &root.join(CLI_GENERATED_DIR),
    )
}

/// The whole codegen, written into `out_dir` (the server's files) and `cli_out_dir`
/// (the thin client's stubs). [`run`] passes the committed directories; the
/// freshness test passes temporary ones and compares.
fn generate_into(root: &Path, out_dir: &Path, cli_out_dir: &Path) -> Result<PathBuf> {
    let proto_path = root.join(PROTO_FILE);
    let metered_proto_path = root.join(METERED_PROTO_FILE);
    let proto_dir = proto_path
        .parent()
        .context("proto file has no parent dir")?
        .to_path_buf();

    fs::create_dir_all(out_dir)
        .with_context(|| format!("create generated dir {}", out_dir.display()))?;

    // 1) Parse with protox (pure Rust — no `protoc` binary, no subprocess).
    //    BOTH files in one descriptor set; tonic-build then writes one Rust file
    //    per package, which is what keeps the metered vocabulary out of the file
    //    every open build compiles.
    anyhow::ensure!(
        metered_proto_path.is_file(),
        "could not locate {METERED_PROTO_FILE} under {}",
        root.display()
    );
    let estate_proto_path = root.join(ESTATE_PROTO_FILE);
    let grow_proto_path = root.join(GROW_PROTO_FILE);
    let room_proto_path = root.join(ROOM_PROTO_FILE);
    let account_proto_path = root.join(ACCOUNT_PROTO_FILE);
    let token_proto_path = root.join(TOKEN_PROTO_FILE);
    let doors_proto_path = root.join(DOORS_PROTO_FILE);
    let fds = protox::compile(
        [
            &proto_path,
            &metered_proto_path,
            &estate_proto_path,
            &grow_proto_path,
            &room_proto_path,
            &account_proto_path,
            &token_proto_path,
            &doors_proto_path,
        ],
        [&proto_dir],
    )
    .with_context(|| format!("protox compile of {}", proto_path.display()))?;

    // 2) Codegen with tonic-build straight from the descriptor set.
    //    `skip_protoc_run()` guarantees the binary is never invoked even though
    //    we already bypassed it by handing over a ready FileDescriptorSet.
    tonic_prost_build::configure()
        .build_server(true)
        .build_client(true)
        .out_dir(out_dir)
        // Emit every artifact payload as `bytes::Bytes` (not `Vec<u8>`) so the
        // serve hot path can hand out cheap refcount slices of one owned buffer
        // (zero-copy) instead of allocating+copying a fresh `Vec` per 64 KiB frame.
        //
        // The chunk field has been `Bytes` since the streaming door was written;
        // the unary pair joins it now that `RepositoryBackendTrait::fetch` returns
        // `Bytes` (`.nornir/vec-to-bytes-migration.md`). Without these two lines
        // the seam would BUY a copy instead of removing one: a `Bytes` fetched
        // from the archive would have to be `to_vec()`'d back into the response.
        .bytes("holger.v1.ArtifactChunk.data")
        .bytes("holger.v1.FetchArtifactResponse.data")
        .bytes("holger.v1.PutArtifactRequest.data")
        .bytes("holger.v1.PutArtifactChunk.data")
        .bytes("holger.estate.v1.FetchResponse.data")
        .skip_protoc_run()
        // No `cargo:rerun-if-changed` lines: this is an xtask, not a build
        // script, so those println directives would be meaningless noise.
        .emit_rerun_if_changed(false)
        .compile_fds(fds)
        .context("tonic-build compile_fds")?;

    let generated = out_dir.join(GENERATED_FILE);
    let metered_generated = out_dir.join(METERED_GENERATED_FILE);
    for path in [&generated, &metered_generated] {
        anyhow::ensure!(
            path.is_file(),
            "expected tonic-build to write {}, but it is missing",
            path.display()
        );
        // Prepend a do-not-edit banner so humans know to run the xtask, not hand-edit.
        prepend_banner(path)?;
        println!("✅ generated {} (no protoc, no shell-out)", path.display());
    }
    run_cli(root, &proto_dir, cli_out_dir)?;
    Ok(generated)
}

/// The thin client's stubs ([`CLI_GENERATED_DIR`]): the same parse, client only.
fn run_cli(root: &Path, proto_dir: &Path, out_dir: &Path) -> Result<()> {
    fs::create_dir_all(out_dir)
        .with_context(|| format!("create generated dir {}", out_dir.display()))?;
    let files: Vec<PathBuf> = CLI_PROTO_FILES.iter().map(|f| root.join(f)).collect();
    let fds =
        protox::compile(&files, [proto_dir]).context("protox compile of the client's protos")?;
    tonic_prost_build::configure()
        .build_server(false)
        .build_client(true)
        .build_transport(true)
        .out_dir(out_dir)
        .skip_protoc_run()
        .emit_rerun_if_changed(false)
        .compile_fds(fds)
        .context("tonic-build compile_fds (client)")?;
    for entry in fs::read_dir(out_dir)? {
        let path = entry?.path();
        if path.extension().is_some_and(|e| e == "rs") {
            prepend_banner(&path)?;
            println!("✅ generated {} (client stubs)", path.display());
        }
    }
    Ok(())
}

/// Prefix the generated file with a generated-by banner (idempotent — only adds
/// it if not already present, so re-runs produce byte-identical output).
fn prepend_banner(path: &Path) -> Result<()> {
    const BANNER: &str = "// @generated by `cargo xtask gen-proto` (protox + tonic-build, no protoc).\n// DO NOT EDIT BY HAND. Regenerate after changing proto/holger.proto.\n\n";
    let body = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
    if body.starts_with("// @generated by `cargo xtask gen-proto`") {
        return Ok(());
    }
    fs::write(path, format!("{BANNER}{body}")).with_context(|| format!("write {}", path.display()))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Every committed generated file is byte for byte what `cargo xtask gen-proto`
    /// writes now. A hand edit (or a proto change without a re-run) fails here, in
    /// the server's copy and in the thin client's copy alike.
    #[test]
    fn the_committed_proto_code_is_what_gen_proto_writes() {
        let root = workspace_root().expect("workspace root");
        let tmp = tempfile::tempdir().expect("tempdir");
        let (server, cli) = (tmp.path().join("server"), tmp.path().join("cli"));
        generate_into(&root, &server, &cli).expect("gen-proto into a temporary dir");
        let mut stale = Vec::new();
        let mut checked = 0;
        for (fresh_dir, committed_dir) in [
            (&server, root.join(GENERATED_DIR)),
            (&cli, root.join(CLI_GENERATED_DIR)),
        ] {
            for entry in fs::read_dir(fresh_dir).expect("read fresh dir") {
                let fresh = entry.expect("entry").path();
                let committed = committed_dir.join(fresh.file_name().expect("name"));
                checked += 1;
                if fs::read(&fresh).ok() != fs::read(&committed).ok() {
                    stale.push(
                        committed
                            .strip_prefix(&root)
                            .unwrap_or(&committed)
                            .display()
                            .to_string(),
                    );
                }
            }
        }
        assert!(checked >= 10, "gen-proto wrote only {checked} files");
        assert!(stale.is_empty(), "these committed files differ from `cargo xtask gen-proto`; re-run it and commit: {stale:?}");
    }
}