Gunnar

nordisk/gunnar-ui

crates/gunnar-ui-server/build.rs

//! Compiles gunnar's vendored protos into a client-only tonic module.
//!
//! Deliberately protoc-free: `protox` parses the `.proto` sources in pure Rust
//! and hands `tonic-prost-build` a ready `FileDescriptorSet`. gunnar's own
//! `gunnar-proto/build.rs` shells out to `protoc` and panics without it, which
//! is exactly the checkout-breaking failure this avoids.
//!
//! The two crates are version-locked: `protox`'s `prost-types` major must equal
//! `tonic-prost-build`'s, or `compile_fds` takes a different `FileDescriptorSet`
//! type than protox returns.
//!
//! # And it stamps the running build's identity
//!
//! Added 2026-08-29: `gunnar-ui-server` had no `--version` at all (found by the
//! functional-matrix rig), while every other binary in the constellation names its
//! commit. The stamp is gunnar's own pattern (`gunnar-common/build.rs`),
//! REUSED rather than re-thought: ONE `git describe --tags --always
//! --dirty=-modified` → `GUNNAR_UI_BUILD_REF`, and a build DATE that is a fact
//! about the SOURCE (`SOURCE_DATE_EPOCH`, else HEAD's committer date, else
//! `unknown`) → `GUNNAR_UI_BUILD_DATE` — never the wall clock, which is what
//! made gunnar unreproducible before its stamp was fixed. Everything degrades
//! to `unknown` rather than failing: a tarball with no `.git` must still build.

use std::path::PathBuf;
use std::process::Command;

const PROTO_FILES: &[&str] = &[
    "common.proto",
    "repos.proto",
    "refs.proto",
    "access.proto",
    "policy.proto",
    "events.proto",
    "admin.proto",
    "mirror.proto",
    "bootstrap.proto",
    "pulls.proto",
    // The review half of a pull request. Vendored 2026-08-31: `PullRequests.Merge`
    // refuses without a verified approval naming the tip, and `Forge.Append` is
    // the only way anything — the CLI, the console — records one. Without this
    // the console could open and merge pull requests it could never approve, so
    // every merge from the web answered "Nobody has approved this pull request".
    "forge.proto",
    // Clans (2026-10-05): ordinary accounts grouped, each giving part of its
    // own room; You → Clans calls them as the person, and gunnar decides.
    "clans.proto",
    // Per-namespace meters. Vendored 2026-09-05 for the namespace usage page.
    // gunnar serves `Usage` unconditionally — it is not behind any feature
    // gate, deliberately, because counting what a namespace holds is as useful
    // to a self-hoster reading their own disk as to anyone billing from it.
    "usage.proto",
    // The per-USER verbs. Vendored 2026-09-07: `Accounts.Gate` locks a user out
    // of everything at once and `Accounts.Purge` empties their account for good.
    // Both belong on the console because the appliance has no shell and the
    // alternative is an operator with a support ticket and no button —
    // `Repos.Delete` reaches one repository at a time, and an account can hold
    // hundreds plus tombstones and orphans nothing else on the box can see.
    "accounts.proto",
    "sso.proto",
    // The owner's enterprise list (2026-10-05): the account admin's audit log,
    // the operator's aggregates and the look the account admin approves, the
    // name filter's review queue and lists. gunnar decides; this relays.
    "enterprise.proto",
];

/// gunnar's own metered protos, compiled **only under the `metered` feature**: a client that
/// is not generated is a call that cannot be typed, which is a stronger guarantee than a route
/// that is not registered.
///
/// monetize's protos are NOT compiled here any more (RA3-4, 2026-10-26): `crate::mpb` is
/// monetize-proto itself. The four-of-eight subset this file used to vendor (to keep, say,
/// `Ledger.Sales` out of the binary) stopped being a guarantee when the console took
/// monetize-site-buy (A12), which links the whole of monetize-proto; what a console's bearer
/// may call is monetize's door to decide, by the bearer's scope.
const METERED_GUNNAR_PROTO_FILES: &[&str] = &["billing.proto", "entitlement.proto"];


fn main() -> Result<(), Box<dyn std::error::Error>> {
    // The README renderer's version, read off the workspace lock so the
    // cache key (`readme::cache::RENDERER`) retires every cached page when
    // nornir-docbook moves. One source of truth: the lock, not a copied
    // literal. Absent (a build outside the workspace) → "unlocked", which
    // still keys consistently within one binary.
    {
        let lock = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../Cargo.lock");
        println!("cargo:rerun-if-changed={}", lock.display());
        let text = std::fs::read_to_string(&lock).unwrap_or_default();
        let mut version = "unlocked".to_string();
        let mut lines = text.lines();
        while let Some(line) = lines.next() {
            if line.trim() == "name = \"nornir-docbook\"" {
                if let Some(v) = lines.next().and_then(|l| l.trim().strip_prefix("version = \"")) {
                    version = v.trim_end_matches('"').to_string();
                }
                break;
            }
        }
        println!("cargo:rustc-env=DOCBOOK_VERSION={version}");
    }

    stamp_build_identity();

    let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../..")
        .canonicalize()?;
    let include = root.join("proto");
    let files: Vec<PathBuf> = PROTO_FILES
        .iter()
        .map(|name| include.join("gunnar/v1").join(name))
        .collect();

    for file in &files {
        println!("cargo:rerun-if-changed={}", file.display());
    }

    // The paid half's contract, compiled beside gunnar's and only when asked
    // for. `println!("cargo:rerun-if-env-changed=…")` is what makes toggling
    // the feature re-run this script rather than reusing the descriptor set
    // from the previous build.
    println!("cargo:rerun-if-env-changed=CARGO_FEATURE_METERED");
    let mut files = files;
    if std::env::var_os("CARGO_FEATURE_METERED").is_some() {
        for name in METERED_GUNNAR_PROTO_FILES {
            let path = include.join("gunnar/v1").join(name);
            println!("cargo:rerun-if-changed={}", path.display());
            files.push(path);
        }
    }

    let descriptors = protox::compile(&files, [&include])?;

    tonic_prost_build::configure()
        // **Servers are generated too, and only the tests use them.**
        //
        // This was `false` for as long as the console had no way to stand a
        // gunnar up in a `cargo test`, and the cost of that showed: every
        // assertion about what the BFF does with gunnar's answer stopped at a
        // pure `render` function, so nothing proved a route was wired to the
        // RPC it claims, and nothing could make gunnar REFUSE on demand. A
        // refusal path that no test can reach is a refusal path nobody has
        // seen work (measure, never assert).
        //
        // `api::usage`'s tests implement `Usage` in-process, serve it on
        // loopback, and drive the console's own router against it. The
        // generated server traits are dead weight in the shipped binary and the
        // linker drops them; they are not dead weight in the suite.
        .build_server(true)
        .build_client(true)
        // Fakes in tests implement only the verbs they model; a verb added to a
        // vendored proto answers Unimplemented instead of breaking every fake.
        .generate_default_stubs(true)
        // Belt and braces: nothing on this path needs protoc, and a stray
        // PROTOC in the environment must not change the outcome.
        .skip_protoc_run()
        .compile_fds(descriptors)?;

    Ok(())
}

/// `GUNNAR_UI_BUILD_REF` + `GUNNAR_UI_BUILD_DATE`, and the re-run triggers so an
/// incremental build cannot carry a stale stamp. Verbatim gunnar-common's.
fn stamp_build_identity() {
    let git = |args: &[&str]| -> Option<String> {
        Command::new("git")
            .args(args)
            .output()
            .ok()
            .filter(|o| o.status.success())
            .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned())
            .filter(|s| !s.is_empty())
    };
    let describe = git(&[
        "describe",
        "--tags",
        "--always",
        "--dirty=-modified",
        "--abbrev=7",
    ]);
    println!(
        "cargo:rustc-env=GUNNAR_UI_BUILD_REF={}",
        describe.as_deref().unwrap_or("unknown")
    );

    println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH");
    let date = std::env::var("SOURCE_DATE_EPOCH")
        .ok()
        .and_then(|raw| raw.trim().parse::<i64>().ok())
        .or_else(|| git(&["log", "-1", "--format=%ct"]).and_then(|s| s.parse::<i64>().ok()))
        .map(iso8601_utc)
        .unwrap_or_else(|| "unknown".to_owned());
    println!("cargo:rustc-env=GUNNAR_UI_BUILD_DATE={date}");

    // `git rev-parse --git-path` resolves correctly in a worktree, a plain
    // checkout and under `GIT_DIR`; a path that does not exist is not named
    // (cargo treats a missing `rerun-if-changed` path as always-dirty).
    for what in ["HEAD", "refs"] {
        if let Some(path) = git(&["rev-parse", "--git-path", what]) {
            if std::path::Path::new(&path).exists() {
                println!("cargo:rerun-if-changed={path}");
            }
        }
    }
}

/// Unix seconds → `YYYY-MM-DDTHH:MM:SSZ`, in std alone (Hinnant's
/// `civil_from_days`; no `chrono` for eleven lines of arithmetic).
fn iso8601_utc(secs: i64) -> String {
    let days = secs.div_euclid(86_400);
    let rem = secs.rem_euclid(86_400);
    let (hh, mm, ss) = (rem / 3600, (rem % 3600) / 60, rem % 60);
    let z = days + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z.rem_euclid(146_097);
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
}