holger-estate-front/build.rs
//! Build script for `holger-estate-front`. Pure Rust, no subprocess.
//!
//! 1. **holger's AdminService client**, from holger's OWN `proto/holger.proto`
//! (protox + tonic-prost-build, no `protoc`) — the certification courier
//! speaks exactly the contract the boxes serve (`src/certify.rs`).
//! 2. **The build's commit** (`HOLGER_FRONT_GIT_HASH`), read from the on-disk
//! git plumbing — the same reader as `server/lib/build.rs` (copied, a build
//! script cannot link another's) — offered to the installer as `version`.
//! 3. **The built console** (feature `console`): holger-ui's built bundle embedded,
//! so the medium stays one binary (korp-start execs exactly one file) —
//! `HOLGER_FRONT_CONSOLE_DIST`, else the declared sibling: the METERED bundle
//! `../../holger-ui/dist-metered` under `metered` (holger-ui's row
//! `mvp-ui-web-metered`, with the sign-up, welcome and order pages its buy routes
//! serve), else the open `../../holger-ui/dist` (A28 M13). An absent console is NOT a build failure (the
//! tests build without it): the binary then knows it (`DIST_SOURCE` is
//! `None`), serves only the pass-through to holger, and says so; a `cargo:warning` says so here. (gunnar-front's build.rs pattern.)
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
let out = PathBuf::from(std::env::var("OUT_DIR")?);
// 1. The AdminService client.
let include = manifest.join("../proto");
let proto = include.join("holger.proto");
rerun_if_changed(&proto);
let descriptors = protox::compile([&proto], [&include])?;
tonic_prost_build::configure()
.build_server(false)
.build_client(true)
.skip_protoc_run()
.compile_fds(descriptors)?;
// 2. The commit.
let hash = git_short_hash().unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=HOLGER_FRONT_GIT_HASH={hash}");
// 3. The console.
println!("cargo:rerun-if-env-changed=HOLGER_FRONT_CONSOLE_DIST");
let console = std::env::var_os("CARGO_FEATURE_CONSOLE").is_some();
let declared = manifest.join(if std::env::var_os("CARGO_FEATURE_METERED").is_some() {
"../../holger-ui/dist-metered"
} else {
"../../holger-ui/dist"
});
let dist = std::env::var_os("HOLGER_FRONT_CONSOLE_DIST")
.map(PathBuf::from)
.unwrap_or_else(|| declared.clone());
let mut files = Vec::new();
if console {
if let Some(parent) = dist.parent() {
println!("cargo:rerun-if-changed={}", parent.display());
}
println!("cargo:rerun-if-changed={}", dist.display());
if dist.join("index.html").is_file() {
walk(&dist, &dist, &mut files);
for (_, abs) in &files {
println!("cargo:rerun-if-changed={}", abs.display());
}
} else {
println!("cargo:warning=holger-estate-front: no built console at {} (index.html absent) — this binary serves the console's API and /login, not the bundle; `cargo xtask estate-media` refuses a front medium without it ([estate-media-front-no-console])", dist.display());
}
}
let mut code = String::new();
let _ = writeln!(
code,
"/// The built console's files, `(path, bytes)`.\npub static FILES: &[(&str, &[u8])] = &["
);
for (rel, abs) in &files {
let _ = writeln!(
code,
" ({rel:?}, include_bytes!({:?})),",
abs.display().to_string()
);
}
let _ = writeln!(code, "];");
let source = (!files.is_empty()).then(|| {
dist.canonicalize()
.unwrap_or(dist.clone())
.display()
.to_string()
});
let _ = writeln!(code, "/// Where they were read from at build time; `None` = not embedded.\npub const DIST_SOURCE: Option<&str> = {source:?};");
let _ = writeln!(code, "/// The declared sibling this build reads when `HOLGER_FRONT_CONSOLE_DIST` is unset.\npub const DECLARED: &str = {:?};", declared.display().to_string());
fs::write(out.join("console_dist.rs"), code)?;
Ok(())
}
fn walk(dir: &Path, base: &Path, out: &mut Vec<(String, PathBuf)>) {
let Ok(rd) = fs::read_dir(dir) else { return };
let mut entries: Vec<_> = rd.flatten().collect();
entries.sort_by_key(|e| e.file_name());
for e in entries {
let p = e.path();
if p.is_dir() {
walk(&p, base, out);
} else if let Ok(rel) = p.strip_prefix(base) {
out.push((
rel.to_string_lossy().replace('\\', "/"),
p.canonicalize().unwrap_or(p.clone()),
));
}
}
}
/// Best-effort short commit hash, reading only the on-disk git plumbing.
fn git_short_hash() -> Option<String> {
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").ok()?);
let git_dir = find_git_dir(&manifest_dir)?;
let common_dir = common_dir(&git_dir);
let head_path = git_dir.join("HEAD");
rerun_if_changed(&head_path);
let head = fs::read_to_string(&head_path).ok()?;
let head = head.trim();
let full = if let Some(refname) = head.strip_prefix("ref:") {
let refname = refname.trim();
resolve_ref(refname, &git_dir, &common_dir)?
} else {
// Detached HEAD: the file holds the commit id directly.
head.to_string()
};
let full = full.trim();
if full.is_empty() {
return None;
}
// Short sha, matching `git rev-parse --short` default width.
Some(full.chars().take(7).collect())
}
/// Walk up from `start` until a `.git` directory or `.git` gitdir-file is found.
fn find_git_dir(start: &Path) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(d) = dir {
let dot_git = d.join(".git");
if dot_git.is_dir() {
return Some(dot_git);
}
if dot_git.is_file() {
// Linked worktree: `.git` is a file `gitdir: <path>`.
let contents = fs::read_to_string(&dot_git).ok()?;
let target = contents.trim().strip_prefix("gitdir:")?.trim();
let target = d.join(target);
return Some(normalize(&target));
}
dir = d.parent();
}
None
}
/// The shared common git dir for a (possibly linked-worktree) git dir. Worktrees
/// carry a `commondir` pointer; the main working copy is its own common dir.
fn common_dir(git_dir: &Path) -> PathBuf {
let commondir_file = git_dir.join("commondir");
if let Ok(contents) = fs::read_to_string(&commondir_file) {
let rel = contents.trim();
return normalize(&git_dir.join(rel));
}
git_dir.to_path_buf()
}
/// Resolve a `refs/…` name to a commit id: loose ref file first (worktree dir,
/// then common dir), then `packed-refs`.
fn resolve_ref(refname: &str, git_dir: &Path, common_dir: &Path) -> Option<String> {
for base in [git_dir, common_dir] {
let loose = base.join(refname);
if loose.is_file() {
rerun_if_changed(&loose);
if let Ok(s) = fs::read_to_string(&loose) {
let s = s.trim();
if !s.is_empty() {
return Some(s.to_string());
}
}
}
}
// packed-refs lives in the common dir.
let packed = common_dir.join("packed-refs");
if packed.is_file() {
rerun_if_changed(&packed);
if let Ok(contents) = fs::read_to_string(&packed) {
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with('^') {
continue;
}
if let Some((sha, name)) = line.split_once(' ') {
if name.trim() == refname {
return Some(sha.trim().to_string());
}
}
}
}
}
None
}
/// Lightweight lexical `.`/`..` normalization (avoids `canonicalize`, which fails
/// on not-yet-existing paths and would resolve symlinks).
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for comp in path.components() {
match comp {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
fn rerun_if_changed(path: &Path) {
println!("cargo:rerun-if-changed={}", path.display());
}