//! 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")
}